搞懂 useState 和 useEffect 的實現原理

現在寫 react 組件基本都是 function + hooks 了,因爲 hooks 很強大也很靈活。

比如 useState 可以聲明和修改 state,useEffect 可以管理異步邏輯,useContext 可以讀取 context 的值等等,還可以把它們進一步封裝成自定義 hooks(自定義 hooks 其實就是普通的函數封裝)。

雖然每天都在用 hooks,但依然有很多人不知道 hooks 的實現原理。

所以這篇文章我們就一起來探究下 hooks 的原理吧,主要是講 useState 和 useEffect 這兩個 hook。

首先,我們過一下 react 的渲染流程。

我們組件裏用 jsx 描述頁面:

jsx 會被編譯成 render function,也就是類似 React.createElement 這種:

所以之前寫 React 組件都必須有一行 import * as React from 'react',因爲編譯後會用到 React 的 api。

但後來改爲了這種 render function:

由 babel、tsc 等編譯工具自動引入一個 react/jsx-runtime 的包,

然後 render function 執行後產生 React Element 對象,也就是常說的 vdom。

也就是這樣的流程:

然後 vdom 會轉換爲 fiber 結構,它是一個鏈表:

vdom 只有 children 屬性來鏈接父子節點,但是轉爲 fiber 結構之後就有了 child、sibling、return 屬性來關聯父子、兄弟節點。

vdom 轉 fiber 的流程叫做 reconcile,我們常說的 diff 算法就是在 reconcile 這個過程中。

多個節點的 diff 也就是當老的 fiber 子節點列表需要更新的時候,要和新的 vdom 的 children 進行對比,找到哪些是可以複用的,直接移動過去,剩下的節點做增刪,產生新的 fiber 節點列表。

經過 reconcile 之後,就有了新的 fiber 樹了。

這時候還沒處理副作用,也就是 useEffect、生命週期等函數,這些會在 reconcile 結束之後處理。

所以 react 渲染流程整體分爲兩個大階段:render 階段和 commit 階段。

render 階段也就是 reconcile 的 vdom 轉 fiber 的過程,commit 階段就是具體操作 dom,以及執行副作用函數的過程。

commit 階段還分爲了 3 個小階段:before mutation、mutation、layout。

具體操作 dom 的階段是 mutation,操作 dom 之前是 before mutation,而操作 dom 之後是 layout。

layout 階段在操作 dom 之後,所以這個階段是能拿到 dom 的,ref 更新是在這個階段,useLayoutEffect 回調函數的執行也是在這個階段。

理清了 react 的渲染流程 render + commit(before mutation、mutation、layout) 之後,我們來進入今天的主要內容 “hooks 實現原理” 部分吧。

hooks 的數據保存在哪裏呢?比如 useState 的 state,useRef 的 ref 等。

很容易想到是在 fiber 節點上。

比如這樣 3 個 hook:

你就可以在 fiber 節點上找到對應的 3 個 memoizedState 的鏈表節點。

hook 的 api 就是在 fiber 的 memoizedState 鏈表上存取數據的。

那是什麼時候構造這個鏈表的呢?

在第一次調用 useXxx api 的時候。

比如 useRef 第一次調用會走到 mountRef:

在 mountRef 裏可以看到它創建了一個 hook 節點,然後設置了 memoizedState 屬性爲有 current 屬性的對象,也就是 ref 對象。

具體創建 hook 鏈表的過程也很容易看懂:

就是第一個節點掛在 fiber 節點的 memoizedState 屬性上,後面的掛在上個節點的 next 屬性上。

只有第一次 mountRef,那第二次呢?

第二次會走到 updateRef:

這裏的 updateRef 就是取出 hook 的 momorizedState 的值直接返回了:

所以 useRef 的返回的 ref 對象始終是最開始那個。

再看幾個別的 hook,比如 useMemo,它是當依賴不變的時候始終返回之前創建的對象,當依賴變了才重新創建。

一般是用在 props 上,因爲組件只要 props 變了就會重新渲染,用 useMemo 可以避免沒必要的 props 變化。

在 antd 源碼裏就用到很多:

上面這個值就是作爲組件的 props 的,如果不用 useMemo 包裹,那每次都會變成一個新對象,每次都會觸發子組件重新渲染。

這就是 useMemo 的作用,useCallback 也是同理。

它們是怎麼實現的呢?

useMemo 同樣也是分爲 mountMemo 和 updateMemo 兩個階段。

mount 的時候是這樣的:

創建 hook,然後執行傳入的 create 函數,把值設置到 hook.memoizedState 屬性上。

update 的時候會判斷依賴有沒有變:

如果依賴數組都沒變,那就返回之前的值,否則創建新的值更新到 hook.memoizedState。

很容易想到 useCallback 的實現是分爲 mountCallback 和 updateCallback 的:

和 useMemo 的實現大同小異。

至此,我們可以小結一下了:

hook 的數據是存放在 fiber 的 memoizedState 屬性的鏈表上的,每個 hook 對應一個節點,第一次執行 useXxx 的 hook 會走 mountXxx 的邏輯來創建 hook 鏈表,之後會走 updateXxx 的邏輯。

當然,前面的 useRef、useCallback、useMemo 都比較簡單,只是 mountXxx 和 updateXxx 裏的那幾行代碼。

但 useState 和 useEffect 就沒那麼簡單了,因爲它們涉及到了渲染的流程。

我們先來看 useEffect,它是用來封裝副作用邏輯的。

比如這樣:

它同樣分了 mountEffect 和 updateEffect 兩個階段:

mountEffect 裏執行了一個 pushEffect 的函數:

在 updateEffect 裏也是,只是多了依賴數組變化的檢測邏輯:

那這個 pusheEffect 做了什麼呢?

這裏面創建了 effect 對象並把它放到了 fiber.updateQueue 上:

updateQueue 是個環形鏈表,有個 lastEffect 來指向最後一個 effect。

爲什麼要這樣設計呢?

因爲這樣新的 effect 好往後面插入呀,直接設置 lastEffect.next 就行。

也就是說我們執行完 useEffect 之後,就把 effect 串聯起來放到了 fiber.updateQueue 上。

那什麼時候執行 effect 呢?

這個前面說過了,就是 commit 階段執行。

那是在 commit 階段的 before mutation、mutation、layout 的哪個階段執行呢?

都不是。

是在 commit 最開始的時候,異步處理的 effect 列表:

具體處理的過程就是取出 fiber.updateQueue,然後從 lastEffect.next 開始循環處理

遍歷完一遍 fiber 樹,處理完每個 fiber.updateQueue 就處理完了所有的 useEffect 的回調:

那有的同學說了,不在 before mutation、mutation、layout 階段執行有啥好處呢?

因爲異步執行不阻塞渲染呀!

當然,還有個 useLayoutEffect 的 hook,它是在 layout 階段同步調用的。

比如這樣的代碼:

大家覺得打印順序是什麼呢?

結果是先 layout effect 再 effect。

因爲 layout effect 是在 layout 階段,也就是 dom 更新之後同步調用的,而 effect 是異步調用的。

一般不建議用 useLayoutEffect,因爲同步邏輯會阻塞渲染。

layout effect 的執行就是在 layout 階段遍歷所有 fiber,取出 updateQueue 的每個 effect 執行。

這就是 effect 的實現原理。

小結一下:

useEffect 的 hook 在 render 階段會把 effect 放到 fiber 的 updateQueue 中,這是一個  lastEffect.next 串聯的環形鏈表,然後 commit 階段會異步執行所有 fiber 節點的 updateQueue 中的 effect。

useLayoutEffect 和 useEffect 差不多,區別只是它是在 commit 階段的 layout 階段同步執行所有 fiber 節點的 updateQueue 中的 effect。

最後,我們再來看下 useState 的實現:

同樣要分爲 mountState 和 updateState 來看:

它把 initialState 設置到了 hook.baseState 上,這是 state 最終保存的地方。

然後創建了一個 queue,這個是用於多個 setState 的時候記錄每次更新的。

返回的第二個值是 dispatch 函數,給他綁定了當前的 fiber 還有那個 queue。

