Morris's blog

為什麼Hook API要有dependencyArray:了解hook Api裡最要注意的stale closure的問題

2019-12-15

上一篇文章裡我們有了,hook 承載住的資料和 callback 其實是一個被放在 React 執行環境底下的 array 的概念, 這篇文章裡要來呈現一個 hook API 裡的獨特設計 - dependency。

dependency 就是指放在useEffect, useCallback, useMemo...這些第一個參數是接受一個function的 hook 的第二個參數,它是一個 array, 裡面放的是第一個參數的 function 裡會用到的外部變數, 如果當每次 function component被執行的時候,這些外部變數有發生變化,然後這些外部變數的值 或是 指向的位置有改變的時候,該 function 應該要被從新執行(在 useEffect, useMemo 的情況時) or 應該要 return 一個新的 function instance(在 useCallback 的情況時)這個變數就應該要被放入到 dependency 裡,要是該 dependency array 裡的值 或 指向的位置(注意是指向的位置,不是 object 裡的值本身,因為是用===去比對),只要一個有變化,重新被傳入的 function 才會被執行,該 function裡面用到的外部變數才會是新的值

example

假設我們寫一個計時器的 custom hook,做的事也很簡單,用一個 useEffect 裡面發動 setInterval,用一個 useState 存住目前算到幾,然後每一秒+1,但是下面這個範例裡,數完 0,跳 1 後,永遠只會停在 1....

讓我們來看看這個 custom hook 的 code

import { useState, useEffect } from 'react'

function usePassedTimeTimer() {
  const [passTimeNowNum, setPassTimeNowNum] = useState(0)

  useEffect(() => {
    const intervalId = setInterval(() => {
      setPassTimeNowNum(passTimeNowNum + 1) // falied !!!
      //  setPassTimeNowNum(prev => prev + 1);  // 上一行換成這一行就OK了 !!!
    }, 1000)

    return () => {
      clearInterval(intervalId)
    }
  }, []) // 或是在[]裡放入 passTimeNowNum,但是這會讓passTimeNowNum每被改變一次就 clearInterval 再 setInterval一次

  return passTimeNowNum
}

export { usePassedTimeTimer }

我們把passTimeNowNum放到 useEffect 裡,但是沒有將它加入 dependency 裡,也沒有在setPassTimeNowNum使用 functional updates的話,就造成passTimeNowNum永遠是它最一開始被宣告的時候的0,不會再被改變。但是在視覺上看起來確實會覺得它應該要被改變,但是如果你不在dependency裡要它有新的值時用新的值的話,整個function就是不會改變。

為什麼會這樣!?

因為該 function(這邊就是指被傳進 useEffect 的那個 callback) 被放到 hook array 裡,在被放進去的時候,形成了一個 closure 環境,把裡面所有用到的外部變數都包裹進去了,function 在執行的時候,根據 js 作用域(scope)的規則,在 function 裡就能夠找到該存取的值,就不會去 function 外部存取,所以如果不做更新的話,那個 function 所形成的 closure 就會是一直留在舊的時候的狀態,這個現象有公認名詞就叫做 stale closure(古くなったクロジーャ), 如果不更新的話,那些外部變數就會是在那個 function 在被宣告的時候的狀態,不會改變。

具體範例?

下面這個 codesandbox 是為了容易觀察這個現象而寫的簡易地模擬 有stale closure特色的 hook API 的程式範例

特別來看一下上面的 code 的 line 33 的定義 useEffect 的地方

const useEffect = cb => {
  // 為了簡化code,先不考慮dependency array
  if (hooks[idx]) {
    // 用index去hooks array裡找有沒有上一次放進去過的cb
    hooks[idx]() // 就是這樣子的呼叫,會讓該callback裡的所有用到的function外部變數留在第一次放進去hooks array時的狀態
  } else {
    hooks[idx] = cb // 用index找不到cb, 代表這是這個component第一次呼叫useEffect, 把傳進來的cb, 放到hooks array裡
    cb()
  }
  idx += 1
}

react 的 code 裡的原始碼?

react/packages/react-reconciler/src/ReactFiberHooks.js
1481 行 定義了 useEffect, 每次我們執行useEffect的時候,會return mountEffect(), 再return mountEffectImpl(), 935 行可以看到把 cb(就是那個create)跟其他一些資訊,放到了 hook.memoizedStatemountEffect 是第一次在 component 裡執行了 useEffect 時在呼叫的,之後都是執行updateEffect,然後再在裡面去比對 deps,來決定要不要把新的 cb 替換掉。

這邊不會看到執行了 cb 的程式碼,而是看到了 一系列function return function 的操作,因為 useEffect 的那個 function 是被丟到類似 requestIdelCallback那樣的 function 裡去讓瀏覽器排程執行(這個又可以單獨寫成一篇文章來講這件事..先不深入探討...。)

好處在哪

因為 function 不會被改變,那麼整個 function 就好像 constant 一樣,constant 是比較容易思考的,這很 functional programing。 這可不是我自己講的,這是 Dan 神這樣說的:
請收聽 Kent C. Dodds 的 podcast - chats with kent, season 1, episode 3 - Realigning Your Model of React After Hooks - With Dan Abramov,可從 37:40 開始聽,也有逐字稿可看。

所以一定注意

dependecy 在使用 hook API 時是非常重要的東西,一定要認真思考,當然那個eslint-plugin-react-hooks是一定要裝的

stale closure 是 react core team 在設計出 hook API 時,刻意的設計,既然是刻意的,就一定是有它的考量的。作為 react 的 user 的我們,應該認識它,理解它,接受它,讓它成我們的血與肉,但是確實一開始一定會有人會很難接受這樣的設計(我就是),但我們還是必須要建立起這個 mental model(心智模型),不然我們在使用 react 時會使用得很痛苦。

reference

response