[FE] React 初窺門徑(七):hook 狀態(tài)創(chuàng)建/更新原理

1. 回顧

上一篇 文章我們介紹了 React 函數(shù)組件的更新過程。
我們來總結以下 組件加載 和 更新 的全流程。

(1)組件載入時,會創(chuàng)建兩棵 Fiber Tree

一棵為當前已寫入 DOM 的 Fiber Tree(名為 current)。
commit 階段 之前這個 Fiber Tree 只有一個根節(jié)點。

另一棵為當前正在渲染的 Fiber Tree,(名為 workInProgress)。
render 階段 就是在創(chuàng)建它。

到了 commit 階段,React 會將 workInProgress 的 Fiber Tree 實際寫到 DOM 中,
然后將 current 指向這個 Fiber Tree。

這樣就完成了組件的首次加載。

(2)事件觸發(fā)組件更新時

首先是由 React 的事件系統(tǒng)監(jiān)聽到用戶事件,然后觸發(fā)用戶綁定的事件處理函數(shù)。
在這個事件處理函數(shù)中,示例中我們用了 hook setState 來更新組件組件狀態(tài)。
執(zhí)行過程中,會將 performSyncWorkOnRoot 放到 syncQueue 中。
然后,用戶事件就執(zhí)行完了。

用戶事件執(zhí)行完之后,React 會緊接著執(zhí)行 flushSyncCallbackQueue,
獲取到 syncQueue 中的 performSyncWorkOnRoot 進行執(zhí)行。

performSyncWorkOnRoot 實際上就是組件的 rendercommit 方法。
(在組件的第一次更新時)它會創(chuàng)建一棵 workInProgress 的 Fiber Tree,然后在 commit 階段 寫到 DOM 中(之后,將 current 指向這棵 Fiber Tree)。
(如果是組件非首次更新,此時內存中已經(jīng)有了兩棵 Fiber Tree 了,此時 render 階段,并不會重新創(chuàng)建一棵全新的 Fiber Tree,而是盡可能利用現(xiàn)有 Fiber Tree 的節(jié)點,這個邏輯在 createWorkInProgress 中控制)。

如此這般,就完成了組件的更新。

以上分析中,我們是從 Fiber Tree 的角度,從 rendercommit 的角度來看待組件的更新過程,
略過了組件的狀態(tài)的計算過程。

