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

伴隨着 React Hooks 的正式發佈,由於其易用性以及對於邏輯代碼的複用性更強,毫無疑問愈來愈多的同窗會偏向於使用 Hooks 來寫本身的組件。可是隨着使用的深刻,咱們發現了一些 State Hooks 的陷阱,那麼今天咱們就來分析一下 State Hooks 存在的一些問題,幫助同窗們踩坑。數組

前幾天在 twitter 上看到了一個關於 Hooks 的討論,其內容圍繞着下面的 demo:瀏覽器

掘金上不讓外掛代碼,因此點擊進去看吧markdown

這裏的代碼想要實現的功能以下:閉包

  • 點擊 Start 開始執行 interval,而且一旦有可能就往 lapse 上加一
  • 點擊 Stop 後取消 interval
  • 點擊 Clear 會取消 interval,而且設置 lapse 爲 0

可是這個例子在實際執行過程當中會出現一個問題,那就是在 interval 開啓的狀況下,直接執行 clear,會中止 interval,可是顯示的 lapse 卻不是 0,那麼這是爲何呢?異步

出現這樣的狀況主要緣由是:useEffect 是異步的,也就是說咱們執行 useEffect 中綁定的函數或者是解綁的函數,**都不是在一次 setState 產生的更新中被同步執行的。**啥意思呢?咱們來模擬一下代碼的執行順序:函數

在咱們點擊來 clear 以後,咱們調用了 setLapsesetRunning,這兩個方法是用來更新 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。一樣的,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 的使用中出現上面提到的這些問題。

相關文章
相關標籤/搜索