如何寫出更優雅的 React 組件 - 設計思維篇

我們從設計思維的角度來談談如何設計一個更優雅的 React 組件。

基本原則

單一職責

單一職責的原則是讓一個模塊都專注於一個功能,即讓一個模塊的責任儘量少。若一個模塊功能過多,則應當拆分爲多個模塊,這樣更有利於代碼的維護。

就如同一個人最好專注做一件事,將負責的每件事情做到最好。而組件也是如此,要求將組件限制在一個合適可被複用的粒度。如果一個組件的功能過於複雜就會導致代碼量變大,這個時候就需要考慮拆分爲職責單一的小組件。每個小組件只關心自己的功能,組合起來就能滿足複雜需求。

單一組件更易於維護和測試,但也不要濫用,必要的時候纔去拆分組件,粒度最小化是一個極端, 可能會導致大量模塊, 模塊離散化也會讓項目變得難以管理。

劃分邊界

如何拆分組件,如果兩個組件的關聯過於緊密,從邏輯上無法清晰定義各自的職責,那麼這兩個組件不應該被拆分。否則各自職責不清,邊界不分,則會產生邏輯混亂的問題。那麼拆分組件最關鍵在於確定好邊界,通過抽象化的參數通信,讓每個組件發揮出各自特有的能力。

高內聚 / 低耦合

高質量的組件要滿足高內聚和低耦合的原則。

高內聚意思是把邏輯緊密相關的內容聚合在一起。在 jQuery 時代,我們將一個功能的資源放在了 jshtmlcss 等目錄,開發時,我們需要到不同的目錄中尋找相關的邏輯資源。再比如 Redux 的建議將 actionsreducersstore 拆分到不同的地方,將一個很簡單的功能邏輯分散開來。這很不滿足高內聚的特點。拋開 Redux,在 React 組件化的思維本身很滿足高內聚的原則,即一個組件是一個自包含的單元, 它包含了邏輯 / 樣式 / 結構, 甚至是依賴的靜態資源。

低耦合指的是要降低不同組件之間的依賴關係,讓每個組件要儘量獨立。也就是說平時寫代碼都是爲了低耦合而進行。通過責任分離、劃分邊界的方式,將複雜的業務解耦。

遵循基本原則好處:

  1. 降低單個組件的複雜度,可讀性高

  2. 降低耦合,不至於牽一髮而動全身

  3. 提高可複用性

  4. 邊界透明,易於測試

  5. 流程清晰,降低出錯率,並調試方便

進階設計

受控 / 非受控狀態

React 表單管理中有兩個經常使用的術語: 受控輸入和非受控輸入。簡單來說,受控的意思是當前組件的狀態成爲該表單的唯一數據源。表明這個表單的值在當前組件的控制中,並只能通過 setState 來更新。

受控 / 非受控的概念在組件設計上極爲常見。受控組件通常以 valueonChange 成對出現。傳入到子組件中,子組件無法直接修改這個 value,只能通過 onChange 回調告訴父組件更新。非受控組件則可以傳入 defaultValue 屬性來提供初始值。

Modal 組件的 visible 受控 / 非受控:

// 受控
<Modal visible={visible} onVisibleChange={handleVisibleChange} />

// 非受控
<Modal defaultVisible={visible} />

若該狀態作爲組件的核心邏輯時,那麼它應該支持受控,或兼容非受控模式。若該狀態爲次要邏輯,可以根據實際情況選擇性支持受控模式。

例如 Select 組件處理受控與非受控邏輯:

function Select(props: SelectProps) {
  // value 和 onChange 爲核心邏輯,支持受控。兼容傳入 defaultValue 成爲非受控
  // defaultOpen 爲次要邏輯,可以非受控
  const { value: controlledValue, onChange: onControlledChange, defaultValue, defaultOpen } = props;
  // 非受控模式使用內部 state
  const [innerValue, onInnerValueChange] = React.useState(defaultValue);
  // 次要邏輯,選擇框展開狀態
  const [visible, setVisible] = React.useState(defaultOpen);

  // 通過檢測參數上是否包含 value 的屬性判斷是否爲受控,儘管 value 爲 undefined
  const shouldControlled = Reflect.has(props, 'value');

  // 支持受控和非受控處理
  const value = shouldControlled ? controlledValue : innerValue;
  const onChange = shouldControlled ? onControlledChange : onInnerValueChange;

  // ...
}

