1. 引言
讀了 精讀《useEffect 完全指南》 之后,是不是對(duì) Function Component 的理解又加深了一些呢?
這次通過(guò) Writing Resilient Components 一文,了解一下什么是有彈性的組件,以及為什么 Function Component 可以做到這一點(diǎn)。
2. 概述
相比代碼的 Lint 或者 Prettier,或許我們更應(yīng)該關(guān)注代碼是否具有彈性。
Dan 總結(jié)了彈性組件具有的四個(gè)特征:
- 不要阻塞數(shù)據(jù)流。
- 時(shí)刻準(zhǔn)備好渲染。
- 不要有單例組件。
- 隔離本地狀態(tài)。
以上規(guī)則不僅適用于 React,它適用于所有 UI 組件。
不要阻塞渲染的數(shù)據(jù)流
不阻塞數(shù)據(jù)流的意思,就是 不要將接收到的參數(shù)本地化, 或者 使組件完全受控。
在 Class Component 語(yǔ)法下,由于有生命周期的概念,在某個(gè)生命周期將 props 存儲(chǔ)到 state 的方式屢見(jiàn)不鮮。 然而一旦將 props 固化到 state,組件就不受控了:
class Button extends React.Component {
state = {
color: this.props.color
};
render() {
const { color } = this.state; // ?? `color` is stale!
return <button className={"Button-" + color}>{this.props.children}</button>;
}
}
當(dāng)組件再次刷新時(shí),props.color 變化了,但 state.color 不會(huì)變,這種情況就阻塞了數(shù)據(jù)流,小伙伴們可能會(huì)吐槽組件有 BUG。這時(shí)候如果你嘗試通過(guò)其他生命周期(componentWillReceiveProps 或 componentDidUpdate)去修復(fù),代碼會(huì)變得難以管理。
然而 Function Component 沒(méi)有生命周期的概念,所以沒(méi)有必須要將 props 存儲(chǔ)到 state,直接渲染即可:
function Button({ color, children }) {
return (
// ? `color` is always fresh!
<button className={"Button-" + color}>{children}</button>
);
}
如果需要對(duì) props 進(jìn)行加工,可以利用 useMemo 對(duì)加工過(guò)程進(jìn)行緩存,僅當(dāng)依賴變化時(shí)才重新執(zhí)行:
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ? Don’t recalculate until `color` changes
);
不要阻塞副作用的數(shù)據(jù)流
發(fā)請(qǐng)求就是一種副作用,如果在一個(gè)組件內(nèi)發(fā)請(qǐng)求,那么在取數(shù)參數(shù)變化時(shí),最好能重新取數(shù)。
class SearchResults extends React.Component {
state = {
data: null
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps) {
if (prevProps.query !== this.props.query) {
// ? Refetch on change
this.fetchResults();
}
}
fetchResults() {
const url = this.getFetchUrl();
// Do the fetching...
}
getFetchUrl() {
return "http://myapi/results?query" + this.props.query; // ? Updates are handled
}
render() {
// ...
}
}
如果用 Class Component 的方式實(shí)現(xiàn),我們需要將請(qǐng)求函數(shù) getFetchUrl 抽出來(lái),并且在 componentDidMount 與 componentDidUpdate 時(shí)同時(shí)調(diào)用它,還要注意 componentDidUpdate 時(shí)如果取數(shù)參數(shù) state.query 沒(méi)有變化則不執(zhí)行 getFetchUrl。
這樣的維護(hù)體驗(yàn)很糟糕,如果取數(shù)參數(shù)增加了 state.currentPage,你很可能在 componentDidUpdate 中漏掉對(duì) state.currentPage 的判斷。
如果使用 Function Component,可以通過(guò) useCallback 將整個(gè)取數(shù)過(guò)程作為一個(gè)整體:
原文沒(méi)有使用
useCallback,筆者進(jìn)行了加工。
function SearchResults({ query }) {
const [data, setData] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const fetchResults = useCallback(() => {
return "http://myapi/results?query" + query + "&page=" + currentPage;
}, [currentPage, query]);
useEffect(() => {
const url = getFetchUrl();
// Do the fetching...
}, [getFetchUrl]); // ? Refetch on change
// ...
}
Function Component 對(duì) props 與 state 的數(shù)據(jù)都一視同仁,且可以將取數(shù)邏輯與 “更新判斷” 通過(guò) useCallback 完全封裝在一個(gè)函數(shù)內(nèi),再將這個(gè)函數(shù)作為整體依賴項(xiàng)添加到 useEffect,如果未來(lái)再新增一個(gè)參數(shù),只要修改 fetchResults 這個(gè)函數(shù)即可,而且還可以通過(guò) eslint-plugin-react-hooks 插件靜態(tài)分析是否遺漏了依賴項(xiàng)。
Function Component 不但將依賴項(xiàng)聚合起來(lái),還解決了 Class Component 分散在多處生命周期的函數(shù)判斷,引發(fā)的無(wú)法靜態(tài)分析依賴的問(wèn)題。
不要因?yàn)樾阅軆?yōu)化而阻塞數(shù)據(jù)流
相比 PureComponent 與 React.memo,手動(dòng)進(jìn)行比較優(yōu)化是不太安全的,比如你可能會(huì)忘記對(duì)函數(shù)進(jìn)行對(duì)比:
class Button extends React.Component {
shouldComponentUpdate(prevProps) {
// ?? Doesn't compare this.props.onClick
return this.props.color !== prevProps.color;
}
render() {
const onClick = this.props.onClick; // ?? Doesn't reflect updates
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button
onClick={onClick}
className={"Button-" + this.props.color + " Button-text-" + textColor}
>
{this.props.children}
</button>
);
}
}
上面的代碼手動(dòng)進(jìn)行了 shouldComponentUpdate 對(duì)比優(yōu)化,但是忽略了對(duì)函數(shù)參數(shù) onClick 的對(duì)比,因此雖然大部分時(shí)間 onClick 確實(shí)沒(méi)有變化,因此代碼也不會(huì)有什么 bug:
class MyForm extends React.Component {
handleClick = () => {
// ? Always the same function
// Do something
};
render() {
return (
<>
<h1>Hello!</h1>
<Button color="green" onClick={this.handleClick}>
Press me
</Button>
</>
);
}
}
但是一旦換一種方式實(shí)現(xiàn) onClick,情況就不一樣了,比如下面兩種情況:
class MyForm extends React.Component {
state = {
isEnabled: true
};
handleClick = () => {
this.setState({ isEnabled: false });
// Do something
};
render() {
return (
<>
<h1>Hello!</h1>
<Button
color="green"
onClick={
// ?? Button ignores updates to the onClick prop
this.state.isEnabled ? this.handleClick : null
}
>
Press me
</Button>
</>
);
}
}
onClick 隨機(jī)在 null 與 this.handleClick 之間切換。
drafts.map(draft => (
<Button
color="blue"
key={draft.id}
onClick={
// ?? Button ignores updates to the onClick prop
this.handlePublish.bind(this, draft.content)
}
>
Publish
</Button>
));
如果 draft.content 變化了,則 onClick 函數(shù)變化。
也就是如果子組件進(jìn)行手動(dòng)優(yōu)化時(shí),如果漏了對(duì)函數(shù)的對(duì)比,很有可能執(zhí)行到舊的函數(shù)導(dǎo)致錯(cuò)誤的邏輯。
所以盡量不要自己進(jìn)行優(yōu)化,同時(shí)在 Function Component 環(huán)境下,在內(nèi)部申明的函數(shù)每次都有不同的引用,因此便于發(fā)現(xiàn)邏輯 BUG,同時(shí)利用 useCallback 與 useContext 有助于解決這個(gè)問(wèn)題。
時(shí)刻準(zhǔn)備渲染
確保你的組件可以隨時(shí)重渲染,且不會(huì)導(dǎo)致內(nèi)部狀態(tài)管理出現(xiàn) BUG。
要做到這一點(diǎn)其實(shí)挺難的,比如一個(gè)復(fù)雜組件,如果接收了一個(gè)狀態(tài)作為起點(diǎn),之后的代碼基于這個(gè)起點(diǎn)派生了許多內(nèi)部狀態(tài),某個(gè)時(shí)刻改變了這個(gè)起始值,組件還能正常運(yùn)行嗎?
比如下面的代碼:
// ?? Should prevent unnecessary re-renders... right?
class TextInput extends React.PureComponent {
state = {
value: ""
};
// ?? Resets local state on every parent render
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value });
}
handleChange = e => {
this.setState({ value: e.target.value });
};
render() {
return <input value={this.state.value} onChange={this.handleChange} />;
}
}
componentWillReceiveProps 標(biāo)識(shí)了每次組件接收到新的 props,都會(huì)將 props.value 同步到 state.value。這就是一種派生 state,雖然看上去可以做到優(yōu)雅承接 props 的變化,但 父元素因?yàn)槠渌虻?rerender 就會(huì)導(dǎo)致 state.value 非正常重置,比如父元素的 forceUpdate。
當(dāng)然可以通過(guò) 不要阻塞渲染的數(shù)據(jù)流 一節(jié)所說(shuō)的方式,比如 PureComponent, shouldComponentUpdate, React.memo 來(lái)做性能優(yōu)化(當(dāng) props.value 沒(méi)有變化時(shí)就不會(huì)重置 state.value),但這樣的代碼依然是脆弱的。
健壯的代碼不會(huì)因?yàn)閯h除了某項(xiàng)優(yōu)化就出現(xiàn) BUG,不要使用派生 state 就能避免此問(wèn)題。
筆者補(bǔ)充:解決這個(gè)問(wèn)題的方式是,1. 如果組件依賴了
props.value,就不需要使用state.value,完全做成 受控組件。2. 如果必須有state.value,那就做成內(nèi)部狀態(tài),也就是不要從外部接收props.value。總之避免寫(xiě) “介于受控與非受控之間的組件”。
補(bǔ)充一下,如果做成了非受控組件,卻想重置初始值,那么在父級(jí)調(diào)用處加上 key 來(lái)解決:
<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />
另外也可以通過(guò) ref 解決,讓子元素提供一個(gè) reset 函數(shù),不過(guò)不推薦使用 ref。
不要有單例組件
一個(gè)有彈性的應(yīng)用,應(yīng)該能通過(guò)下面考驗(yàn):
ReactDOM.render(
<>
<MyApp />
<MyApp />
</>,
document.getElementById("root")
);
將整個(gè)應(yīng)用渲染兩遍,看看是否能各自正確運(yùn)作?
除了組件本地狀態(tài)由本地維護(hù)外,具有彈性的組件不應(yīng)該因?yàn)槠渌麑?shí)例調(diào)用了某些函數(shù),而 “永遠(yuǎn)錯(cuò)過(guò)了某些狀態(tài)或功能”。
筆者補(bǔ)充:一個(gè)危險(xiǎn)的組件一般是這么思考的:沒(méi)有人會(huì)隨意破壞數(shù)據(jù)流,因此只要在 didMount 與 unMount 時(shí)做好數(shù)據(jù)初始化和銷毀就行了。
那么當(dāng)另一個(gè)實(shí)例進(jìn)行銷毀操作時(shí),可能會(huì)破壞這個(gè)實(shí)例的中間狀態(tài)。一個(gè)具有彈性的組件應(yīng)該能 隨時(shí)響應(yīng) 狀態(tài)的變化,沒(méi)有生命周期概念的 Function Component 處理起來(lái)顯然更得心應(yīng)手。
隔離本地狀態(tài)
很多時(shí)候難以判斷數(shù)據(jù)屬于組件的本地狀態(tài)還是全局狀態(tài)。
文章提供了一個(gè)判斷方法:“想象這個(gè)組件同時(shí)渲染了兩個(gè)實(shí)例,這個(gè)數(shù)據(jù)會(huì)同時(shí)影響這兩個(gè)實(shí)例嗎?如果答案是 不會(huì),那這個(gè)數(shù)據(jù)就適合作為本地狀態(tài)”。
尤其在寫(xiě)業(yè)務(wù)組件時(shí),容易將業(yè)務(wù)數(shù)據(jù)與組件本身狀態(tài)數(shù)據(jù)混淆。
根據(jù)筆者的經(jīng)驗(yàn),從上層業(yè)務(wù)到底層通用組件之間,本地狀態(tài)數(shù)量是遞增的:
業(yè)務(wù)
-> 全局?jǐn)?shù)據(jù)流
-> 頁(yè)面(完全依賴全局?jǐn)?shù)據(jù)流,幾乎沒(méi)有自己的狀態(tài))
-> 業(yè)務(wù)組件(從頁(yè)面或全局?jǐn)?shù)據(jù)流繼承數(shù)據(jù),很少有自己狀態(tài))
-> 通用組件(完全受控,比如 input;或大量?jī)?nèi)聚狀態(tài)的復(fù)雜通用邏輯,比如 monaco-editor)
3. 精讀
再次強(qiáng)調(diào),一個(gè)有彈性的組件需要同時(shí)滿足下面 4 個(gè)原則:
- 不要阻塞數(shù)據(jù)流。
- 時(shí)刻準(zhǔn)備好渲染。
- 不要有單例組件。
- 隔離本地狀態(tài)。
想要遵循這些規(guī)則看上去也不難,但實(shí)踐過(guò)程中會(huì)遇到不少問(wèn)題,筆者舉幾個(gè)例子。
頻繁傳遞回調(diào)函數(shù)
Function Component 會(huì)導(dǎo)致組件粒度拆分的比較細(xì),在提高可維護(hù)性同時(shí),也會(huì)導(dǎo)致全局 state 成為過(guò)去,下面的代碼可能讓你覺(jué)得別扭:
const App = memo(function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState("nick");
return (
<>
<Count count={count} setCount={setCount}/>
<Name name={name} setName={setName}/>
</>
);
});
const Count = memo(function Count(props) {
return (
<input value={props.count} onChange={pipeEvent(props.setCount)}>
);
});
const Name = memo(function Name(props) {
return (
<input value={props.name} onChange={pipeEvent(props.setName)}>
);
});
雖然將子組件 Count 與 Name 拆分出來(lái),邏輯更加解耦,但子組件需要更新父組件的狀態(tài)就變得麻煩,我們不希望將函數(shù)作為參數(shù)透?jìng)鹘o子組件。
一種辦法是將函數(shù)通過(guò) Context 傳給子組件:
const SetCount = createContext(null)
const SetName = createContext(null)
const App = memo(function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState("nick");
return (
<SetCount.Provider value={setCount}>
<SetName.Provider value={setName}>
<Count count={count}/>
<Name name={name}/>
</SetName.Provider>
</SetCount.Provider>
);
});
const Count = memo(function Count(props) {
const setCount = useContext(SetCount)
return (
<input value={props.count} onChange={pipeEvent(setCount)}>
);
});
const Name = memo(function Name(props) {
const setName = useContext(SetName)
return (
<input value={props.name} onChange={pipeEvent(setName)}>
);
});
但這樣會(huì)導(dǎo)致 Provider 過(guò)于臃腫,因此建議部分組件使用 useReducer 替代 useState,將函數(shù)合并到 dispatch:
const AppDispatch = createContext(null)
class State = {
count = 0
name = 'nick'
}
function appReducer(state, action) {
switch(action.type) {
case 'setCount':
return {
...state,
count: action.value
}
case 'setName':
return {
...state,
name: action.value
}
default:
return state
}
}
const App = memo(function App() {
const [state, dispatch] = useReducer(appReducer, new State())
return (
<AppDispatch.Provider value={dispaych}>
<Count count={count}/>
<Name name={name}/>
</AppDispatch.Provider>
);
});
const Count = memo(function Count(props) {
const dispatch = useContext(AppDispatch)
return (
<input value={props.count} onChange={pipeEvent(value => dispatch({type: 'setCount', value}))}>
);
});
const Name = memo(function Name(props) {
const dispatch = useContext(AppDispatch)
return (
<input value={props.name} onChange={pipeEvent(pipeEvent(value => dispatch({type: 'setName', value})))}>
);
});
將狀態(tài)聚合到 reducer 中,這樣一個(gè) ContextProvider 就能解決所有數(shù)據(jù)處理問(wèn)題了。
memo 包裹的組件類似 PureComponent 效果。
useCallback 參數(shù)變化頻繁
在 精讀《useEffect 完全指南》 我們介紹了利用 useCallback 創(chuàng)建一個(gè) Immutable 的函數(shù):
function Form() {
const [text, updateText] = useState("");
const handleSubmit = useCallback(() => {
const currentText = text;
alert(currentText);
}, [text]);
return (
<>
<input value={text} onChange={e => updateText(e.target.value)} />
<ExpensiveTree onSubmit={handleSubmit} />
</>
);
}
但這個(gè)函數(shù)的依賴 [text] 變化過(guò)于頻繁,以至于在每個(gè) render 都會(huì)重新生成 handleSubmit 函數(shù),對(duì)性能有一定影響。一種解決辦法是利用 Ref 規(guī)避這個(gè)問(wèn)題:
function Form() {
const [text, updateText] = useState("");
const textRef = useRef();
useEffect(() => {
textRef.current = text; // Write it to the ref
});
const handleSubmit = useCallback(() => {
const currentText = textRef.current; // Read it from the ref
alert(currentText);
}, [textRef]); // Don't recreate handleSubmit like [text] would do
return (
<>
<input value={text} onChange={e => updateText(e.target.value)} />
<ExpensiveTree onSubmit={handleSubmit} />
</>
);
}
當(dāng)然,也可以將這個(gè)過(guò)程封裝為一個(gè)自定義 Hooks,讓代碼稍微好看些:
function Form() {
const [text, updateText] = useState("");
// Will be memoized even if `text` changes:
const handleSubmit = useEventCallback(() => {
alert(text);
}, [text]);
return (
<>
<input value={text} onChange={e => updateText(e.target.value)} />
<ExpensiveTree onSubmit={handleSubmit} />
</>
);
}
function useEventCallback(fn, dependencies) {
const ref = useRef(() => {
throw new Error("Cannot call an event handler while rendering.");
});
useEffect(() => {
ref.current = fn;
}, [fn, ...dependencies]);
return useCallback(() => {
const fn = ref.current;
return fn();
}, [ref]);
}
不過(guò)這種方案并不優(yōu)雅,React 考慮提供一個(gè)更優(yōu)雅的方案。
有可能被濫用的 useReducer
在 精讀《useEffect 完全指南》 “將更新與動(dòng)作解耦” 一節(jié)里提到了,利用 useReducer 解決 “函數(shù)同時(shí)依賴多個(gè)外部變量的問(wèn)題”。
一般情況下,我們會(huì)這么使用 useReducer:
const reducer = (state, action) => {
switch (action.type) {
case "increment":
return { value: state.value + 1 };
case "decrement":
return { value: state.value - 1 };
case "incrementAmount":
return { value: state.value + action.amount };
default:
throw new Error();
}
};
const [state, dispatch] = useReducer(reducer, { value: 0 });
但其實(shí) useReducer 對(duì) state 與 action 的定義可以很隨意,因此我們可以利用 useReducer 打造一個(gè) useState。
比如我們創(chuàng)建一個(gè)擁有復(fù)數(shù) key 的 useState:
const [state, setState] = useState({ count: 0, name: "nick" });
// 修改 count
setState(state => ({ ...state, count: 1 }));
// 修改 name
setState(state => ({ ...state, name: "jack" }));
利用 useReducer 實(shí)現(xiàn)相似的功能:
function reducer(state, action) {
return action(state);
}
const [state, dispatch] = useReducer(reducer, { count: 0, name: "nick" });
// 修改 count
dispatch(state => ({ ...state, count: 1 }));
// 修改 name
dispatch(state => ({ ...state, name: "jack" }));
因此針對(duì)如上情況,我們可能濫用了 useReducer,建議直接用 useState 代替。
4. 總結(jié)
本文總結(jié)了具有彈性的組件的四個(gè)特性:不要阻塞數(shù)據(jù)流、時(shí)刻準(zhǔn)備好渲染、不要有單例組件、隔離本地狀態(tài)。
這個(gè)約定對(duì)代碼質(zhì)量很重要,而且難以通過(guò) lint 規(guī)則或簡(jiǎn)單肉眼觀察加以識(shí)別,因此推廣起來(lái)還是有不小難度。
總的來(lái)說(shuō),F(xiàn)unction Component 帶來(lái)了更優(yōu)雅的代碼體驗(yàn),但是對(duì)團(tuán)隊(duì)協(xié)作的要求也更高了。
如果你想?yún)⑴c討論,請(qǐng) 點(diǎn)擊這里,每周都有新的主題,周末或周一發(fā)布。前端精讀 - 幫你篩選靠譜的內(nèi)容。
關(guān)注 前端精讀微信公眾號(hào)
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
special Sponsors
版權(quán)聲明:自由轉(zhuǎn)載-非商用-非衍生-保持署名(創(chuàng)意共享 3.0 許可證)