伴隨着 React Hooks 的正式發佈,由於其易用性以及對於邏輯代碼的複用性更強,毫無疑問愈來愈多的同窗會偏向於使用 Hooks 來寫本身的組件。可是隨着使用的深刻,咱們發現了一些 State Hooks 的陷阱,那麼今天咱們就來分析一下 State Hooks 存在的一些問題,幫助同窗們踩坑。數組
前幾天在 twitter 上看到了一個關於 Hooks 的討論,其內容圍繞着下面的 demo:瀏覽器
掘金上不讓外掛代碼,因此點擊進去看吧markdown
這裏的代碼想要實現的功能以下:閉包
lapse
上加一lapse
爲 0可是這個例子在實際執行過程當中會出現一個問題,那就是在 interval 開啓的狀況下,直接執行 clear,會中止 interval,可是顯示的 lapse
卻不是 0,那麼這是爲何呢?異步
出現這樣的狀況主要緣由是:useEffect
是異步的,也就是說咱們執行 useEffect
中綁定的函數或者是解綁的函數,**都不是在一次 setState
產生的更新中被同步執行的。**啥意思呢?咱們來模擬一下代碼的執行順序:函數
在咱們點擊來 clear
以後,咱們調用了 setLapse
和 setRunning
,這兩個方法是用來更新 state 的,因此他們會標記組件更新,而後通知 React 咱們須要從新渲染來。oop
而後 React 開始來從新渲染的流程,並很快執行到了 Stopwatch
組件。測試
注意以上都是同步執行的過程,因此不會存在在這個過程當中 setInterval
又觸發的狀況,因此在更新 Stopwatch
的時候,若是咱們能同步得執行 useEffect
的解綁函數,那麼就能夠在此次 JavaScript 的調用棧中清除這個 interval
,而不會出現這種狀況。this
可是偏偏由於 useEffect
是異步執行的,他要在 React 走完本次更新以後纔會執行解綁以及從新綁定的函數。那麼這就給 interval
再次觸發的機會,這也就致使來,咱們設置 lapse
爲 0 以後,他又在 interval
中被更新成了一個計算後的值,以後才被真正的解綁。spa
那麼咱們如何解決這個問題呢?
useLayoutEffect
useLayoutEffect
能夠看做是 useEffect
的同步版本。使用 useLayoutEffect
就能夠達到咱們上面說的,在同一次更新流程中解綁 interval
的目的。
那麼同窗們確定要問了,既然 useLayoutEffect
能夠避免這個問題,那麼爲何還要用 useEffect
呢,直接全部地方都用 useLayoutEffect
不就行了。
這個呢主要是由於 useLayoutEffect
是同步的,若是咱們要在 useLayoutEffect
調用狀態更新,或者執行一些很是耗時的計算,可能會致使 React 運行時間過長,阻塞了瀏覽器的渲染,致使一些卡頓的問題。這塊呢咱們有機會再單獨寫一篇文章來分析,這裏就再也不贅述。
useLayoutEffect
固然咱們不能由於 useLayoutEffect
很是方便得解決了問題因此就直接拋棄 useEffect
,畢竟這是 React 更推薦的用法。那麼咱們該如何解決這個問題呢?
在解決問題以前,咱們須要弄清楚問題的根本。在這個問題上,咱們以前已經分析過,就是由於在咱們設置了 lapse
以後,由於 interval
的再次觸發,可是又設置了一次 lapse
。那麼要解決這個問題,就能夠經過避免最新的那次觸發,或者在觸發的時候判斷若是沒有 running
,就再也不設置。
使用 useLayoutEffect
顯然屬於第一種方法來解決問題,那麼咱們接下去來說講第二種方法。
按照這種思路,咱們第一個反應應該就是在 setInterval
的回調中加入判斷:
const intervalId = setInterval(() => { if (running) { setLapse(Date.now() - startTime) } }, 0) 複製代碼
可是很遺憾,這樣作是不行的,由於這個回調方法保存了他的閉包,而在他的閉包裏面,running
永遠都是true
。那麼咱們是否能夠經過在 useEffect
外部聲明方法來逃過閉包呢?好比下面這樣:
function updateLapse(time) { if (runing) { setLapse(time) } } React.useEffect(() => { //... setInterval(() => { updateLapse(/* ... */) }) }) 複製代碼
看上去 updateLapse
使用的是直接外部的 running
,因此不是 setInterval
回調保存的閉包來。可是惋惜的是,這也是不行的。由於 updateLapse
也是 setInterval
閉包中的一部分,在這個閉包當中,running
永遠都是一開始的值。
可能看到這裏你們會有點迷糊,主要就是對於閉包的層次的不太理解,這裏我就專門提出來說解一下。
在這裏咱們的組件是一個函數組件,他是一個純粹的函數,沒有 this
,同理也就沒有 this.render
這樣的在 ClassComponent
中特有的函數,因此每次咱們渲染函數組件的時候,咱們都是要執行這個方法的,在這裏咱們執行 Stopwatch
。
那麼在開始執行的時候,咱們就爲 Stopwatch
建立來一個做用域,在這個做用域裏面咱們會聲明方法,好比 updateLapse
,他是在此次執行 Stopwatch
的時候才聲明的,每一次執行 Stopwatch
的時候都會聲明 updateLapse
。一樣的,lapse
和 running
也是每一個做用域裏單獨聲明的,**同一次聲明的變量會出於同一個閉包,不一樣的聲明在不一樣的閉包。**而 useEffect
只有在第一次渲染,或者後續 running
變化以後纔會執行他的回調,因此對應的回調裏面使用的閉包,也是每次執行的那次保存下來的。
這就致使了,在一個 useEffect
內部是沒法獲知 running
的變化的,這也是 useEffct
提供第二個參數的緣由。
那麼是否是這裏就無解了呢?明顯不是的,這時候咱們須要考慮使用 useReducer
來管理 state
咱們先來看一下使用 useReducer
實現的代碼:
在這裏咱們把 lapse
和 running
放在一塊兒,變成了一個 state
對象,有點相似 Redux 的用法。在這裏咱們給 TICK
action 上加了一個是否 running
的判斷,以此來避開了在 running
被設置爲 false
以後多餘的 lapse
改變。
那麼這個實現跟咱們使用 updateLapse
的方式有什麼區別呢?最大的區別是咱們的 state
不來自於閉包,在以前的代碼中,咱們在任何方法中獲取 lapse
和 running
都是經過閉包,而在這裏,state
是做爲參數傳入到 Reducer 中的,也就是不論什麼時候咱們調用了 dispatch
,在 Reducer 中獲得的 State 都是最新的,這就幫助咱們避開了閉包的問題。
其實咱們也能夠經過 useState
來實現,原理是同樣的,咱們能夠經過把 lapse
和 running
放在一個對象中,而後使用
updateState(newState) { setState((state) => ({ ...state, newState })) } 複製代碼
這樣的方式來更新狀態。這裏最重要的就是給 setState
傳入的是回調,這個回調會接受最新的狀態,因此不須要使用閉包中的狀態來進行判斷。具體的代碼我這邊就不爲你們實現來,你們能夠去試一下,最終的代碼應該相似下面的(沒有測試過):
const [state, dispatch] = React.useState(stateReducer, { lapse: 0, running: false, }) function updateState(action) { setState(state => { switch (action.type) { case TOGGLE: return { ...state, running: !state.running } case TICK: if (state.running) { return { ...state, lapse: action.lapse } } return state case CLEAR: return { running: false, lapse: 0 } default: return state } }) } 複製代碼
若是有問題很是歡迎跟我討論哦。
相信看到這裏你們應該已經有一些本身的心得了,關於 Hooks 使用上存在的一些問題,最主要的其實就是由於函數組件的特性帶來的做用域和閉包問題,一旦你可以理清楚那麼你就能夠理解不少了。
固然咱們確定不只僅是給你們一些建議,從這個 demo 中咱們也總結出一些最佳實踐:
useReducer
,或者相似的方式,對狀態操做定義類型,執行不一樣的操做。好了,以上就是這一次的分享,但願你們能收穫必定的經驗,避免之後在 Hooks 的使用中出現上面提到的這些問題。