1. 引言
讀了 精讀《useEffect 完全指南》 之後,是不是對 Function Component 的理解又加深了一些呢?
這次通過 Writing Resilient Components 一文,瞭解一下什麼是有彈性的元件,以及為什麼 Function Component 可以做到這一點。
2. 概述
相比程式碼的 Lint 或者 Prettier,或許我們更應該關注程式碼是否具有彈性。
Dan 總結了彈性元件具有的四個特徵:
- 不要阻塞資料流。
- 時刻準備好渲染。
- 不要有單例元件。
- 隔離本地狀態。
以上規則不僅適用於 React,它適用於所有 UI 元件。
不要阻塞渲染的資料流
不阻塞資料流的意思,就是 不要將接收到的引數本地化, 或者 使元件完全受控。
在 Class Component 語法下,由於有生命週期的概念,在某個生命週期將 props
儲存到 state
的方式屢見不鮮。 然而一旦將 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>;
}
}
複製程式碼
當元件再次重新整理時,props.color
變化了,但 state.color
不會變,這種情況就阻塞了資料流,小夥伴們可能會吐槽元件有 BUG。這時候如果你嘗試通過其他生命週期(componentWillReceiveProps
或 componentDidUpdate
)去修復,程式碼會變得難以管理。
然而 Function Component 沒有生命週期的概念,所以沒有必須要將 props
儲存到 state
,直接渲染即可:
function Button({ color, children }) {
return (
// ✅ `color` is always fresh!
<button className={"Button-" + color}>{children}</button>
);
}
複製程式碼
如果需要對 props
進行加工,可以利用 useMemo
對加工過程進行快取,僅當依賴變化時才重新執行:
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ✅ Don’t recalculate until `color` changes
);
複製程式碼
不要阻塞副作用的資料流
發請求就是一種副作用,如果在一個元件內發請求,那麼在取數引數變化時,最好能重新取數。
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 的方式實現,我們需要將請求函式 getFetchUrl
抽出來,並且在 componentDidMount
與 componentDidUpdate
時同時呼叫它,還要注意 componentDidUpdate
時如果取數引數 state.query
沒有變化則不執行 getFetchUrl
。
這樣的維護體驗很糟糕,如果取數引數增加了 state.currentPage
,你很可能在 componentDidUpdate
中漏掉對 state.currentPage
的判斷。
如果使用 Function Component,可以通過 useCallback
將整個取數過程作為一個整體:
原文沒有使用
useCallback
,筆者進行了加工。
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 對 props
與 state
的資料都一視同仁,且可以將取數邏輯與 “更新判斷” 通過 useCallback
完全封裝在一個函式內,再將這個函式作為整體依賴項新增到 useEffect
,如果未來再新增一個引數,只要修改 fetchResults
這個函式即可,而且還可以通過 eslint-plugin-react-hooks
外掛靜態分析是否遺漏了依賴項。
Function Component 不但將依賴項聚合起來,還解決了 Class Component 分散在多處生命週期的函式判斷,引發的無法靜態分析依賴的問題。
不要因為效能優化而阻塞資料流
相比 PureComponent
與 React.memo
,手動進行比較優化是不太安全的,比如你可能會忘記對函式進行對比:
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>
);
}
}
複製程式碼
上面的程式碼手動進行了 shouldComponentUpdate
對比優化,但是忽略了對函式引數 onClick
的對比,因此雖然大部分時間 onClick
確實沒有變化,因此程式碼也不會有什麼 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>
</>
);
}
}
複製程式碼
但是一旦換一種方式實現 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
隨機在 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
函式變化。
也就是如果子元件進行手動優化時,如果漏了對函式的對比,很有可能執行到舊的函式導致錯誤的邏輯。
所以儘量不要自己進行優化,同時在 Function Component 環境下,在內部申明的函式每次都有不同的引用,因此便於發現邏輯 BUG,同時利用 useCallback
與 useContext
有助於解決這個問題。
時刻準備渲染
確保你的元件可以隨時重渲染,且不會導致內部狀態管理出現 BUG。
要做到這一點其實挺難的,比如一個複雜元件,如果接收了一個狀態作為起點,之後的程式碼基於這個起點派生了許多內部狀態,某個時刻改變了這個起始值,元件還能正常執行嗎?
比如下面的程式碼:
// ? 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
標識了每次元件接收到新的 props
,都會將 props.value
同步到 state.value
。這就是一種派生 state
,雖然看上去可以做到優雅承接 props
的變化,但 父元素因為其他原因的 rerender 就會導致 state.value
非正常重置,比如父元素的 forceUpdate
。
當然可以通過 不要阻塞渲染的資料流 一節所說的方式,比如 PureComponent
, shouldComponentUpdate
, React.memo
來做效能優化(當 props.value
沒有變化時就不會重置 state.value
),但這樣的程式碼依然是脆弱的。
健壯的程式碼不會因為刪除了某項優化就出現 BUG,不要使用派生 state
就能避免此問題。
筆者補充:解決這個問題的方式是,1. 如果元件依賴了
props.value
,就不需要使用state.value
,完全做成 受控元件。2. 如果必須有state.value
,那就做成內部狀態,也就是不要從外部接收props.value
。總之避免寫 “介於受控與非受控之間的元件”。
補充一下,如果做成了非受控元件,卻想重置初始值,那麼在父級呼叫處加上 key
來解決:
<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />
複製程式碼
另外也可以通過 ref
解決,讓子元素提供一個 reset
函式,不過不推薦使用 ref
。
不要有單例元件
一個有彈性的應用,應該能通過下面考驗:
ReactDOM.render(
<>
<MyApp />
<MyApp />
</>,
document.getElementById("root")
);
複製程式碼
將整個應用渲染兩遍,看看是否能各自正確運作?
除了元件本地狀態由本地維護外,具有彈性的元件不應該因為其他例項呼叫了某些函式,而 “永遠錯過了某些狀態或功能”。
筆者補充:一個危險的元件一般是這麼思考的:沒有人會隨意破壞資料流,因此只要在 didMount
與 unMount
時做好資料初始化和銷燬就行了。
那麼當另一個例項進行銷燬操作時,可能會破壞這個例項的中間狀態。一個具有彈性的元件應該能 隨時響應 狀態的變化,沒有生命週期概念的 Function Component 處理起來顯然更得心應手。
隔離本地狀態
很多時候難以判斷資料屬於元件的本地狀態還是全域性狀態。
文章提供了一個判斷方法:“想象這個元件同時渲染了兩個例項,這個資料會同時影響這兩個例項嗎?如果答案是 不會,那這個資料就適合作為本地狀態”。
尤其在寫業務元件時,容易將業務資料與元件本身狀態資料混淆。
根據筆者的經驗,從上層業務到底層通用元件之間,本地狀態數量是遞增的:
業務
-> 全域性資料流
-> 頁面(完全依賴全域性資料流,幾乎沒有自己的狀態)
-> 業務元件(從頁面或全域性資料流繼承資料,很少有自己狀態)
-> 通用元件(完全受控,比如 input;或大量內聚狀態的複雜通用邏輯,比如 monaco-editor)
複製程式碼
3. 精讀
再次強調,一個有彈性的元件需要同時滿足下面 4 個原則:
- 不要阻塞資料流。
- 時刻準備好渲染。
- 不要有單例元件。
- 隔離本地狀態。
想要遵循這些規則看上去也不難,但實踐過程中會遇到不少問題,筆者舉幾個例子。
頻繁傳遞迴調函式
Function Component 會導致元件粒度拆分的比較細,在提高可維護性同時,也會導致全域性 state
成為過去,下面的程式碼可能讓你覺得彆扭:
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
拆分出來,邏輯更加解耦,但子元件需要更新父元件的狀態就變得麻煩,我們不希望將函式作為引數透傳給子元件。
一種辦法是將函式通過 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)}>
);
});
複製程式碼
但這樣會導致 Provider
過於臃腫,因此建議部分元件使用 useReducer
替代 useState
,將函式合併到 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})))}>
);
});
複製程式碼
將狀態聚合到 reducer
中,這樣一個 ContextProvider
就能解決所有資料處理問題了。
memo 包裹的元件類似 PureComponent 效果。
useCallback 引數變化頻繁
在 精讀《useEffect 完全指南》 我們介紹了利用 useCallback
建立一個 Immutable 的函式:
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} />
</>
);
}
複製程式碼
但這個函式的依賴 [text]
變化過於頻繁,以至於在每個 render
都會重新生成 handleSubmit
函式,對效能有一定影響。一種解決辦法是利用 Ref
規避這個問題:
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} />
</>
);
}
複製程式碼
當然,也可以將這個過程封裝為一個自定義 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]);
}
複製程式碼
不過這種方案並不優雅,React 考慮提供一個更優雅的方案。
有可能被濫用的 useReducer
在 精讀《useEffect 完全指南》 “將更新與動作解耦” 一節裡提到了,利用 useReducer
解決 “函式同時依賴多個外部變數的問題”。
一般情況下,我們會這麼使用 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 });
複製程式碼
但其實 useReducer
對 state
與 action
的定義可以很隨意,因此我們可以利用 useReducer
打造一個 useState
。
比如我們建立一個擁有複數 key 的 useState
:
const [state, setState] = useState({ count: 0, name: "nick" });
// 修改 count
setState(state => ({ ...state, count: 1 }));
// 修改 name
setState(state => ({ ...state, name: "jack" }));
複製程式碼
利用 useReducer
實現相似的功能:
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" }));
複製程式碼
因此針對如上情況,我們可能濫用了 useReducer
,建議直接用 useState
代替。
4. 總結
本文總結了具有彈性的元件的四個特性:不要阻塞資料流、時刻準備好渲染、不要有單例元件、隔離本地狀態。
這個約定對程式碼質量很重要,而且難以通過 lint 規則或簡單肉眼觀察加以識別,因此推廣起來還是有不小難度。
總的來說,Function Component 帶來了更優雅的程式碼體驗,但是對團隊協作的要求也更高了。
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
special Sponsors
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)