伴隨著 React Hooks 的正式釋出,因為其易用性以及對於邏輯程式碼的複用性更強,毫無疑問越來越多的同學會偏向於使用 Hooks 來寫自己的元件。但是隨著使用的深入,我們發現了一些 State Hooks 的陷阱,那麼今天我們就來分析一下 State Hooks 存在的一些問題,幫助同學們踩坑。
前幾天在 twitter 上看到了一個關於 Hooks 的討論,其內容圍繞著下面的 demo:
這裡的程式碼想要實現的功能如下:
- 點選 Start 開始執行 interval,並且一旦有可能就往
lapse
上加一 - 點選 Stop 後取消 interval
- 點選 Clear 會取消 interval,並且設定
lapse
為 0
但是這個例子在實際執行過程中會出現一個問題,那就是在 interval 開啟的情況下,直接執行 clear,會停止 interval,但是顯示的 lapse
卻不是 0,那麼這是為什麼呢?
出現這樣的情況主要原因是:useEffect
是非同步的,也就是說我們執行 useEffect
中繫結的函式或者是解綁的函式,**都不是在一次 setState
產生的更新中被同步執行的。**啥意思呢?我們來模擬一下程式碼的執行順序:
在我們點選來 clear
之後,我們呼叫了 setLapse
和 setRunning
,這兩個方法是用來更新 state 的,所以他們會標記元件更新,然後通知 React 我們需要重新渲染來。
然後 React 開始來重新渲染的流程,並很快執行到了 Stopwatch
元件。
注意以上都是同步執行的過程,所以不會存在在這個過程中 setInterval
又觸發的情況,所以在更新 Stopwatch
的時候,如果我們能同步得執行 useEffect
的解綁函式,那麼就可以在這次 JavaScript 的呼叫棧中清除這個 interval
,而不會出現這種情況。
但是恰恰因為 useEffect
是非同步執行的,他要在 React 走完本次更新之後才會執行解綁以及重新繫結的函式。那麼這就給 interval
再次觸發的機會,這也就導致來,我們設定 lapse
為 0 之後,他又在 interval
中被更新成了一個計算後的值,之後才被真正的解綁。
那麼我們如何解決這個問題呢?
使用 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 中我們也總結出一些最佳實踐:
- 講相關的 state 最好放到一個物件中進行統一管理
- 使用更新方法的時候最好使用回撥的方式,使用傳入的狀態,而不要使用閉包中的 state
- 管理複雜的狀態可以考慮使用
useReducer
,或者類似的方式,對狀態操作定義型別,執行不同的操作。
好了,以上就是這一次的分享,希望大家能收穫一定的經驗,避免以後在 Hooks 的使用中出現上面提到的這些問題。