讀了 精讀《useEffect 徹底指南》 以後,是否是對 Function Component 的理解又加深了一些呢?html
此次經過 Writing Resilient Components 一文,瞭解一下什麼是有彈性的組件,以及爲何 Function Component 能夠作到這一點。前端
相比代碼的 Lint 或者 Prettier,或許咱們更應該關注代碼是否具備彈性。react
Dan 總結了彈性組件具備的四個特徵:git
以上規則不只適用於 React,它適用於全部 UI 組件。github
不阻塞數據流的意思,就是 不要將接收到的參數本地化, 或者 使組件徹底受控。api
在 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)
再次強調,一個有彈性的組件須要同時知足下面 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 效果。
在 精讀《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 考慮提供一個更優雅的方案。
在 精讀《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
代替。
本文總結了具備彈性的組件的四個特性:不要阻塞數據流、時刻準備好渲染、不要有單例組件、隔離本地狀態。
這個約定對代碼質量很重要,並且難以經過 lint 規則或簡單肉眼觀察加以識別,所以推廣起來仍是有不小難度。
總的來講,Function Component 帶來了更優雅的代碼體驗,可是對團隊協做的要求也更高了。
討論地址是: 精讀《編寫有彈性的組件》 · Issue #139 · dt-fe/weekly
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
<img width=200 src="https://img.alicdn.com/tfs/TB...;>
special Sponsors
版權聲明:自由轉載-非商用-非衍生-保持署名( 創意共享 3.0 許可證)