每天都在用,也沒整明白的 React Hook

useState
useState 可以說是我們?nèi)粘W畛S玫?hook 之一了,在實(shí)際使用過程中,有一些簡(jiǎn)單的小技巧能幫助你提升性能 & 減少出 bug 的概率。
- 使用 惰性初始值 (https://reactjs.org/docs/hooks-reference.html#lazy-initial-state)
 
通常我們會(huì)使用以下的方式初始化 state。
對(duì)于簡(jiǎn)單的初始值,這樣做完全沒有任何性能問題,但如果初始值是根據(jù)復(fù)雜計(jì)算得出來的,我們這么寫就會(huì)產(chǎn)生性能問題。
相信你已經(jīng)發(fā)現(xiàn)這里的問題了,對(duì)于 useState 的初始值,我們只需要計(jì)算一次,但是根據(jù) React Function Component 的渲染邏輯,在每一次 render 的時(shí)候,都會(huì)重新調(diào)用該函數(shù),因此 initalState 在每次 render 時(shí)都會(huì)被重新計(jì)算,哪怕它只在第一次渲染的時(shí)候被用到,這無疑會(huì)造成嚴(yán)重過的性能問題,我們可以通過 useState 的惰性初始值來解決這個(gè)問題。
不要把只需要計(jì)算一次的的東西直接放在函數(shù)組件內(nèi)部頂層 block 中。
- 使用 函數(shù)式更新 (https://zh-hans.reactjs.org/docs/hooks-reference.html#functional-updates)
 
當(dāng)我們想更新 state 的時(shí)候,我們通常會(huì)這樣調(diào)用 setState。
看上去沒有任何問題,我們來看看另外一個(gè) case。
點(diǎn)擊 div,我們可以看到計(jì)數(shù)器增加,那么 3 秒過后,計(jì)數(shù)器的值會(huì)是幾呢?
答案是 1
這是一個(gè)非常反直覺的結(jié)果,原因在于第一次運(yùn)行函數(shù)時(shí),state 的值為 0,而 setTimeout 中的回調(diào)函數(shù)捕獲了第一次運(yùn)行 Demo 函數(shù)時(shí) state 的值,也就是 0,所以 setState(state + 1)執(zhí)行后 state 的值變成了 1,哪怕當(dāng)前 state 值已經(jīng)不是 0 了。
讓我們通過函數(shù)式更新修復(fù)這個(gè)問題。
讓我們?cè)龠\(yùn)行一次程序試試,這次 3 秒后,state 并沒有變成 1,而是增加了 1。
直接在 setState 中依賴 state 計(jì)算新的 state 在異步執(zhí)行的函數(shù)中會(huì)由于 js 閉包捕獲得到預(yù)期外的結(jié)果,此時(shí)可以使用 setState(prev => getNewState(prev)) 函數(shù)式更新來解決。
- 使用 useImmer (https://github.com/immerjs/use-immer) 替代 useState。
 
相信很多同學(xué)聽過 immer.js 這個(gè)庫,簡(jiǎn)單來說就是基于 proxy 攔截 getter 和 setter 的能力,讓我們可以很方便的通過修改對(duì)象本身,創(chuàng)建新的對(duì)象,那么這有什么用呢?我們知道,React 通過 Object.is 函數(shù)比較 props,也就是說對(duì)于引用一致的對(duì)象,react是不會(huì)刷新視圖的,這也是為什么我們不能直接修改調(diào)用 useState 得到的 state 來更新視圖,而是要通過 setState 刷新視圖,通常,為了方便,我們會(huì)使用 es6 的 spread 運(yùn)算符構(gòu)造新的對(duì)象(淺拷貝)。
我相信你已經(jīng)發(fā)現(xiàn)問題了,對(duì)于嵌套層級(jí)多的對(duì)象,使用 spread 構(gòu)造新的對(duì)象寫起來心智負(fù)擔(dān)很大,也不易于維護(hù),這時(shí)候聰明的你肯定想到了,我直接 deepClone,修改后再 setState 不就完事了。
這樣就完全沒有心智負(fù)擔(dān)的問題了,程序也運(yùn)作良好,然而,這不是沒有代價(jià)的,且不說 deepClone 本身對(duì)于嵌套層級(jí)復(fù)雜的對(duì)象就非常耗時(shí),同時(shí)因?yàn)檎麄€(gè)對(duì)象都是 deepClone 過來的,而不是淺拷貝,react 認(rèn)為整個(gè)大對(duì)象都變了,這時(shí)候使用到對(duì)象里的引用值的組件也都會(huì)刷新,哪怕這兩個(gè)引用前后值根本沒有變化。
有沒有兩全其美的方法呢?
當(dāng)然是用的,這里就要用到我們提到的 immer.js 了。
這里我們可以看到,即使用了 deepClone 沒有心智負(fù)擔(dān)的寫法,同時(shí) immer 只會(huì)改變寫部分的引用 (也就是所謂的“Copy On Write”),其余沒用變動(dòng)的部分引用保持不變,react 會(huì)跳過這部分的更新,這樣我們就同時(shí)獲得了簡(jiǎn)易的寫法和良好的性能。
事實(shí)上,我們還可以使用 useImmer 這個(gè)語法糖來進(jìn)一步簡(jiǎn)化調(diào)用方式。
可以看到,使用 useImmer 之后,setState 幾乎跟原生的 useState提供的函數(shù)式更新 api 一模一樣,只不過,你可以在 setState 內(nèi)直接修改對(duì)象生成新的 state ,同時(shí) useImmer 還對(duì)普通的 setState 用法做了兼容,你也可以直接在 setState 內(nèi)返回新的 state,完全沒有心智負(fù)擔(dān)。
useEffect
前面講了useState,那么還有一個(gè)開發(fā)過程中最常用的就是 useEffect 了,接下來來聊聊我的日常使用過程中 useEffect 的坑吧。
- 在 useEffect 中調(diào)用 setState
 
在很多情況下,我們可能會(huì)寫出這樣的代碼
咋一眼看上去沒有任何問題,dep1 這個(gè) state 變動(dòng)的時(shí)候我更新一下 dep2 的值,然而這其實(shí)是一種反模式,我們可能會(huì)習(xí)慣性的把 useEffect 當(dāng)成一個(gè) watch 來用,但每次我們 setState 過后,函數(shù)組件又會(huì)重新執(zhí)行一遍,useEffect 也會(huì)重新跑一遍,這里你肯定會(huì)想,那不是成死循環(huán)了,但其實(shí)不然,useEffect 提供的第二個(gè)參數(shù)允許我們傳遞依賴項(xiàng),在依賴項(xiàng)不變的情況下會(huì)跳過 effect 執(zhí)行,這才讓我們的代碼可以正常運(yùn)行。
所以到這里,聰明的你肯定已經(jīng)發(fā)現(xiàn)問題了么?
要是我 dep 數(shù)組寫的不對(duì),那不是有可能出現(xiàn)無限循環(huán)?
在實(shí)際開發(fā)過程中,如此高的心智負(fù)擔(dān)必然不利于代碼的維護(hù),因此我們來聊一聊什么是 effect,setState 又該在哪里調(diào)用,我們來看一個(gè)圖:

這里的 input / output 部分即 IO,也就是副作用,Input 產(chǎn)生一些值,而中間的純函數(shù)對(duì) Input 做一些轉(zhuǎn)換,最終生成一堆數(shù)據(jù),通過 output driver 執(zhí)行副作用,也就是渲染,修改標(biāo)題等操作,對(duì)應(yīng)到 React 中,我們可以這樣理解。
所有使用 hook 得到的狀態(tài)即為 input 副作用產(chǎn)生的值。
function 組件函數(shù)本身是中間轉(zhuǎn)換的純函數(shù)。
React.render 函數(shù)作為 driver 負(fù)責(zé)讀取轉(zhuǎn)換好之后的值,并且執(zhí)行渲染這個(gè)副作用 (其它的副作用在 useEffect 和 useLayoutEffect中執(zhí)行 )。
基于以上的心智模型,我們可以得出這么幾個(gè)結(jié)論:
- 不要直接在函數(shù)頂層 block 中調(diào)用 setState 或者執(zhí)行其它副作用的操作!
 
(提醒一下,直接在函數(shù)頂層 block 中調(diào)用 setState,if 條件一下沒寫好,組件就掛了)
- 所有組件內(nèi)部狀態(tài)的轉(zhuǎn)換都應(yīng)該歸于純函數(shù)中,不要把 useEffect 當(dāng)成 watch 來用。
 
我們可以使用這種方式計(jì)算新的 state。
(注意這里并沒有使用 useMemo )
在 React 中,每次渲染都會(huì)重新調(diào)用函數(shù),因此直接寫在函數(shù)體內(nèi)的自然就是 compute state ,在沒有嚴(yán)重性能問題的情況下不推薦使用 useMemo, 依賴項(xiàng)寫錯(cuò)了容易出 bug。
- 盡可能在 event 中執(zhí)行 setState,以確??深A(yù)測(cè)的 state 變化,例如:
 
- onClick 事件
 - Promise
 - setTimeout
 - setInterval
 - ...
 
- 依賴項(xiàng)為空數(shù)組的 useEffect 中,可以放心調(diào)用 setState。
 
- 不要同時(shí)使用一堆依賴項(xiàng) & 多個(gè) useEffect !!!
 
如果你寫過以下的代碼:
這樣的代碼非常容易造成循環(huán)依賴的問題,而且一旦出了問題,非常難排查很解決,整個(gè) state 的更新很難預(yù)測(cè),相關(guān)的 state 更新如果建議一次更新 (可以考慮使用 useReducer 并且在可能的情況下,盡量將狀態(tài)更新放到事件而不是 useEffect 里)。
useContext
在多個(gè)組件共享狀態(tài)以及要向深層組件傳遞狀態(tài)時(shí),我們通常會(huì)使用 useContext 這個(gè) hook 和 createContext 搭配,也就是下面這樣:
這也是 React 官方推薦的共享狀態(tài)的方式,然而在需要共享狀態(tài)的組件非常多的情況下,這有著嚴(yán)重的性能問題,在上述例子里,哪怕 A 組件只更新 state.a,并沒有用到 state.b,B 組件更新 state.b 的時(shí)候 A 組件也會(huì)刷新,在組件非常多的情況下,就卡死了,用戶體驗(yàn)非常不好。好在這個(gè)地方有很多種方法可以解決這個(gè)問題,這里我要推薦最簡(jiǎn)單的一種,也就是 react-tracked (https://react-tracked.js.org/) 這個(gè)庫,它擁有和 useContext 差不多的 api,但基于 proxy 和組件內(nèi)部的 useForceUpdate 做到了自動(dòng)化的追蹤,可以精準(zhǔn)更新每個(gè)組件,不會(huì)出現(xiàn)修改大的 state,所有組件都刷新的情況。
useCallback
一個(gè)很常見的誤區(qū)是為了心理上的性能提升把函數(shù)通通使用 useCallback 包裹,在大多數(shù)情況下,javascript 創(chuàng)建一個(gè)函數(shù)的開銷是很小的,哪怕每次渲染都重新創(chuàng)建,也不會(huì)有太大的性能損耗,真正的性能損耗在于,很多時(shí)候 callback 函數(shù)是組件 props 的一部分,因?yàn)槊看武秩镜臅r(shí)候都會(huì)重新創(chuàng)建 callback 導(dǎo)致函數(shù)引用不同,所以觸發(fā)了組件的重渲染。然而一旦函數(shù)使用 useCallback 包裹,則要面對(duì)聲明依賴項(xiàng)的問題,對(duì)于一個(gè)內(nèi)部捕獲了很多 state 的函數(shù),寫依賴項(xiàng)非常容易寫錯(cuò),因此引發(fā) bug。所以,在大多數(shù)場(chǎng)景下,我們應(yīng)該只在需要維持函數(shù)引用的情況下使用 useCallback,例如下面這個(gè)例子:
這里我們需要在組件卸載的時(shí)候移除 event listener callback,因此需要保持 event handler 的引用,所以這里需要使用 useCallback 來保持引用不變。
然而一旦我們使用 useCallback,我們又會(huì)面臨聲明依賴項(xiàng)的問題,這里我們可以使用 ahook 中的 useMemoizedFn (https://ahooks.js.org/zh-CN/hooks/use-memoized-fn) 的方式,既能保持引用,又不用聲明依賴項(xiàng)。
是不是覺得很神奇,為什么不用聲明依賴項(xiàng)也能保持函數(shù)引用不變,而內(nèi)部的變量又可以捕獲最新的 state,實(shí)際上,這個(gè) hook 的實(shí)現(xiàn)異常的簡(jiǎn)單,我們只需要用到 useRef 和 useMemo。
所有需要用到 useCallback 的地方都可以用 useMemoizedFn 代替。
memo & useMemo
對(duì)于需要優(yōu)化渲染性能的場(chǎng)景,我們可以使用 memo 和 useMemo,通常用法如下:
考慮到 useMemo 需要聲明依賴項(xiàng),而 memo 不需要,會(huì)自動(dòng)對(duì)所有 props 進(jìn)行淺比較 (Object.is),因此大多數(shù)場(chǎng)景下,我們可以結(jié)合上面提到的 useImmer 以及 useMemoizedFn 保持對(duì)象和函數(shù)的引用不變,以此減少不必要的渲染,對(duì)于 Context 共享的數(shù)據(jù),我們可以使用 react-tracked 進(jìn)行精準(zhǔn)渲染,這些庫的好處是不需要聲明依賴項(xiàng),能減小維護(hù)成本和心智負(fù)擔(dān),對(duì)于剩下的沒法 cover 的場(chǎng)景,我們?cè)偈褂?useMemo 進(jìn)行更細(xì)粒度的渲染控制。
useReducer
相對(duì)于上文中提到的這些 hook,useReducer 是我們?nèi)粘i_發(fā)過程中很少會(huì)用到的一個(gè) hook (因?yàn)榇蟛糠中枰?flux 這樣架構(gòu)的軟件一般都直接上狀態(tài)管理庫了)。
但是,我們可以思考一下,在很多場(chǎng)景下,我們真的需要額外的狀態(tài)管理庫么?
我們來看一下下面的這個(gè)例子:
這是一個(gè)非常簡(jiǎn)單的計(jì)數(shù)器例子,雖說運(yùn)作良好,但是卻反映了一個(gè)問題,當(dāng)我們需要同時(shí)操作一系列相關(guān)的 state 時(shí),在不借助外部狀態(tài)管理庫的情況下,隨著程序的規(guī)模變大,函數(shù)組件內(nèi)部可能會(huì)充斥著非常多的 setState 一系列 state 的操作,這樣視圖就和實(shí)際邏輯耦合起來了,代碼變得難以維護(hù),但其實(shí)我們不一定需要使用外部的狀態(tài)管理庫解決這個(gè)問題,很多時(shí)候 useReducer 就能幫我們搞定這個(gè)問題,我們嘗試用 useReducer 重寫一下這個(gè)邏輯。
我們先寫一個(gè) reducer 的純函數(shù):
如果看到這里,你已經(jīng)忘記了reducer之類的概念,我們來復(fù)習(xí)一下吧。 reducer 通常是一個(gè)純函數(shù),它接受一個(gè)action和一個(gè)payload,當(dāng)然還有上一次的state,基于這三者,reducer計(jì)算出next state,就是這么簡(jiǎn)單。
我們?cè)賮矶x一下初始狀態(tài)以及 action 類型:
接下來只要用 useReducer 把他們組合起來就行了:
這樣我們就把 reducer 這個(gè)狀態(tài)的變更邏輯從組件中抽離出去了,代碼看起來清晰易懂,維護(hù)起來也方便多了。
Q: reducer 是個(gè)純函數(shù),如果我需要獲取異步數(shù)據(jù)呢?
A: 可以使用 use-reducer-async (https://github.com/dai-shi/use-reducer-async) 這個(gè)庫,只要引入一個(gè)極小的包,就能擁有 effect 的能力。
結(jié)語
React Hook 心智負(fù)擔(dān)真的很重,希望 react-forget (https://zhuanlan.zhihu.com/p/443807113) 能早日 production ready。















 
 
 













 
 
 
 