在實際開發(fā)中,常見的場景是,

  • 有多個 hook(setState
  • 一次更新調用了多次 setState

React 內部是如何處理這個狀態(tài)計算的呢?本文我們來仔細研究下這個問題。

2. 場景:多個 hook

2.1 示例項目的修改

參考 example-project/src/AppTwoState.js

我們修改了 App 組件如下,

const App = () => {
  debugger;
  const [state1, setState1] = useState(0);
  debugger;
  const [state2, setState2] = useState('a');
  debugger;

  const onDivClick = () => {
    debugger;
    setState1(1);
    debugger;
    setState2('b');
    debugger;
  };

  debugger;
  return <div onClick={onDivClick}>
    {state1}-{state2}
  </div>;
}

其中用到了兩個 hook(都是 useState),這樣會給 App 組件創(chuàng)建兩個獨立的狀態(tài) state1 state2

2.2 兩個 hook 的更新流程

我們來跟蹤一下兩個 useStatesetState1 setState2 的執(zhí)行過程。

完整的執(zhí)行流程在這里:7.1 hook 原理:多個 hook,總共分為三個部分:

  • 組件首次加載時,調用 useState第 1-50 行

  • 用戶點擊 div 時,setState 調用 lastRenderedReducer 更新狀態(tài)(第 51-96 行

  • 事件響應完之前,React 調用 flushSyncCallbackQueue 更新狀態(tài)(第 97-154 行

2.3 多個 hook 是怎么存儲的

我們看到組件載入的時候,useState 會調用 mountWorkInProgressHook

function mountWorkInProgressHook() {
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;  // 第一個 useState
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;                  // 第二個 useState
  }

  return workInProgressHook;
}

每次調用 useState 會創(chuàng)建一個新的 hook,多個 hook 構成了一個鏈表結構(第二個 hooknext 指向 第一個 hook

(1)第一個 hook


currentlyRenderingFiber$1<App /> 節(jié)點(Fiber Node),
并且,F(xiàn)iber Node 的 memorizedState 指向了 hook 鏈表的第一個 hook。

currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;

(2)第二個 hook
設置 第一個 hooknext 屬性指向 第二個 hook,

通過 Fiber Node(currentlyRenderingFiber$1)觀察一下 hook 鏈表的結構,

currentlyRenderingFiber$1.memorizedState -> hook1
hook1.next -> hook2
hook2.next -> null

2.4 dispatch(setState1 setState2)

雖然 hook 是通過鏈表結構來存儲的,但實際調用 setState1 setState2 的時候,卻并不是通過鏈表來取的。

這是是因為雖然 setState 只傳入了一個參數(shù) action,

但實際 React 已通過 bind 傳入了其他參數(shù),另外兩個參數(shù)是 fiberqueue,

fiber 就是上文那個 currentlyRenderingFiber$1,queue 就是 setState1 對應 hook 的 queue 屬性值(hook 相關的 update quque,下文介紹)

所以調用 setState1 setState2 時不用在 hook 鏈表中進行查找,而是直接進入 dispatchAction 函數(shù)中。

3. 場景:多次 dispatch

上文介紹了多個 hook 的存儲和調用原理,在實際項目中,還會有一個事件中多次調用了 dispatch(setState),
這些 dispatch 函數(shù)也許是同一個狀態(tài)的 dispatch(多次調用 setState),也許是不同狀態(tài)的(先后調用 setState1 setState2)。
原理其實是大同小異的,為了簡單起見,本文只介紹后者,即,一個事件中,多次調用了同一個 hook 的 dispatch(setState)的執(zhí)行流程。

3.1 示例項目的修改

示例項目的修改如下,example-project/src/AppAsyncState.js
(為了便于跟蹤,setState 采用了回調方式進行編寫)

const App = () => {
  debugger;
  const [state, setState] = useState(0);
  debugger;

  const onDivClick = () => {
    debugger;
    setState(s => {
      debugger;
      return s + 1;
    });
    debugger;
    setState(s => {
      debugger;
      return s + 2;
    });
    debugger;
  };

  debugger;
  return <div onClick={onDivClick}>
    {state}
  </div>;
}

3.2 多次調用 setState 的執(zhí)行流程

完整的執(zhí)行流程可參考 7.2 hook 原理:多次調用,包含以下兩個部分,
(省略了組件首次加載的流程)

(1)用戶點擊 div 觸發(fā)事件,事件中調用了兩次 setState第 1-65 行


我們看到 React 只執(zhí)行了第一個狀態(tài)更新函數(shù)(第一次 setStateaction 參數(shù)),

s => {
  debugger;
  return s + 1;
}

第二次 setStateaction 并未在這個階段執(zhí)行,而是將更新過程,放到了一個名為 update循環(huán)隊列中。
參考 dispatchAction L16620

function dispatchAction(fiber, queue, action) {
  ...
  var update = {
    lane: lane,
    action: action,
    eagerReducer: null,
    eagerState: null,
    next: null
  };

  var pending = queue.pending;

  if (pending === null) {
    update.next = update;          // 第一次調用 setState 時,循環(huán)隊列只有一個元素(自己指向自己)
  } else {
    update.next = pending.next;
    pending.next = update;         // <- 將 update 放到循環(huán)隊列中(邏輯見下文解釋)
  }

  queue.pending = update;
  var alternate = fiber.alternate;

  if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
    ...
  } else {
    if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
      ...
      if (lastRenderedReducer !== null) {
        ...
        try {
          var currentState = queue.lastRenderedState;
          var eagerState = lastRenderedReducer(currentState, action);

          update.eagerReducer = lastRenderedReducer;    // <- 用來標記這個 update 元素已經(jīng)計算過了
          update.eagerState = eagerState;

          if (objectIs(eagerState, currentState)) {
            return;
          }
        } catch (error) {// Suppress the error. It will throw again in the render phase.
        } finally {
          ...
        }
      }
    }
    ...
  }
  ...
}

update 邏輯如下,

  • 每個 hook 維護了一個 update quque,hookpending 屬性指向了這個 quque 的隊尾(隊尾的 next 為隊首)
  • 每次調用 setState(= dispatchAction)都會創(chuàng)建一個 update 節(jié)點
  • 第一次調用 setState,update quque 只包含了一個元素(自己指向自己),然后設置 hook.pending 指向這個 update 元素
  • 第二次調用 setState,會在 update quque 隊尾添加一個元素,再設置當前這個隊尾元素指向隊首,
hook.pending -> 當前的 update 元素
(當前的 update 元素).next -> 隊首
原隊尾.next -> 當前的 update 元素

以上這樣設置的好處是,可以從隊尾元素開始,循環(huán)獲取 next 元素,將隊列按順序處理一遍。

值得一提的是,React 采用了給 update.eagerReducer 賦值為 lastRenderedReducer 的方式,來標記這個 update 元素已經(jīng)處理過了,

update.eagerReducer = lastRenderedReducer;

這里要留意一下,下文會用到。

(2)事件完成之前,React 通過 flushSyncCallbackQueue,更新 Fiber Tree,并寫入到 DOM 中,第 67-166 行


其中 syncQueue 中保存了 performSyncWorkOnRoot,React 用它在事件結束之前更新頁面(見 前一篇 的分析)
update quque 是本文介紹的內容,React 在每次調用 setState 的時候,會創(chuàng)建一個循環(huán)隊列,然后在 performSyncWorkOnRootrender 階段 再執(zhí)行計算。

代碼邏輯在這里 updateReducer L15761

function updateReducer(reducer, initialArg, init) {
  var hook = updateWorkInProgressHook();
  var queue = hook.queue;
  ...
  if (baseQueue !== null) {
    ...
    do {                                        // <- 從隊首開始處理 update quque
      ...
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        ...
      } else {
        ...
        if (update.eagerReducer === reducer) {  // 用來標記第一個 setState 已經(jīng)計算過了
          newState = update.eagerState;
        } else {
          var action = update.action;
          newState = reducer(newState, action); // 后續(xù)未計算過的 setState,會按順序執(zhí)行計算
        }
      }

      update = update.next;
    } while (update !== null && update !== first);
    ...
  }
  ...
}

