每天都在用,也沒整明白的 React Hook
useState
useState 可以說是我們?nèi)粘W畛S玫?hook 之一了,在實際使用過程中,有一些簡單的小技巧能幫助你提升性能 & 減少出 bug 的概率。
- 使用 惰性初始值 (https://reactjs.org/docs/hooks-reference.html#lazy-initial-state)
通常我們會使用以下的方式初始化 state。
對于簡單的初始值,這樣做完全沒有任何性能問題,但如果初始值是根據(jù)復(fù)雜計算得出來的,我們這么寫就會產(chǎn)生性能問題。
相信你已經(jīng)發(fā)現(xiàn)這里的問題了,對于 useState 的初始值,我們只需要計算一次,但是根據(jù) React Function Component 的渲染邏輯,在每一次 render 的時候,都會重新調(diào)用該函數(shù),因此 initalState 在每次 render 時都會被重新計算,哪怕它只在第一次渲染的時候被用到,這無疑會造成嚴重過的性能問題,我們可以通過 useState 的惰性初始值來解決這個問題。
不要把只需要計算一次的的東西直接放在函數(shù)組件內(nèi)部頂層 block 中。
- 使用 函數(shù)式更新 (https://zh-hans.reactjs.org/docs/hooks-reference.html#functional-updates)
當我們想更新 state 的時候,我們通常會這樣調(diào)用 setState。
看上去沒有任何問題,我們來看看另外一個 case。
點擊 div,我們可以看到計數(shù)器增加,那么 3 秒過后,計數(shù)器的值會是幾呢?
答案是 1
這是一個非常反直覺的結(jié)果,原因在于第一次運行函數(shù)時,state 的值為 0,而 setTimeout 中的回調(diào)函數(shù)捕獲了第一次運行 Demo 函數(shù)時 state 的值,也就是 0,所以 setState(state + 1)執(zhí)行后 state 的值變成了 1,哪怕當前 state 值已經(jīng)不是 0 了。
讓我們通過函數(shù)式更新修復(fù)這個問題。
讓我們再運行一次程序試試,這次 3 秒后,state 并沒有變成 1,而是增加了 1。
直接在 setState 中依賴 state 計算新的 state 在異步執(zhí)行的函數(shù)中會由于 js 閉包捕獲得到預(yù)期外的結(jié)果,此時可以使用 setState(prev => getNewState(prev)) 函數(shù)式更新來解決。
- 使用 useImmer (https://github.com/immerjs/use-immer) 替代 useState。
相信很多同學(xué)聽過 immer.js 這個庫,簡單來說就是基于 proxy 攔截 getter 和 setter 的能力,讓我們可以很方便的通過修改對象本身,創(chuàng)建新的對象,那么這有什么用呢?我們知道,React 通過 Object.is 函數(shù)比較 props,也就是說對于引用一致的對象,react是不會刷新視圖的,這也是為什么我們不能直接修改調(diào)用 useState 得到的 state 來更新視圖,而是要通過 setState 刷新視圖,通常,為了方便,我們會使用 es6 的 spread 運算符構(gòu)造新的對象(淺拷貝)。
我相信你已經(jīng)發(fā)現(xiàn)問題了,對于嵌套層級多的對象,使用 spread 構(gòu)造新的對象寫起來心智負擔很大,也不易于維護,這時候聰明的你肯定想到了,我直接 deepClone,修改后再 setState 不就完事了。
這樣就完全沒有心智負擔的問題了,程序也運作良好,然而,這不是沒有代價的,且不說 deepClone 本身對于嵌套層級復(fù)雜的對象就非常耗時,同時因為整個對象都是 deepClone 過來的,而不是淺拷貝,react 認為整個大對象都變了,這時候使用到對象里的引用值的組件也都會刷新,哪怕這兩個引用前后值根本沒有變化。
有沒有兩全其美的方法呢?
當然是用的,這里就要用到我們提到的 immer.js 了。
這里我們可以看到,即使用了 deepClone 沒有心智負擔的寫法,同時 immer 只會改變寫部分的引用 (也就是所謂的“Copy On Write”),其余沒用變動的部分引用保持不變,react 會跳過這部分的更新,這樣我們就同時獲得了簡易的寫法和良好的性能。
事實上,我們還可以使用 useImmer 這個語法糖來進一步簡化調(diào)用方式。
可以看到,使用 useImmer 之后,setState 幾乎跟原生的 useState提供的函數(shù)式更新 api 一模一樣,只不過,你可以在 setState 內(nèi)直接修改對象生成新的 state ,同時 useImmer 還對普通的 setState 用法做了兼容,你也可以直接在 setState 內(nèi)返回新的 state,完全沒有心智負擔。
useEffect
前面講了useState,那么還有一個開發(fā)過程中最常用的就是 useEffect 了,接下來來聊聊我的日常使用過程中 useEffect 的坑吧。
- 在 useEffect 中調(diào)用 setState
在很多情況下,我們可能會寫出這樣的代碼
咋一眼看上去沒有任何問題,dep1 這個 state 變動的時候我更新一下 dep2 的值,然而這其實是一種反模式,我們可能會習(xí)慣性的把 useEffect 當成一個 watch 來用,但每次我們 setState 過后,函數(shù)組件又會重新執(zhí)行一遍,useEffect 也會重新跑一遍,這里你肯定會想,那不是成死循環(huán)了,但其實不然,useEffect 提供的第二個參數(shù)允許我們傳遞依賴項,在依賴項不變的情況下會跳過 effect 執(zhí)行,這才讓我們的代碼可以正常運行。
所以到這里,聰明的你肯定已經(jīng)發(fā)現(xiàn)問題了么?
要是我 dep 數(shù)組寫的不對,那不是有可能出現(xiàn)無限循環(huán)?
在實際開發(fā)過程中,如此高的心智負擔必然不利于代碼的維護,因此我們來聊一聊什么是 effect,setState 又該在哪里調(diào)用,我們來看一個圖:
這里的 input / output 部分即 IO,也就是副作用,Input 產(chǎn)生一些值,而中間的純函數(shù)對 Input 做一些轉(zhuǎn)換,最終生成一堆數(shù)據(jù),通過 output driver 執(zhí)行副作用,也就是渲染,修改標題等操作,對應(yīng)到 React 中,我們可以這樣理解。
所有使用 hook 得到的狀態(tài)即為 input 副作用產(chǎn)生的值。
function 組件函數(shù)本身是中間轉(zhuǎn)換的純函數(shù)。
React.render 函數(shù)作為 driver 負責讀取轉(zhuǎn)換好之后的值,并且執(zhí)行渲染這個副作用 (其它的副作用在 useEffect 和 useLayoutEffect中執(zhí)行 )。
基于以上的心智模型,我們可以得出這么幾個結(jié)論:
- 不要直接在函數(shù)頂層 block 中調(diào)用 setState 或者執(zhí)行其它副作用的操作!
(提醒一下,直接在函數(shù)頂層 block 中調(diào)用 setState,if 條件一下沒寫好,組件就掛了)
- 所有組件內(nèi)部狀態(tài)的轉(zhuǎn)換都應(yīng)該歸于純函數(shù)中,不要把 useEffect 當成 watch 來用。
我們可以使用這種方式計算新的 state。
(注意這里并沒有使用 useMemo )
在 React 中,每次渲染都會重新調(diào)用函數(shù),因此直接寫在函數(shù)體內(nèi)的自然就是 compute state ,在沒有嚴重性能問題的情況下不推薦使用 useMemo, 依賴項寫錯了容易出 bug。
- 盡可能在 event 中執(zhí)行 setState,以確??深A(yù)測的 state 變化,例如:
- onClick 事件
- Promise
- setTimeout
- setInterval
- ...
- 依賴項為空數(shù)組的 useEffect 中,可以放心調(diào)用 setState。
- 不要同時使用一堆依賴項 & 多個 useEffect !!!
如果你寫過以下的代碼:
這樣的代碼非常容易造成循環(huán)依賴的問題,而且一旦出了問題,非常難排查很解決,整個 state 的更新很難預(yù)測,相關(guān)的 state 更新如果建議一次更新 (可以考慮使用 useReducer 并且在可能的情況下,盡量將狀態(tài)更新放到事件而不是 useEffect 里)。
useContext
在多個組件共享狀態(tài)以及要向深層組件傳遞狀態(tài)時,我們通常會使用 useContext 這個 hook 和 createContext 搭配,也就是下面這樣:
這也是 React 官方推薦的共享狀態(tài)的方式,然而在需要共享狀態(tài)的組件非常多的情況下,這有著嚴重的性能問題,在上述例子里,哪怕 A 組件只更新 state.a,并沒有用到 state.b,B 組件更新 state.b 的時候 A 組件也會刷新,在組件非常多的情況下,就卡死了,用戶體驗非常不好。好在這個地方有很多種方法可以解決這個問題,這里我要推薦最簡單的一種,也就是 react-tracked (https://react-tracked.js.org/) 這個庫,它擁有和 useContext 差不多的 api,但基于 proxy 和組件內(nèi)部的 useForceUpdate 做到了自動化的追蹤,可以精準更新每個組件,不會出現(xiàn)修改大的 state,所有組件都刷新的情況。
useCallback
一個很常見的誤區(qū)是為了心理上的性能提升把函數(shù)通通使用 useCallback 包裹,在大多數(shù)情況下,javascript 創(chuàng)建一個函數(shù)的開銷是很小的,哪怕每次渲染都重新創(chuàng)建,也不會有太大的性能損耗,真正的性能損耗在于,很多時候 callback 函數(shù)是組件 props 的一部分,因為每次渲染的時候都會重新創(chuàng)建 callback 導(dǎo)致函數(shù)引用不同,所以觸發(fā)了組件的重渲染。然而一旦函數(shù)使用 useCallback 包裹,則要面對聲明依賴項的問題,對于一個內(nèi)部捕獲了很多 state 的函數(shù),寫依賴項非常容易寫錯,因此引發(fā) bug。所以,在大多數(shù)場景下,我們應(yīng)該只在需要維持函數(shù)引用的情況下使用 useCallback,例如下面這個例子:
這里我們需要在組件卸載的時候移除 event listener callback,因此需要保持 event handler 的引用,所以這里需要使用 useCallback 來保持引用不變。
然而一旦我們使用 useCallback,我們又會面臨聲明依賴項的問題,這里我們可以使用 ahook 中的 useMemoizedFn (https://ahooks.js.org/zh-CN/hooks/use-memoized-fn) 的方式,既能保持引用,又不用聲明依賴項。
是不是覺得很神奇,為什么不用聲明依賴項也能保持函數(shù)引用不變,而內(nèi)部的變量又可以捕獲最新的 state,實際上,這個 hook 的實現(xiàn)異常的簡單,我們只需要用到 useRef 和 useMemo。
所有需要用到 useCallback 的地方都可以用 useMemoizedFn 代替。
memo & useMemo
對于需要優(yōu)化渲染性能的場景,我們可以使用 memo 和 useMemo,通常用法如下:
考慮到 useMemo 需要聲明依賴項,而 memo 不需要,會自動對所有 props 進行淺比較 (Object.is),因此大多數(shù)場景下,我們可以結(jié)合上面提到的 useImmer 以及 useMemoizedFn 保持對象和函數(shù)的引用不變,以此減少不必要的渲染,對于 Context 共享的數(shù)據(jù),我們可以使用 react-tracked 進行精準渲染,這些庫的好處是不需要聲明依賴項,能減小維護成本和心智負擔,對于剩下的沒法 cover 的場景,我們再使用 useMemo 進行更細粒度的渲染控制。
useReducer
相對于上文中提到的這些 hook,useReducer 是我們?nèi)粘i_發(fā)過程中很少會用到的一個 hook (因為大部分需要 flux 這樣架構(gòu)的軟件一般都直接上狀態(tài)管理庫了)。
但是,我們可以思考一下,在很多場景下,我們真的需要額外的狀態(tài)管理庫么?
我們來看一下下面的這個例子:
這是一個非常簡單的計數(shù)器例子,雖說運作良好,但是卻反映了一個問題,當我們需要同時操作一系列相關(guān)的 state 時,在不借助外部狀態(tài)管理庫的情況下,隨著程序的規(guī)模變大,函數(shù)組件內(nèi)部可能會充斥著非常多的 setState 一系列 state 的操作,這樣視圖就和實際邏輯耦合起來了,代碼變得難以維護,但其實我們不一定需要使用外部的狀態(tài)管理庫解決這個問題,很多時候 useReducer 就能幫我們搞定這個問題,我們嘗試用 useReducer 重寫一下這個邏輯。
我們先寫一個 reducer 的純函數(shù):
如果看到這里,你已經(jīng)忘記了reducer之類的概念,我們來復(fù)習(xí)一下吧。 reducer 通常是一個純函數(shù),它接受一個action和一個payload,當然還有上一次的state,基于這三者,reducer計算出next state,就是這么簡單。
我們再來定義一下初始狀態(tài)以及 action 類型:
接下來只要用 useReducer 把他們組合起來就行了:
這樣我們就把 reducer 這個狀態(tài)的變更邏輯從組件中抽離出去了,代碼看起來清晰易懂,維護起來也方便多了。
Q: reducer 是個純函數(shù),如果我需要獲取異步數(shù)據(jù)呢?
A: 可以使用 use-reducer-async (https://github.com/dai-shi/use-reducer-async) 這個庫,只要引入一個極小的包,就能擁有 effect 的能力。
結(jié)語
React Hook 心智負擔真的很重,希望 react-forget (https://zhuanlan.zhihu.com/p/443807113) 能早日 production ready。