如何寫出更優雅的 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
。
結語
希望每個人都能寫出高質量的組件。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/t8NChmdca_YUYSSObt1kHw