這里出現(xiàn)了對 update 元素 update.eagerReducer 的判定,來區(qū)分這個元素所表示的 setState 是否已經(jīng)計算過了。
所以,除了第一個 setState 是 “同步”(setState 返回之前)執(zhí)行的之外,
后續(xù)各個 setState 都是 “異步”(setState 返回后,由 React 通過 flushSyncCallbackQueuerender 階段) 執(zhí)行的。

4. fiber, hook, update

Fiber Tree,F(xiàn)iber Node,hook,Update Queue 四者的關系如下,


  • Fiber Tree 有兩棵
    一棵是已寫入到的 DOM 的(稱為 current),一棵是用于 render 階段處理的(稱為 workInProgress
    Fiber Tree 的根節(jié)點的 tagHostRoot
    兩棵 Fiber Tree 的根節(jié)點通過 stateNode 指向 FiberRootNode,它通過 containerInfo 保存了 html 元素 div#root
    Fiber Tree 的節(jié)點有三個屬性,return 指向父節(jié)點,child 指向子節(jié)點,alternate 指向同級的另一棵 Fiber Tree

  • 一個 React 組件可以使用多個 hook(創(chuàng)建多個獨立的狀態(tài))
    hook 保存在了 Fiber Node (代表 <App /> 元素的那個)的 memorizedState 屬性中,多個 hook 以鏈表形式存儲
    (同層級的 Fiber Node 共用一個 hook 對象)(可能會出現(xiàn)復制的情況)
    每一個 hook(比如 useState)返回一個新的 dispatch 方法,
    特定 dispatch 方法的每次調用,都會創(chuàng)建一個 update 元素,并添加到 update quque 中。
    hook.queue.pending 指向了 update queue 的隊尾,隊尾指向隊首(循環(huán)隊列)。

  • 組件通過 setState 進行狀態(tài)更新時
    只有第一個 更新 會在 setState 返回值之前執(zhí)行,不論是 setState(action) 中的 action 是數(shù)值還是函數(shù)
    后續(xù)所有(同一個或其他 hook)的 setState 調用,都會將更新放到 update quque 中,
    然后由 React 通過 flushSyncCallbackQueue 調用 performSyncWorkOnRootrender 階段按順序執(zhí)行計算。


參考

React 初窺門徑(六):React 組件的更新過程
github: thzt/react-tour
7.1 hook 原理:多個 hook
7.2 hook 原理:多次調用

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容