React State Hooks的閉包陷阱,在使用Hooks之前必須掌握

Jokcy發表於2019-03-18

伴隨著 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 之後,我們呼叫了 setLapsesetRunning,這兩個方法是用來更新 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。同樣的,lapserunning 也是每個作用域裡單獨宣告的,**同一次宣告的變數會出於同一個閉包,不同的宣告在不同的閉包。**而 useEffect 只有在第一次渲染,或者後續 running 變化之後才會執行他的回撥,所以對應的回撥裡面使用的閉包,也是每次執行的那次儲存下來的。

這就導致了,在一個 useEffect 內部是無法獲知 running 的變化的,這也是 useEffct 提供第二個引數的原因。

那麼是不是這裡就無解了呢?明顯不是的,這時候我們需要考慮使用 useReducer 來管理 state

逃出閉包

我們先來看一下使用 useReducer 實現的程式碼:

掘金上不讓外掛程式碼,所以點選進去看吧

在這裡我們把 lapserunning 放在一起,變成了一個 state 物件,有點類似 Redux 的用法。在這裡我們給 TICK action 上加了一個是否 running 的判斷,以此來避開了在 running 被設定為 false 之後多餘的 lapse 改變。

那麼這個實現跟我們使用 updateLapse 的方式有什麼區別呢?最大的區別是我們的 state 不來自於閉包,在之前的程式碼中,我們在任何方法中獲取 lapserunning 都是通過閉包,而在這裡,state 是作為引數傳入到 Reducer 中的,也就是不論何時我們呼叫了 dispatch,在 Reducer 中得到的 State 都是最新的,這就幫助我們避開了閉包的問題。

其實我們也可以通過 useState 來實現,原理是一樣的,我們可以通過把 lapserunning 放在一個物件中,然後使用

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 的使用中出現上面提到的這些問題。

相關文章