配合 hooks 受控

一個組件是否受控,通常來說針對其本身的支持,現在自定義 hooks 的出現可以突破此限制。複雜的組件,配合 hooks 會更加得心應手。

封裝此類組件,將邏輯放在 hooks 中,組件本身則被掏空,其作用是主要配合自定義 hooks 進行渲染。

function Demo() {
  // 主要的邏輯在自定義 hook 中
  const sheet = useSheetTable();

  // 組件本身只接收一個參數,爲 hook 的返回值
  <SheetTable sheet={sheet} />;
}

這樣做的好處是邏輯與組件徹底分離,更利於狀態提升,可以直接訪問 sheet 所有的狀態,這種模式受控會更加徹底。簡單的組件也許不適合做成這種模式,本身沒這麼大的受控需求,這樣封裝會增加一些使用複雜度。

單一數據源

單一數據源原則,指組件的一個狀態以 props 的形式傳給子組件,並且在傳遞過程中具有延續性。也就是說狀態在傳遞到各個子組件中不用 useState 去接收,這會使傳遞的狀態失去響應特性。

以下代碼違背了單一數據源的原則,因爲在子組件中定義了狀態 searchResult 緩存了搜索結果,這會導致 options 參數在 onFilter 後與子組件失去響應特性。

function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
  // 緩存搜索結果
  const [searchResult, setSearchResult] = React.useState<Option[] | undefined>(undefined);

  return (
    <div>
      <Input.Search
        onSearch={(keyword) => {
          setSearchResult(keyword ? onFilter(keyword) : undefined);
        }}
      />

      <OptionList options={searchResult ?? options} />
    </div>
  );
}

應當遵循單一數據源的原則。將關鍵詞存爲 state,通過響應 keyword 變化生成新的 options

function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
  // 搜索關鍵詞
  const [keyword, setKeyword] = React.useState<string | undefined>(undefined);
  // 使用過濾條件篩選數據
  const currentOptions = React.useMemo(() => {
    return keyword && onFilter ? options.filter((n) => onFilter(keyword, n)) : options;
  }, [options, onFilter, keyword]);

  return (
    <div>
      <Input.Search
        onSearch={(text) => {
          setKeyword(text);
        }}
      />
      <OptionList options={currentOptions} />
    </div>
  );
}

減少 useEffect

useEffect 即副作用。如果沒有必要,儘量減少 useEffect 的使用。React 官方將這個 API 的使用場景歸納爲改變 DOM、添加訂閱、異步任務、記錄日誌等。先來看一段代碼:

function Demo({ value, onChange }) {
  const [labelList, setLabelList] = React.useState(() => value.map(customFn));

  // value 變化後,使內部狀態更新
  React.useEffect(() => {
    setLabelList(value.map(customFn));
  }, [value]);
}

上面代碼爲了保持 labelListvalue 的響應,使用了 useEffect。也許你現在看這個代碼的本身能正常執行。如果現在有個需求:labelList 變化後也同步到 value,字面理解下你可能會寫出如下代碼:

React.useEffect(() => {
  onChange(labelList.map(customFn));
}, [labelList]);

你會發現應用進入了永久循環中,瀏覽器失去控制,這就是沒必要的 useEffect 。可以理解爲不做改變 DOM、添加訂閱、異步任務、記錄日誌等場景的操作,就儘量別用 useEffect,比如監聽 state 再改變別的 state。結局就是應用複雜度達到一定程度,不是瀏覽器先崩潰,就是開發者崩潰。

那有好的方式解決嗎?我們可以將邏輯理解爲 動作 + 狀態。其中 狀態 的變更只能由 動作 觸發。這就能很好解決上面代碼中的問題,將 labelList 的狀態提升,找出改變 value動作,封裝一個聯動改變 labelList 的方法給各個 動作,越複雜的場景這種模式越高效。

通用性原則

通用性設計其實是一定意義上放棄對 DOM 的掌控, 而將 DOM 結構的決定權轉移給開發者,比如預留自定義渲染。

舉個例子, antd 中的 Table通過 render 函數將每個單元格渲染的決定權交給使用者,這樣極大提高了組件的可擴展性:

const columns = [
  {
    title: '名稱',
    dataIndex: 'name',
    width: 200,
    render(text) {
      return<em>{text}</em>;
    },
  },
];

