- Published on
透過 SOLID 原則撰寫 React clean-code
- Authors
- Name
- Luke Lin
在撰寫 React 時,對於思考怎麼封裝元件以及封裝邏輯,顯示邏輯與資料邏輯應該如何分離的概念還不熟悉時,可以透過 SOLID 原則來規範自己並提高產出程式碼的品質。
根據維基百科的定義
In software engineering, SOLID is a mnemonic acronym for five design principles intended to make object-oriented designs more understandable, flexible, and maintainable.
在軟體工程中,
SOLID是對面物件導向設計的五個設計原則之簡寫,目的在於設計更容易理解、靈活且易於維護的程式碼。
- 單一職責原則 (Single Responsibility. SRP)
- 開放封閉原則 (Open-Close, OCP)
- 里氏替換原則 (Liskov Substitution, LSP)
- 介面分離原則 (Interface Segregation, ISP)
- 依賴倒置原則 (Dependency inversion, DIP)
#單一職責原則
There should never be more than one reason for a class to change." In other words, every class should have only one responsibility.
每個類別只應負責一個職責
Bad 範例
import axios from 'axios'
import { useEffect, useMemo, useState } from 'react'
import { Product } from './product'
import { Rating } from 'react-simple-star-rating'
export function Bad() {
const [products, setProducts] = useState([])
const [filterRate, setFilterRate] = useState(1)
const fetchProducts = async () => {
const response = await axios.get('https://fakestoreapi.com/products')
if (response && response.data) setProducts(response.data)
}
useEffect(() => {
fetchProducts()
}, [])
const handleRating = (rate: number) => {
setFilterRate(rate)
}
const filteredProducts = useMemo(
() => products.filter((product: any) => product.rating.rate > filterRate),
[products, filterRate]
)
return (
<div className="flex h-full flex-col">
<div className="flex flex-col items-center justify-center">
<span className="font-semibold">Minimum Rating </span>
<Rating initialValue={filterRate} SVGclassName="inline-block" onClick={handleRating} />
</div>
<div className="flex h-full flex-wrap justify-center">
{filteredProducts.map((product: any) => (
<div className="...">
<a href="#">
<img className="h-48 rounded-t-lg p-8" src={product.image} alt="product image" />
</a>
<div className="flex flex-col px-5 pb-5">
<a href="#">
<h5 className="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
{product.title}
</h5>
</a>
<div className="mt-2.5 mb-5 flex flex-1 items-center">
{Array(parseInt(product.rating.rate))
.fill('')
.map((_, idx) => (
<svg
aria-hidden="true"
className="h-5 w-5 text-yellow-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<title>First star</title>
<path d="..."></path>
</svg>
))}
<span className="...">{parseInt(product.rating.rate)}</span>
</div>
<div className="items-between flex flex-col justify-around">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
${product.price}
</span>
<a
href="#"
className="..."
// onClick={onAddToCart}
>
Add to cart
</a>
</div>
</div>
</div>
))}
</div>
</div>
)
}
Good 範例
import axios from 'axios'
import { useEffect, useMemo, useState } from 'react'
import { Product } from './product'
import { Rating } from 'react-simple-star-rating'
import { Filter, filterProducts } from './filter'
import { useProducts } from './hooks/useProducts'
import { useRateFilter } from './hooks/useRateFilter'
export function Good() {
const { products } = useProducts()
const { filterRate, handleRating } = useRateFilter()
return (
<div className="flex h-full flex-col">
<Filter filterRate={filterRate as number} handleRating={handleRating} />
<div className="flex h-full flex-wrap justify-center">
{filterProducts(products, filterRate).map((product: any) => (
<Product product={product} />
))}
</div>
</div>
)
}
對比 Bad 範例與 Good 範例
Bad 範例將資料邏輯與顯示邏輯全部塞在同一個元件之中,在剛開始構思元件時,可以往幾個方向思考
- 可以將
資料邏輯以及顯示邏輯拆分出來 - 將
資料邏輯的處理分類出來寫成hook,讓每個Custom Hook只處理同一件事情的資料邏輯 - 將
顯示邏輯拆成更小的元件,因為在資料邏輯層我們並不應該需要關心顯示邏輯的程式碼,只負責渲染純元件,不處理資料邏輯
Good 範例可以看到:
顯示邏輯的部份
- 原本的 Filter 元件拆出來寫成小組件並將需要要用到的
filterRate和handleRating當作props往下傳
<div className="flex flex-col items-center justify-center">
<span className="font-semibold">Minimum Rating </span>
<Rating initialValue={filterRate} SVGclassName="inline-block" onClick={handleRating} />
</div>
// => 拆出來
<Filter filterRate={filterRate as number} handleRating={handleRating} />
map渲染的一整坨顯示邏輯拆分成<Product product={product} />
<div className="flex h-full flex-wrap justify-center">
{filterProducts(products, filterRate).map((product: any) => (
<Product product={product} />
))}
</div>
- 原本用
useMemo實作的filteredProducts拉出來放在組件之外並改寫成Pure Function
const filteredProducts = useMemo(
() => products.filter((product: any) => product.rating.rate > filterRate),
[products, filterRate]
)
// => 拆出來
// filter.tsx
export function filterProducts(products: any[], rate: number) {
return products.filter((product: any) => product.rating.rate > rate)
}
顯示邏輯的部分
- 將
products的資料邏輯處理拆分成hooks
// useProducts.tsx
import axios from 'axios'
import { useEffect, useState } from 'react'
export const useProducts = () => {
const [products, setProducts] = useState<any[]>([])
const fetchProducts = async () => {
const response = await axios.get('https://fakestoreapi.com/products')
if (response && response.data) setProducts(response.data)
}
useEffect(() => {
fetchProducts()
}, [])
return { products }
}
- 將
filterRate的資料邏輯處理拆分成hooks
// useRateFilter.tsx
import { useState } from 'react'
export function useRateFilter() {
const [filterRate, setFilterRate] = useState(1)
const handleRating = (rate: number) => {
setFilterRate(rate)
}
return { filterRate, handleRating }
}
在看一次 Good 範例,比起原本的大雜燴,每個功能都只負責處理好一件事,程式碼也因為 單一職責原則 變得更容易理解
import axios from 'axios'
import { useEffect, useMemo, useState } from 'react'
import { Product } from './product'
import { Rating } from 'react-simple-star-rating'
import { Filter, filterProducts } from './filter'
import { useProducts } from './hooks/useProducts'
import { useRateFilter } from './hooks/useRateFilter'
export function Good() {
const { products } = useProducts()
const { filterRate, handleRating } = useRateFilter()
return (
<div className="flex h-full flex-col">
<Filter filterRate={filterRate as number} handleRating={handleRating} />
<div className="flex h-full flex-wrap justify-center">
{filterProducts(products, filterRate).map((product: any) => (
<Product product={product} />
))}
</div>
</div>
)
}
#開放封閉原則
Software entities ... should be open for extension, but closed for modification.
對擴展保持開放性,對修改具有封閉性
import { HiOutlineArrowNarrowRight, HiOutlineArrowNarrowLeft } from 'react-icons/hi'
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string
role?: 'back' | 'forward'
}
export function Button(props: IButtonProps) {
const { text, role } = props
return (
<button {...props}>
{text}
<div>
{role === 'forward' && <HiOutlineArrowNarrowRight />}
{role === 'back' && <HiOutlineArrowNarrowLeft />}
{/* 如果需要新增規則,必須修改程式碼來新增 */}
</div>
</button>
)
}
使用 Button
import { Button } from './button'
export function Demo() {
return (
<div>
<Button text="Go Home" role="forward" />
<Button text="Go Back" role="back" />
</div>
)
}
以上方實作 Button 為範例,可以看到 IButtonProps 中透過定義 role 來新增 icon,但當需新增更多 icon 時,必須在 interface 以及實際的 JSX 中修改元件的程式碼,這打破了 OCP 對 修改具封閉性 的原則
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string
role?: 'back' | 'forward' | 'not-found'
}
...
<div >
{role === 'forward' && <HiOutlineArrowNarrowRight />}
{role === 'back' && <HiOutlineArrowNarrowLeft />}
{/* 如果需要新增規則,必須修改程式碼來新增 */}
{role === 'not-found' && <AnotherIcon />}
</div>
可以將元件修改為在使用時才賦予希望渲染的 icon,透過 保持擴展性 來保持對修改的封閉性
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string
icon?: React.ReactNode
}
export function Button(props: IButtonProps) {
const { text, icon } = props
return (
<button {...props}>
{text}
<div>{icon}</div>
</button>
)
}
使用 Button
import { Button } from './button'
import { HiOutlineArrowNarrowRight, HiOutlineArrowNarrowLeft } from 'react-icons/hi'
export function OCP() {
return (
<div>
<Button text="Go Home" icon={<HiOutlineArrowNarrowRight />} />
<Button text="Go Back" icon={<HiOutlineArrowNarrowLeft />} />
</div>
)
}
#里氏替換原則
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
當 子類別 是基於某個 基礎類別 衍伸而來,子類別應能夠在不知情的情況下使用該 基礎類別
如果 S 是 T 的子類型,則適用於 T 對象的情況也適用於 S 對象
範例透過原生的 input 時做了一個 SearchInput 元件,並且 ISearchInputProps 繼承 React.InputHTMLAttributes<HTMLInputElement>
因此根據 LSP 原則,需要寫 restProps 來拿到除了解構出來的參數以外的所有 props,並且傳給原生的 input,讓 SearchInput 可以透過 props 使用繼承的對象
import cx from 'clsx'
interface ISearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
isLarge?: boolean
}
export function SearchInput(props: ISearchInputProps) {
const { value, onChange, isLarge, ...restProps } = props
return (
<div className="flex w-10/12">
<div className="relative w-full">
<input
type="search"
id="default-search"
className={cx(isLarge && 'text-3xl')}
placeholder="Search for the right one..."
required
value={value}
onChange={onChange}
{...restProps}
/>
</div>
</div>
)
}
import React, { useState } from 'react'
import { SearchInput } from './searchInput'
export function LSP() {
const [value, setValue] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}
return <SearchInput value={value} onChange={handleChange} isLarge />
}
#介面分離原則
Clients should not be forced to depend upon interfaces that they do not use.
不應強迫依賴沒有使用的介面。
// Product.tsx
import ProductImage from './productImage'
export interface IProduct {
id: string
title: string
price: number
rating: { rate: number }
image: string
}
interface IProductProps {
product: IProduct
}
export function Product(props: IProductProps) {
const { product } = props
return (
<div>
<a>
<ProductImage product={product} />
</a>
</div>
)
}
// ProductImage.tsx
import { IProduct } from './product'
interface IProductImageProps {
product: IProduct
}
const ProductImage = (props: IProductImageProps) => {
const {
product: { id, image, price, rating, title },
} = props
return <img src={image} alt="product image" />
}
export default ProductImage
上方的範例中的 ProductImage 元件只關心 image props ,但卻接收了整包的 IProduct 資料進來
根據 ISP 原則,應將 ProductImage 改為只接收它關心的 props imageUrl ,讓元件變得更加乾淨簡潔
import { IProduct } from './product'
interface IProductImageProps {
imageUrl: string
}
const ProductImage = (props: IProductImageProps) => {
const { imageUrl } = props
return <img className="h-48 rounded-t-lg p-8" src={imageUrl} alt="product image" />
}
export default ProductImage
#依賴倒置原則
Depend upon abstractions, [not] concretions.
實體應更加依賴於抽象化而非具體化
範例設計了一個可以覆用的 Form 元件,每當按下 Submit 時,會送出 login 的 api 會發現有個問題,沒辦法改變 Form 元件按下 Submit 觸發別的行為
import axios from 'axios'
import React, { useState } from 'react'
export function Form() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await axios.post('https://localhost:3000/login', {
email,
password,
})
}
return (
<section>
<h1>Sign in to your account</h1>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Your email</label>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email"
placeholder="name@company.com"
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
id="password"
placeholder="••••••••"
/>
</div>
<button type="submit">Sign in</button>
</form>
</section>
)
}
import { Form } from './form'
export function DIP() {
return <Form />
}
這時可以透過將邏輯抽象化來讓 Form 元件變得具彈性,將 handleSubmit 透過 props 往下傳,讓元件可以根據不同的場景來決定 Submit 後的行為
可以大大的增加在設計 React 元件時的 抽象概念 與 可重用性
import axios from 'axios'
import React, { useState } from 'react'
interface IFormProps {
onSubmit: (email: string, password: string) => void
}
export function Form(props: IFormProps) {
const { onSubmit } = props
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email, password)
}
return (
<section>
<h1>Sign in to your account</h1>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Your email</label>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email"
placeholder="name@company.com"
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
id="password"
placeholder="••••••••"
/>
</div>
<button type="submit">Sign in</button>
</form>
</section>
)
}
import axios from 'axios'
import { Form } from './form'
export function DIP() {
const handleSubmit = async (email: string, password: string) => {
// 抽象化之後可以根據不同場景呼叫其他 api 或是做其他事情
await axios.post('https://localhost:3000/login2', {
email,
password,
})
}
return <Form onSubmit={handleSubmit} />
}