以 useState 的視角來看 Hooks 的運行機制

大家好,我是小杜杜,我們都知道,在 React v16.8 之前,函數式組件只能接收 props、渲染 UI,做一個展示組件,所有的邏輯就要在 Class 中書寫,這樣勢必會導致 Class 組件內部錯綜複雜、代碼臃腫。所以有必要做出一套函數式代替類組件的方案,因此函數式編程 Hooks 誕生了。

Hooks 的出現即保留了函數式組件的簡潔,又讓其擁有自己的狀態、處理一些副作用的能力、獲取目標元素的屬性、緩存數據等。它提供了 useState 和 useReducer 兩個 Hook,解決自身的狀態問題,取代 Class 組件的 this.setState

在我們日常工作中最常用的就是 useState,我們就從它的源碼入手,瞭解函數式組件是如何擁有自身的狀態,如何保存數據、更新數據的。全面掌握 useState 的運行流程,就等同於掌握整個 Hooks 的運行機制。

先附上一張今天的知識圖譜:

一、當引入 useState 後發生了什麼?

先舉個例子:

import { Button } from "antd";
import { useState } from "react";
const Index = () ={
  const [count, setCount] = useState(0);
  return (
    <>
      <div>大家好,我是小杜杜,一起玩轉Hooks吧!</div>
      <div>數字:{count}</div>
      <Button onClick={() => setCount((v) => v + 1)}>點擊加1</Button>
    </>
  );
};

export default Index;

在上述的例子中,我們引入了 useState,並存儲 count 變量,通過 setCount 來控制 count。也就是說 count 是函數式組件自身的狀態,setCount 是觸發數據更新的函數。

在通常的開發中,當引入組件後,會從引用地址跳到對應引用的組件,查看該組件到底是如何書寫的。

我們以相同的方式來看看 useState,看看 useState 在 React 中是如何書寫的。

文件位置:packages/react/src/ReactHooks.js

export function useState<S>(
  initialState: (() => S) | S,
)[S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

可以看出 useState 的執行就等價於 resolveDispatcher().useState(initialState),那麼我們順着線索看下去:

resolveDispatcher()

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

ReactCurrentDispatcher:

文件位置:packages/react/src/ReactCurrentDispatcher.js

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

通過類型可以看到 ReactCurrentDispatcher 不是 null,就是 Dispatcher,而在初始化時 ReactCurrentDispatcher.current 的值必爲 null,因爲此時還未進行操作

那麼此時就很奇怪了,我們並沒有發現 useState 是如何進行存儲、更新的,ReactCurrentDispatcher.current 又是何時爲 Dispatcher 的。

既然我們在 useState 自身中無法看到存儲的變量,那麼就只能從函數執行開始,一步一步探索 useState 是如何保存數據的。

二、函數式組件如何執行的?

在上章(小冊中的) Fiber 的講解中,瞭解到我們寫的 JSX 代碼,是被 babel 編譯成 React.createElement 的形式後,最終會走到 beginWork 這個方法中, 而 beginWork 會走到 mountIndeterminateComponent 中,在這個方法中會有一個函數叫 renderWithHooks

renderWithHooks 就是所有函數式組件觸發函數,接下來一起看看:

文件位置:packages/react-reconciler/src/ReactFiberHooks

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  currentlyRenderingFiber = workInProgress;

  // memoizedState: 用於存放hooks的信息,如果是類組件,則存放state信息
  workInProgress.memoizedState = null;
  //updateQueue:更新隊列,用於存放effect list,也就是useEffect產生副作用形成的鏈表
  workInProgress.updateQueue = null;

  // 用於判斷走初始化流程還是更新流程
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 執行真正的函數式組件,所有的hooks依次執行
  let children = Component(props, secondArg);

  finishRenderingHooks(current, workInProgress);

  return children;
}

function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) {

  // 防止hooks亂用,所報錯的方案
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  const didRenderTooFewHooks =
    currentHook !== null && currentHook.next !== null;

  // current樹
  currentHook = null;
  workInProgressHook = null;

  didScheduleRenderPhaseUpdate = false;
}

展示的代碼有稍許加工。

我們先分析下 renderWithHooks 函數的入參:

renderWithHooks 的執行流程

  1. 在每次函數組件執行之前,先將 workInProgress 的 memoizedState 和 updateQueue 屬性進行清空,之後將新的 Hooks 信息掛載到這兩個屬性上,之後在 commit 階段替換 current 樹,也就是說 current 樹保存 Hooks 信息

  2. 然後通過判斷 current 樹 是否存在來判斷走初始化( HooksDispatcherOnMount )流程還是更新(  HooksDispatcherOnUpdate )流程。而 ReactCurrentDispatcher.current 實際上包含所有的 Hooks,簡單地講,Reac 根據 current 的不同來判斷對應的 Hooks,從而監控 Hooks 的調用情況

  3. 接下來調用的 Component(props, secondArg) 就是真正的函數組件,然後依次執行裏面的 Hooks;

  4. 最後提供整個的異常處理,防止不必要的報錯,再將一些屬性置空,如:currentHook、workInProgressHook 等。

通過 renderWithHooks 的執行步驟,可以看出總共分爲三個階段,分別是:初始化階段更新階段 以及 異常處理 三個階段,同時這三個階段也是整個 Hooks 處理的三種策略,接下來我們逐一分析。

HooksDispatcherOnMount(初始化階段)

在初始化階段中,調用的是 HooksDispatcherOnMount,對應的 useState 所走的是 mountState,如:

文件位置:packages/react-reconciler/src/ReactFiberHooks.js

// 包含所有的hooks,這裏列舉常見的
const HooksDispatcherOnMount = { 
    useRef: mountRef,
    useMemo: mountMemo,
    useCallback: mountCallback,
    useEffect: mountEffect,
    useState: mountState,
    useTransition: mountTransition,
    useSyncExternalStore: mountSyncExternalStore,
    useMutableSource: mountMutableSource,
    ...
}

function mountState(initialState){
  // 所有的hooks都會走這個函數
  const hook = mountWorkInProgressHook(); 

  // 確定初始入參
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;

  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState),
  };
  hook.queue = queue;

  const dispatch = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

mountWorkInProgressHook