這樣,當你再執行返回的 setXxx 函數的時候就會走到 dispatch 邏輯:

這時候前兩個參數 fiber 和 queue 都是 bind 的值,只有第三個參數是傳入的新 state,當然,現在還叫 action:

它會創建一個 update 對象,然後標記 fiber 節點,之後調度下次渲染:

這裏要簡單介紹下優先級機制 lane。

假設有 30 多種優先級,怎麼表示呢?

用數字麼?

這樣計算太慢了,而且如果同時有幾種優先級計算起來就比較麻煩了。

所以 react 選擇了用二進制的方式來表示:

每個二進制位代表一種優先級,有多個優先級就是多個位爲 1。

這樣通過位運算就能輕鬆算出是啥優先級:

這種機制就叫做 lane,因爲二進制的位就像一條條賽道一樣,很形象:

創建了 update 對象之後就要標記 fiber 節點有更新了,不只是要標記那個節點,還要標記它的父節點直到跟節點:

所以這個方法名字就叫做 markUpdateFromFiberToRoot,也就是從當前 fiber 一直到 root 的意思:

做的事情就是循環往上一層層 merge lane。

不過當前節點是 fiber.lanes,而父節點是 fiber.childLanes,用來區分是當前節點的更新還是子節點的更新。

標記完更新就是調度下次渲染了。

也就是 scheduleUpdateOnFiber 這個方法:

它裏面最終會調用到 renderRootSync,也就是從跟節點開啓新的 vdom 轉 fiber 的循環:

這樣就觸發了新一次渲染。

然後再渲染到這個函數的時候就會執行到 updateState:

updateState 會調用 updateReducer,選出最終的 state 來返回做渲染:

怎麼決定 state 要更新成啥呢?

自然也是根據優先級,這裏會根據 lane 來比較,然後做 state 的合併,最後返回一個新的 state:

這樣組件裏拿到的就是新 state,然後根據它做渲染。

這就是 useState 的實現原理。

小結一下:

useState 同樣分爲 mountState 和 updateState 兩個階段:

mountState 會返回 state 和 dispatch 函數,dispatch 函數里會記錄更新到 hook.queue,然後標記當前 fiber 到根 fiber 的 lane 需要更新,之後調度下次渲染。

再次渲染的時候會執行 updateState,會取出 hook.queue,根據優先級確定最終的 state 返回,這樣渲染出的就是最新的結果。

總結

react 渲染流程分爲 render 和 commit 階段。

render 階段執行 vdom 轉 fiber 的 reconcile,commit 階段更新 dom,執行 effect 等副作用邏輯。

commit 階段分爲 before mutation、mutation、layout 3 個小階段。

hook 的數據就是保存在 fiber.memoizedState 的鏈表上的,每個 hook 對應一個鏈表節點。

hook 的執行分爲 mountXxx 和 updateXxx 兩個階段,第一次會走 mountXxx,創建 hook 鏈表,之後執行 updateXxx。

我們看了 useRef、useMemo、useCallback 的實現原理,這幾個 hook 都比較簡單。其中後兩個 hook 是作爲 props 時爲了減少不必要渲染的時候用的。

useState 和 useEffect 就和渲染流程有關了:

useEffect 在 render 階段會把 effect 放到 fiber.updateQueue 的環形鏈表上,然後在 commit 階段遍歷所有 fiber 的 updateQueue,取出 effect 異步執行。

useLayoutEffect 和 useEffect 差不多,只是 effect 鏈表是在 layout 階段同步執行的。

useState 的 mountState 階段返回的 setXxx 是綁定了幾個參數的 dispatch 函數。執行它會創建  hook.queue 記錄更新,然後標記從當前到根節點的 fiber 的 lanes 和 childLanes 需要更新,然後調度下次渲染。

下次渲染執行到 updateState 階段會取出 hook.queue,根據優先級確定最終的 state,最後返回來渲染。

這樣就實現了 state 的更新和重新渲染。

這就是 react hooks 特別是 useState 和 useEffect 的實現原理。理解它們不單單要理解 hook 的存儲結構,還要理解 react 的整個渲染流程。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/zhaVGY1yrQB1fECiQMntaQ