如何寫出更優雅的 React 組件 - 設計思維篇
我們從設計思維的角度來談談如何設計一個更優雅的 React 組件。
基本原則
單一職責
單一職責的原則是讓一個模塊都專注于一個功能,即讓一個模塊的責任盡量少。若一個模塊功能過多,則應當拆分為多個模塊,這樣更有利于代碼的維護。
就如同一個人最好專注做一件事,將負責的每件事情做到最好。而組件也是如此,要求將組件限制在一個合適可被復用的粒度。如果一個組件的功能過于復雜就會導致代碼量變大,這個時候就需要考慮拆分為職責單一的小組件。每個小組件只關心自己的功能,組合起來就能滿足復雜需求。
單一組件更易于維護和測試,但也不要濫用,必要的時候才去拆分組件,粒度最小化是一個極端, 可能會導致大量模塊, 模塊離散化也會讓項目變得難以管理。
劃分邊界
如何拆分組件,如果兩個組件的關聯過于緊密,從邏輯上無法清晰定義各自的職責,那么這兩個組件不應該被拆分。否則各自職責不清,邊界不分,則會產生邏輯混亂的問題。那么拆分組件最關鍵在于確定好邊界,通過抽象化的參數通信,讓每個組件發揮出各自特有的能力。
高內聚/低耦合
高質量的組件要滿足高內聚和低耦合的原則。
高內聚意思是把邏輯緊密相關的內容聚合在一起。在 jQuery 時代,我們將一個功能的資源放在了 js、html、css 等目錄,開發時,我們需要到不同的目錄中尋找相關的邏輯資源。再比如 Redux 的建議將 actions、reducers、store 拆分到不同的地方,將一個很簡單的功能邏輯分散開來。這很不滿足高內聚的特點。拋開 Redux,在 React 組件化的思維本身很滿足高內聚的原則,即一個組件是一個自包含的單元, 它包含了邏輯/樣式/結構, 甚至是依賴的靜態資源。
低耦合指的是要降低不同組件之間的依賴關系,讓每個組件要盡量獨立。也就是說平時寫代碼都是為了低耦合而進行。通過責任分離、劃分邊界的方式,將復雜的業務解耦。
遵循基本原則好處:
- 降低單個組件的復雜度,可讀性高
- 降低耦合,不至于牽一發而動全身
- 提高可復用性
- 邊界透明,易于測試
- 流程清晰,降低出錯率,并調試方便
進階設計
受控/非受控狀態
在 React 表單管理中有兩個經常使用的術語: 受控輸入和非受控輸入。簡單來說,受控的意思是當前組件的狀態成為該表單的唯一數據源。表明這個表單的值在當前組件的控制中,并只能通過 setState 來更新。
受控/非受控的概念在組件設計上極為常見。受控組件通常以 value 與 onChange 成對出現。傳入到子組件中,子組件無法直接修改這個 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]);
- }
上面代碼為了保持 labelList 與 value 的響應,使用了 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 名稱的心智負擔。否則組件傳參就會如同一根一根數面條一樣痛苦。
舉個例子,經典的 value 與 onChange 的 API 可以在各個不同的表單域上出現??梢酝ㄟ^包裝的方式導出更多高階組件,這些高階組件又可以被表單管理組件所容納。
我們可以約定在各個組件上比如 visible、onVisibleChange、bordered、size、allowClear 這樣的 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>;
- }
所以傳遞給 Context 的 value 做一下記憶緩存:
- 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 用于使用一個值為函數的 prop 在 React 組件之間的代碼共享。Render Props 其實和高階組件一樣,是為了給純函數組件加上 state,響應 react 的生命周期。它以一種回調的方式,傳入一個函數給子組件調用,獲得狀態可以與父組件交互。
鏈式 Hooks
在 React Hooks 時代,高階組件和 render props 使用頻率會下降很多,很多場景下會被 hooks 所替代。
我們看看 hooks 的規則:
- 只在最頂層使用 Hook
- 不在循環,條件或嵌套函數中調用 Hook
- 只在 React 函數中調用 Hook
hook 都是按照一定的順序調用,因為其內部使用鏈表實現。我們可以通過 單一職責 的概念將每個 hook 作為模塊去呈現,通過組合自定義 hook 就可以實現漸進式功能增強。如同 rxjs 一樣具備鏈式調用的同時又可以操作其狀態與生命周期。
示例:
- function Component() {
- const value = useSelectStore();
- const keyboardEvents = useInteractive(value);
- const label = useSelectPresent(keyboardEvents);
- // ...
- }
用過語義化組合可以選擇使用需要的 hooks 來創造出適應各個需求的自定義組件。在某種意義上說最小單元不止是組件,也可以是自定義 hooks。
結語
希望每個人都能寫出高質量的組件。