整體的流程先走向 mountWorkInProgressHook() 這個函數,它的作用尤爲重要,因爲這個函數的作用是將 Hooks 與 Fiber 聯繫起來,並且你會發現,所有的 Hooks 都會走這個函數,只是不同的 Hooks 保存着不同的信息

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

  if (workInProgressHook === null) { // 第一個hooks執行
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else { // 之後的hooks
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

來看看 hook 值的參數:

那麼 mountWorkInProgressHook 的作用就很明確了,每執行一個 Hooks 函數就會生成一個 hook 對象,然後將每個 hook 串聯起來

特別注意:這裏的 memoizedState 並不是 Fiber 鏈表上的 memoizedState,workInProgress 保存的是當前函數組件每個 Hooks 形成的鏈表

執行步驟

瞭解完 mountWorkInProgressHook 後,再來看看之後的流程。

首先通過 initialState 初始值的類型(判斷是否是函數),並將初始值賦值給 hook 的memoizedStatebaseState

再之後,創建一個 queue 對象,這個對象中會保存一些數據,這些數據爲:

最後會定義一個 dispath,而這個 dispath 就應該對應最開始的 setCount,那麼接下來的目的就是搞懂 dispatch 的機制。

dispatchSetState

dispatch 的機制就是 dispatchSetState,在源碼內部還是調用了很多函數,所以在這裏對 dispatchSetState 函數做了些優化,方便我們更好地觀看。

function dispatchSetState<S, A>(
  fiber: Fiber, // 對應currentlyRenderingFiber
  queue: UpdateQueue<S, A>, // 對應 queue
  action: A, // 真實傳入的參數
): void {

  // 優先級,不做介紹,後面也會去除有關優先級的部分
  const lane = requestUpdateLane(fiber);

  // 創建一個update
  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

   // 判斷是否在渲染階段
  if (fiber === currentlyRenderingFiber || (fiber.alternate !== null && fiber.alternate === currentlyRenderingFiber)) {
      didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
      const pending = queue.pending;
      // 判斷是否是第一次更新
      if (pending === null) {
        update.next = update;
      } else {
        update.next = pending.next;
        pending.next = update;
      }
      // 將update存入到queue.pending中
      queue.pending = update;
  } else { // 用於獲取最新的state值
    const alternate = fiber.alternate;
    if (alternate === null && lastRenderedReducer !== null){
      const lastRenderedReducer = queue.lastRenderedReducer;
      let prevDispatcher;
      const currentState: S = (queue.lastRenderedState: any);
      // 獲取最新的state
      const eagerState = lastRenderedReducer(currentState, action);
      update.hasEagerState = true;
      update.eagerState = eagerState;
      if (is(eagerState, currentState)) return;
    }

    // 將update 插入鏈表尾部,然後返回root節點
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      // 實現對應節點的更新
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
    }
  }
}

在代碼中我已經將每段代碼執行的目的標註出來,爲了我們更好的理解,分析一下對應的入參,以及函數體內較重要的參數與步驟:

  1. 分析入參:dispatchSetState 一共有三個入參,前兩個入參數被 bind 分別改爲 currentlyRenderingFiber 和 queue,第三個 action 則是我們實際寫的函數;

  2. update 對象:生成一個 update 對象,用於記錄更新的信息

  3. 判斷是否處於渲染階段:如果是渲染階段,則將 update 放入等待更新的 pending 隊列中,如果不是,就會獲取最新的 state 值,從而進行更新。

值得注意的是:在更新過程中,也會判斷很多,通過調用 lastRenderedReducer 獲取最新state,然後進行比較(淺比較),如果相等則退出,這一點就是證明 useState 渲染相同值時,組件不更新的原因。

如果不相等,則會將 update 插入鏈表的尾部,返回對應的 root 節點,通過 scheduleUpdateOnFiber 實現對應的更新,可見 scheduleUpdateOnFiber 是 React 渲染更新的主要函數。

HooksDispatcherOnUpdate(更新階段)

在更新階段時,調用 HooksDispatcherOnUpdate,對應的 useState 所走的是 updateState,如:

文件位置:packages/react-reconciler/src/ReactFiberHooks.js

const HooksDispatcherOnUpdate: Dispatcher = {
  useRef: updateRef,
  useMemo: updateMemo,
  useCallback: updateCallback,
  useEffect: updateEffect,
  useState: updateState,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useMutableSource: updateMutableSource,
  ...
};

function updateState<S>(
  initialState: (() => S) | S,
)[S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

updateState 有兩個函數,一個是 updateReducer,另一個是 basicStateReducer

basicStateReducer 很簡單,判斷是否是函數,返回對應的值即可。

那麼下面將主要看 updateReducer 這個函數,在 updateReducer 函數中首先調用 updateWorkInProgressHook,我們先來看看這個函數,方便後續對 updateReducer 的理解。

updateWorkInProgressHook

updateWorkInProgressHookmountWorkInProgressHook 一樣,當函數更新時,所有的 Hooks 都會執行

文件位置:packages/react-reconciler/src/ReactFiberHooks.js

function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: null | Hook;

  // 判斷是否是第一個更新的hook
  if (currentHook === null) { 
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else { // 如果不是第一個hook,則指向下一個hook
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  // 第一次執行
  if (workInProgressHook === null) { 
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 特殊情況:發生多次函數組件的執行
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;

      const newHook: Hook = {
        memoizedState: null,
        baseState: null,
        baseQueue: null,
        queue: null,
        next: null,
      };
        nextCurrentHook = newHook;
      } else {
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    // 創建一個新的hook
    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null,
    };

    if (workInProgressHook === null) { // 如果是第一個函數
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

updateWorkInProgressHook 執行流程:如果是首次執行 Hooks 函數,就會從已有的 current 樹中取到對應的值,然後聲明 nextWorkInProgressHook,經過一系列的操作,得到更新後的 Hooks 狀態。

在這裏要注意一點,大多數情況下,workInProgress 上的 memoizedState 會被置空,也就是 nextWorkInProgressHook 應該爲 null。但執行多次函數組件時,就會出現循環執行函數組件的情況,此時 nextWorkInProgressHook 不爲 null。

updateReducer

掌握了 updateWorkInProgressHook執行流程後, 再來看 updateReducer  具體有哪些內容。

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
)[S, Dispatch<A>] {

  // 獲取更新的hook,每個hook都會走
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  queue.lastRenderedReducer = reducer;

  const current: Hook = (currentHook: any);

  let baseQueue = current.baseQueue;

  // 在更新的過程中,存在新的更新,加入新的更新隊列
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // 如果在更新過程中有新的更新,則加入新的隊列,有個合併的作用,合併到 baseQueue
    if (baseQueue !== null) {
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = current.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;

    // 循環更新
    do {
      // 獲取優先級
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const isHiddenUpdate = updateLane !== update.lane;

      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);

      if (shouldSkipUpdate) {
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        // 合併優先級(低級任務)
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
         // 判斷更新隊列是否還有更新任務
        if (newBaseQueueLast !== null) {
          const clone: Update<S, A> = {
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: (null: any),
          };

          // 將更新任務插到末尾
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        const action = update.action;

        // 判斷更新的數據是否相等
        if (update.hasEagerState) {
          newState = ((update.eagerState: any): S);
        } else {
          newState = reducer(newState, action);
        }
      }
      // 判斷是否還需要更新
      update = update.next;
    } while (update !== null && update !== first);

    // 如果 newBaseQueueLast 爲null,則說明所有的update處理完成,對baseState進行更新
    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // 如果新值與舊值不想等,則觸發更新流程
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    // 將新值,保存在hook中
    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  if (baseQueue === null) {
    queue.lanes = NoLanes;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

updateReducer 的作用是將待更新的隊列 pendingQueue 合併到 baseQueue 上,之後進行循環更新,最後進行一次合成更新,也就是批量更新,統一更換節點

這種行爲解釋了 useState 在更新的過程中爲何傳入相同的值,不進行更新,同時多次操作,只會執行最後一次更新的原因了。

ContextOnlyDispatcher 異常處理階段

renderWithHooks 流程最後,調用了 finishRenderingHooks 函數,這個函數中用到了 ContextOnlyDispatcher,那麼它的作用是什麼呢?看看代碼:

throwInvalidHookError:

function throwInvalidHookError() {
  throw new Error(
    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
      ' one of the following reasons:\n' +
      '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
      '2. You might be breaking the Rules of Hooks\n' +
      '3. You might have more than one copy of React in the same app\n' +
      'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
  );
}

可以看到 ContextOnlyDispatcher 是判斷所需 Hooks 是否在函數組件內部,有捕獲並拋出異常的作用,這也就解釋了爲什麼 Hooks 無法在 React 之外運行的原因。

三、useState 運行流程

我們以 useState 爲例,講解了對應的初始化和更新,發現整個 Hooks 的運行流程包括三大策略,分別是:初始化、更新和異常。

這裏以 useState 的運行流程,簡單回顧一下:

注意:此外,我們還需要遵守 Hooks 的規則:時序問題,熟悉 Fiber 的三個階段,從而更加深入的瞭解 Hooks。

另外,我們可以思考一個問題,React 中的 Hooks 是在 Fiber 的基礎上誕生的產物,那麼 Hooks 跟 Fiber 真的有必然聯繫嗎?

這篇文章是小冊的第一篇原碼篇,在小冊中上面的問題都會得到解答,這裏就賣個小關子,對 React Hooks 感興趣的小夥伴可以關注下,謝謝大家~

四、玩轉 React Hooks 小冊

有些小夥伴可能會覺得 Hooks 有必要系統的去學習嗎?有必要去買一本這樣的小冊嗎?

事實上,我也有相同的問題,因爲 Hooks 並不算一個新穎的技術,唯一新穎的地方在 React v18 版本的 Hooks 上。

但我們可以捫心自問下,自己真的掌握 Hooks 了嗎?在實際的開發中,是否還停留在 useState、useEffect 基本使用上,對其他 API 並不瞭解,更對整個 Hooks 的運行流程感到陌生。

我非常好奇一點,函數式組件(本質是函數)在渲染和更新的時候,對所有的變量、表達式進行初始化,而 useState、useRef 仍然可以保留變量,這究竟是如何做到的?

爲此,爲了滿足我的好奇,我覺得有必要去梳理一份關於 React Hooks 的內容,去提升,幫助我們打破技術瓶頸期,更加深入進階 React。

我會這樣評價自己的小冊:知其然,知其所以然。

把 React 當作自己的女朋友,徹底瞭解它,從本質上去了解,因爲它可能比你的女朋友還重要,因爲你要靠它去工作,你越瞭解它,它會讓你的工作更加輕鬆,工資更高~

問與答

問:這本小冊只限於 React Hooks ?

答:會以 React Hooks 爲核心,同時穿插其他相關知識作爲輔助,幫助你更好的理解 Hooks,比如:講自定義 Hooks 時會涉及 TS,講 Hooks 運行機制時會提及 Fibler,講 useMemo 會介紹 React 其他優化方案,講 useRef 會 介紹 createRef、ref 屬性問題…… 總之,以 Hooks 爲核心的內容會全部涉及到。

問:小冊涉及到 TS、Jest、Fiber 等知識,這些都沒有接觸過,是否先要了解後才能學習小冊?

答:不用擔心沒接觸過,也不用瞭解,只需要保持一顆想要學習的心即可。小冊所設計的 TS、Jest 等知識都會有詳細的解答。在學習 Hooks 的基礎上,順便掌握其他知識點。

最後

關於這本小冊,我寫的非常詳細,因爲在寫作的時候,有個小小的期望,就是讓不懂 React 的小夥伴,通過閱讀,也可以玩轉 React 😂😂😂,如果可以實現,那就證明這本小冊是成功的。

最後,《玩轉 React Hooks》 小冊將在 5.30 上線,感謝各位大佬和小冊編輯的小姐姐的支持,希望小冊能大賣~

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