MILLETBARD
Published on

透過 SOLID 原則撰寫 React clean-code

Authors
  • avatar
    Name
    Luke Lin
img01

在撰寫 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 是對面物件導向設計的五個設計原則之簡寫,目的在於設計更容易理解、靈活且易於維護的程式碼。

#單一職責原則

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 範例可以看到:

顯示邏輯的部份

  1. 原本的 Filter 元件拆出來寫成小組件並將需要要用到的 filterRatehandleRating 當作 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} />
  1. map 渲染的一整坨 顯示邏輯 拆分成 <Product product={product} />
<div className="flex h-full flex-wrap justify-center">
  {filterProducts(products, filterRate).map((product: any) => (
    <Product product={product} />
  ))}
</div>
  1. 原本用 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)
}

顯示邏輯的部分

  1. 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 }
}
  1. 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.

子類別 是基於某個 基礎類別 衍伸而來,子類別應能夠在不知情的情況下使用該 基礎類別

img

如果 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 時,會送出 loginapi 會發現有個問題,沒辦法改變 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} />
}