如何寫(xiě)出更優(yōu)雅的 React 組件 - 設(shè)計(jì)思維篇
我們從設(shè)計(jì)思維的角度來(lái)談?wù)勅绾卧O(shè)計(jì)一個(gè)更優(yōu)雅的 React 組件。
基本原則
單一職責(zé)
單一職責(zé)的原則是讓一個(gè)模塊都專(zhuān)注于一個(gè)功能,即讓一個(gè)模塊的責(zé)任盡量少。若一個(gè)模塊功能過(guò)多,則應(yīng)當(dāng)拆分為多個(gè)模塊,這樣更有利于代碼的維護(hù)。
就如同一個(gè)人最好專(zhuān)注做一件事,將負(fù)責(zé)的每件事情做到最好。而組件也是如此,要求將組件限制在一個(gè)合適可被復(fù)用的粒度。如果一個(gè)組件的功能過(guò)于復(fù)雜就會(huì)導(dǎo)致代碼量變大,這個(gè)時(shí)候就需要考慮拆分為職責(zé)單一的小組件。每個(gè)小組件只關(guān)心自己的功能,組合起來(lái)就能滿足復(fù)雜需求。
單一組件更易于維護(hù)和測(cè)試,但也不要濫用,必要的時(shí)候才去拆分組件,粒度最小化是一個(gè)極端, 可能會(huì)導(dǎo)致大量模塊, 模塊離散化也會(huì)讓項(xiàng)目變得難以管理。
劃分邊界
如何拆分組件,如果兩個(gè)組件的關(guān)聯(lián)過(guò)于緊密,從邏輯上無(wú)法清晰定義各自的職責(zé),那么這兩個(gè)組件不應(yīng)該被拆分。否則各自職責(zé)不清,邊界不分,則會(huì)產(chǎn)生邏輯混亂的問(wèn)題。那么拆分組件最關(guān)鍵在于確定好邊界,通過(guò)抽象化的參數(shù)通信,讓每個(gè)組件發(fā)揮出各自特有的能力。
高內(nèi)聚/低耦合
高質(zhì)量的組件要滿足高內(nèi)聚和低耦合的原則。
高內(nèi)聚意思是把邏輯緊密相關(guān)的內(nèi)容聚合在一起。在 jQuery 時(shí)代,我們將一個(gè)功能的資源放在了 js、html、css 等目錄,開(kāi)發(fā)時(shí),我們需要到不同的目錄中尋找相關(guān)的邏輯資源。再比如 Redux 的建議將 actions、reducers、store 拆分到不同的地方,將一個(gè)很簡(jiǎn)單的功能邏輯分散開(kāi)來(lái)。這很不滿足高內(nèi)聚的特點(diǎn)。拋開(kāi) Redux,在 React 組件化的思維本身很滿足高內(nèi)聚的原則,即一個(gè)組件是一個(gè)自包含的單元, 它包含了邏輯/樣式/結(jié)構(gòu), 甚至是依賴的靜態(tài)資源。
低耦合指的是要降低不同組件之間的依賴關(guān)系,讓每個(gè)組件要盡量獨(dú)立。也就是說(shuō)平時(shí)寫(xiě)代碼都是為了低耦合而進(jìn)行。通過(guò)責(zé)任分離、劃分邊界的方式,將復(fù)雜的業(yè)務(wù)解耦。
遵循基本原則好處:
- 降低單個(gè)組件的復(fù)雜度,可讀性高
- 降低耦合,不至于牽一發(fā)而動(dòng)全身
- 提高可復(fù)用性
- 邊界透明,易于測(cè)試
- 流程清晰,降低出錯(cuò)率,并調(diào)試方便
進(jìn)階設(shè)計(jì)
受控/非受控狀態(tài)
在 React 表單管理中有兩個(gè)經(jīng)常使用的術(shù)語(yǔ): 受控輸入和非受控輸入。簡(jiǎn)單來(lái)說(shuō),受控的意思是當(dāng)前組件的狀態(tài)成為該表單的唯一數(shù)據(jù)源。表明這個(gè)表單的值在當(dāng)前組件的控制中,并只能通過(guò) setState 來(lái)更新。
受控/非受控的概念在組件設(shè)計(jì)上極為常見(jiàn)。受控組件通常以 value 與 onChange 成對(duì)出現(xiàn)。傳入到子組件中,子組件無(wú)法直接修改這個(gè) value,只能通過(guò) onChange 回調(diào)告訴父組件更新。非受控組件則可以傳入 defaultValue 屬性來(lái)提供初始值。
Modal 組件的 visible 受控/非受控:
- // 受控
- <Modal visible={visible} onVisibleChange={handleVisibleChange} />
- // 非受控
- <Modal defaultVisible={visible} />
若該狀態(tài)作為組件的核心邏輯時(shí),那么它應(yīng)該支持受控,或兼容非受控模式。若該狀態(tài)為次要邏輯,可以根據(jù)實(shí)際情況選擇性支持受控模式。
例如 Select 組件處理受控與非受控邏輯:
- function Select(props: SelectProps) {
- // value 和 onChange 為核心邏輯,支持受控。兼容傳入 defaultValue 成為非受控
- // defaultOpen 為次要邏輯,可以非受控
- const { value: controlledValue, onChange: onControlledChange, defaultValue, defaultOpen } = props;
- // 非受控模式使用內(nèi)部 state
- const [innerValue, onInnerValueChange] = React.useState(defaultValue);
- // 次要邏輯,選擇框展開(kāi)狀態(tài)
- const [visible, setVisible] = React.useState(defaultOpen);
- // 通過(guò)檢測(cè)參數(shù)上是否包含 value 的屬性判斷是否為受控,盡管 value 為 undefined
- const shouldControlled = Reflect.has(props, 'value');
- // 支持受控和非受控處理
- const value = shouldControlled ? controlledValue : innerValue;
- const onChange = shouldControlled ? onControlledChange : onInnerValueChange;
- // ...
- }
配合 hooks 受控
一個(gè)組件是否受控,通常來(lái)說(shuō)針對(duì)其本身的支持,現(xiàn)在自定義 hooks 的出現(xiàn)可以突破此限制。復(fù)雜的組件,配合 hooks 會(huì)更加得心應(yīng)手。
封裝此類(lèi)組件,將邏輯放在 hooks 中,組件本身則被掏空,其作用是主要配合自定義 hooks 進(jìn)行渲染。
- function Demo() {
- // 主要的邏輯在自定義 hook 中
- const sheet = useSheetTable();
- // 組件本身只接收一個(gè)參數(shù),為 hook 的返回值
- <SheetTable sheet={sheet} />;
- }
這樣做的好處是邏輯與組件徹底分離,更利于狀態(tài)提升,可以直接訪問(wèn) sheet 所有的狀態(tài),這種模式受控會(huì)更加徹底。簡(jiǎn)單的組件也許不適合做成這種模式,本身沒(méi)這么大的受控需求,這樣封裝會(huì)增加一些使用復(fù)雜度。
單一數(shù)據(jù)源
單一數(shù)據(jù)源原則,指組件的一個(gè)狀態(tài)以 props 的形式傳給子組件,并且在傳遞過(guò)程中具有延續(xù)性。也就是說(shuō)狀態(tài)在傳遞到各個(gè)子組件中不用 useState 去接收,這會(huì)使傳遞的狀態(tài)失去響應(yīng)特性。
以下代碼違背了單一數(shù)據(jù)源的原則,因?yàn)樵谧咏M件中定義了狀態(tài) searchResult 緩存了搜索結(jié)果,這會(huì)導(dǎo)致 options 參數(shù)在 onFilter 后與子組件失去響應(yīng)特性。
- function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
- // 緩存搜索結(jié)果
- const [searchResult, setSearchResult] = React.useState<Option[] | undefined>(undefined);
- return (
- <div>
- <Input.Search
- onSearch={(keyword) => {
- setSearchResult(keyword ? onFilter(keyword) : undefined);
- }}
- />
- <OptionList options={searchResult ?? options} />
- </div>
- );
- }
應(yīng)當(dāng)遵循單一數(shù)據(jù)源的原則。將關(guān)鍵詞存為 state,通過(guò)響應(yīng) keyword 變化生成新的 options:
- function SelectDropdown({ options = [], onFilter }: SelectDropdownProps) {
- // 搜索關(guān)鍵詞
- const [keyword, setKeyword] = React.useState<string | undefined>(undefined);
- // 使用過(guò)濾條件篩選數(shù)據(jù)
- 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 即副作用。如果沒(méi)有必要,盡量減少 useEffect 的使用。React 官方將這個(gè) API 的使用場(chǎng)景歸納為改變 DOM、添加訂閱、異步任務(wù)、記錄日志等。先來(lái)看一段代碼:
- function Demo({ value, onChange }) {
- const [labelList, setLabelList] = React.useState(() => value.map(customFn));
- // value 變化后,使內(nèi)部狀態(tài)更新
- React.useEffect(() => {
- setLabelList(value.map(customFn));
- }, [value]);
- }
上面代碼為了保持 labelList 與 value 的響應(yīng),使用了 useEffect。也許你現(xiàn)在看這個(gè)代碼的本身能正常執(zhí)行。如果現(xiàn)在有個(gè)需求:labelList 變化后也同步到 value,字面理解下你可能會(huì)寫(xiě)出如下代碼:
- React.useEffect(() => {
- onChange(labelList.map(customFn));
- }, [labelList]);
你會(huì)發(fā)現(xiàn)應(yīng)用進(jìn)入了永久循環(huán)中,瀏覽器失去控制,這就是沒(méi)必要的 useEffect ??梢岳斫鉃椴蛔龈淖? DOM、添加訂閱、異步任務(wù)、記錄日志等場(chǎng)景的操作,就盡量別用 useEffect,比如監(jiān)聽(tīng) state 再改變別的 state。結(jié)局就是應(yīng)用復(fù)雜度達(dá)到一定程度,不是瀏覽器先崩潰,就是開(kāi)發(fā)者崩潰。
那有好的方式解決嗎?我們可以將邏輯理解為 動(dòng)作 + 狀態(tài)。其中 狀態(tài) 的變更只能由 動(dòng)作 觸發(fā)。這就能很好解決上面代碼中的問(wèn)題,將 labelList 的狀態(tài)提升,找出改變 value 的 動(dòng)作,封裝一個(gè)聯(lián)動(dòng)改變 labelList 的方法給各個(gè) 動(dòng)作,越復(fù)雜的場(chǎng)景這種模式越高效。
通用性原則
通用性設(shè)計(jì)其實(shí)是一定意義上放棄對(duì) DOM 的掌控,而將 DOM 結(jié)構(gòu)的決定權(quán)轉(zhuǎn)移給開(kāi)發(fā)者,比如預(yù)留自定義渲染。
舉個(gè)例子, antd 中的 Table通過(guò) render 函數(shù)將每個(gè)單元格渲染的決定權(quán)交給使用者,這樣極大提高了組件的可擴(kuò)展性:
- const columns = [
- {
- title: '名稱',
- dataIndex: 'name',
- width: 200,
- render(text) {
- return<em>{text}</em>;
- },
- },
- ];
- <Table columns={columns} />;
優(yōu)秀的組件,會(huì)通過(guò)參數(shù)預(yù)設(shè)默認(rèn)的渲染行為,同時(shí)支持自定義渲染。
統(tǒng)一 API
當(dāng)各個(gè)組件數(shù)量變多之后,組件與組件直接可能存在某種契合的關(guān)系,我們可以統(tǒng)一某種行為 API 的一致性,這樣可以降低使用者對(duì)各個(gè)組件 API 名稱的心智負(fù)擔(dān)。否則組件傳參就會(huì)如同一根一根數(shù)面條一樣痛苦。
舉個(gè)例子,經(jīng)典的 value 與 onChange 的 API 可以在各個(gè)不同的表單域上出現(xiàn)。可以通過(guò)包裝的方式導(dǎo)出更多高階組件,這些高階組件又可以被表單管理組件所容納。
我們可以約定在各個(gè)組件上比如 visible、onVisibleChange、bordered、size、allowClear 這樣的 API,使其在各個(gè)組件中保持一致性。
不可變狀態(tài)
對(duì)于函數(shù)式編程范式的 React 來(lái)說(shuō),不可變狀態(tài)與單向數(shù)據(jù)流是其核心概念。如果一個(gè)復(fù)雜的組件手動(dòng)保持不可變狀態(tài)繁雜程度也是相當(dāng)高,這里推薦使用 immer 做不可變數(shù)據(jù)管理。如果一個(gè)對(duì)象內(nèi)部屬性變化了,那么整個(gè)對(duì)象就是全新的,不變的部分會(huì)保持引用,這樣天生契合 React.memo 做淺對(duì)比,減少 shouldComponentUpdate 比較的性能消耗。
注意陷阱
React 在某個(gè)意義上說(shuō)一個(gè)狀態(tài)機(jī),每次 render 所定義的變量會(huì)重新聲明。
Context 陷阱
- exportfunction ThemeProvider(props) {
- const [theme, switchTheme] = useState(redTheme);
- // 這里每一次渲染 ThemeProvider, 都會(huì)創(chuàng)建一個(gè)新的 value 從而導(dǎo)致強(qiáng)制渲染所有使用該 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 方法里創(chuàng)建函數(shù),那么使用 render props 會(huì)抵消使用 React.memo 帶來(lái)的優(yōu)勢(shì)。因?yàn)闇\比較 props 的時(shí)候總會(huì)得到 false,并且在這種情況下每一個(gè) render 對(duì)于 render props 將會(huì)生成一個(gè)新的值。
- <CustomComponent renderFooter={() => <em>Footer</em>} />
可以使用 useMethods 代替:github.com/MinJieLiu/heo/blob/main/src/useMethods.tsx
社區(qū)實(shí)踐
高階組件/裝飾器模式
- const HOC = (Component) => EnhancedComponent;
裝飾器模式是在不改變?cè)瓕?duì)象的基礎(chǔ)上,通過(guò)對(duì)其進(jìn)行包裝擴(kuò)展(添加屬性或方法),使原有對(duì)象可以滿足用戶的更復(fù)雜需求,滿足開(kāi)閉原則,也不會(huì)破壞現(xiàn)有的操作。組件是將 props 轉(zhuǎn)化成 UI ,然而高階組件將一個(gè)組件轉(zhuǎn)化成另外一個(gè)組件。
例如漫威電影中的鋼鐵俠,本身就是一個(gè)普通人,可以行走、跳躍。經(jīng)過(guò)戰(zhàn)衣的裝飾,可以跑得更快,還具備飛行能力。
在普通組件中包裝一個(gè) withRouter(react-router),就具備了操作路由的能力。包裝一個(gè) connect(react-redux),就具備了操作全局?jǐn)?shù)據(jù)的能力。
Render Props
- <Component render={(props) => <EnhancedComponent {...props} />} />
Render Props 用于使用一個(gè)值為函數(shù)的 prop 在 React 組件之間的代碼共享。Render Props 其實(shí)和高階組件一樣,是為了給純函數(shù)組件加上 state,響應(yīng) react 的生命周期。它以一種回調(diào)的方式,傳入一個(gè)函數(shù)給子組件調(diào)用,獲得狀態(tài)可以與父組件交互。
鏈?zhǔn)?Hooks
在 React Hooks 時(shí)代,高階組件和 render props 使用頻率會(huì)下降很多,很多場(chǎng)景下會(huì)被 hooks 所替代。
我們看看 hooks 的規(guī)則:
- 只在最頂層使用 Hook
- 不在循環(huán),條件或嵌套函數(shù)中調(diào)用 Hook
- 只在 React 函數(shù)中調(diào)用 Hook
hook 都是按照一定的順序調(diào)用,因?yàn)槠鋬?nèi)部使用鏈表實(shí)現(xiàn)。我們可以通過(guò) 單一職責(zé) 的概念將每個(gè) hook 作為模塊去呈現(xiàn),通過(guò)組合自定義 hook 就可以實(shí)現(xiàn)漸進(jìn)式功能增強(qiáng)。如同 rxjs 一樣具備鏈?zhǔn)秸{(diào)用的同時(shí)又可以操作其狀態(tài)與生命周期。
示例:
- function Component() {
- const value = useSelectStore();
- const keyboardEvents = useInteractive(value);
- const label = useSelectPresent(keyboardEvents);
- // ...
- }
用過(guò)語(yǔ)義化組合可以選擇使用需要的 hooks 來(lái)創(chuàng)造出適應(yīng)各個(gè)需求的自定義組件。在某種意義上說(shuō)最小單元不止是組件,也可以是自定義 hooks。
結(jié)語(yǔ)
希望每個(gè)人都能寫(xiě)出高質(zhì)量的組件。