<Table columns={columns} />;

優秀的組件,會通過參數預設默認的渲染行爲,同時支持自定義渲染。

統一 API

當各個組件數量變多之後,組件與組件直接可能存在某種契合的關係,我們可以統一某種行爲 API 的一致性,這樣可以降低使用者對各個組件 API 名稱的心智負擔。否則組件傳參就會如同一根一根數麪條一樣痛苦。

舉個例子,經典的 valueonChange 的 API 可以在各個不同的表單域上出現。可以通過包裝的方式導出更多高階組件,這些高階組件又可以被表單管理組件所容納。

我們可以約定在各個組件上比如 visibleonVisibleChangeborderedsizeallowClear 這樣的 API,使其在各個組件中保持一致性。

不可變狀態

對於函數式編程範式的 React 來說,不可變狀態與單向數據流是其核心概念。如果一個複雜的組件手動保持不可變狀態繁雜程度也是相當高,這裏推薦使用 immer 做不可變數據管理。如果一個對象內部屬性變化了,那麼整個對象就是全新的,不變的部分會保持引用,這樣天生契合 React.memo 做淺對比,減少 shouldComponentUpdate 比較的性能消耗。

注意陷阱

React 在某個意義上說一個狀態機,每次 render 所定義的變量會重新聲明。

Context 陷阱
exportfunction ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);

  // 這裏每一次渲染 ThemeProvider, 都會創建一個新的 value 從而導致強制渲染所有使用該 Context 的組件
  return<Context.Provider value={{ theme, switchTheme }}>{props.children}</Context.Provider>;
}

所以傳遞給 Contextvalue 做一下記憶緩存:

exportfunction ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  const value = React.useMemo(() => ({ theme, switchTheme }), [theme]);

  return<Context.Provider value={value}>{props.children}</Context.Provider>;
}
render props 陷阱

render 方法裏創建函數,那麼使用 render props 會抵消使用 React.memo 帶來的優勢。因爲淺比較 props 的時候總會得到 false,並且在這種情況下每一個 render 對於 render props 將會生成一個新的值。

<CustomComponent renderFooter={() => <em>Footer</em>} />

可以使用 useMethods 代替:github.com/MinJieLiu/heo/blob/main/src/useMethods.tsx

社區實踐

高階組件 / 裝飾器模式

const HOC = (Component) => EnhancedComponent;

裝飾器模式是在不改變原對象的基礎上,通過對其進行包裝擴展(添加屬性或方法),使原有對象可以滿足用戶的更復雜需求,滿足開閉原則,也不會破壞現有的操作。組件是將 props 轉化成 UI ,然而高階組件將一個組件轉化成另外一個組件。

例如漫威電影中的鋼鐵俠,本身就是一個普通人,可以行走、跳躍。經過戰衣的裝飾,可以跑得更快,還具備飛行能力。

在普通組件中包裝一個 withRouter(react-router),就具備了操作路由的能力。包裝一個 connect(react-redux),就具備了操作全局數據的能力。

Render Props

<Component render={(props) => <EnhancedComponent {...props} />} />

Render Props 用於使用一個值爲函數的 propReact 組件之間的代碼共享。Render Props 其實和高階組件一樣,是爲了給純函數組件加上 state,響應 react 的生命週期。它以一種回調的方式,傳入一個函數給子組件調用,獲得狀態可以與父組件交互。

鏈式 Hooks

React Hooks 時代,高階組件和 render props 使用頻率會下降很多,很多場景下會被 hooks 所替代。

我們看看 hooks 的規則:

hook 都是按照一定的順序調用,因爲其內部使用鏈表實現。我們可以通過 單一職責 的概念將每個 hook 作爲模塊去呈現,通過組合自定義 hook 就可以實現漸進式功能增強。如同 rxjs 一樣具備鏈式調用的同時又可以操作其狀態與生命週期。

示例:

function Component() {
  const value = useSelectStore();
  const keyboardEvents = useInteractive(value);
  const label = useSelectPresent(keyboardEvents);
  // ...
}

用過語義化組合可以選擇使用需要的 hooks 來創造出適應各個需求的自定義組件。在某種意義上說最小單元不止是組件,也可以是自定義 hooks

結語

希望每個人都能寫出高質量的組件。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/t8NChmdca_YUYSSObt1kHw