Read OSS

Hooks 与 Dispatcher —— React 的状态机

高级

前置知识

  • 第 1 篇:架构概览(fork 系统与 SharedInternals 桥接机制)
  • 第 2 篇:Fiber 数据结构(fiber.memoizedState 字段)
  • 第 3 篇:工作循环(beginWork 如何调用函数组件)
  • 熟悉 React hooks 的日常用法(useState、useEffect、useRef、useMemo)

Hooks 与 Dispatcher —— React 的状态机

Hooks 让函数组件在 React 中真正成为一等公民。但它们也是整个系统中最具架构价值的部分之一——一个依赖调用顺序来维持身份标识的状态机,通过dispatcher 间接层跨包边界桥接,使 hook 存根可以与具体渲染器解耦。

本文将带你追踪一次 useState 调用的完整路径:从 react 包经由 dispatcher 抵达 reconciler 的实际实现;理解 hook 状态如何以链表形式存储在 fiber.memoizedState 上;分析挂载与更新阶段的 dispatcher 切换机制;并解读 effect 系统的标志位设计。

Dispatcher 模式——与渲染器解耦的 Hooks

在组件中调用 useState 时,你实际上调用的是 react 包导出的一个函数。但 react 包本身并不依赖任何渲染器——它对 fiber、工作循环和 DOM 一无所知。那它是如何工作的?

答案藏在 ReactHooks.js 中:

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  // Will result in a null access error if accessed outside render phase.
  return ((dispatcher: any): Dispatcher);
}

export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

每个 hook 存根都遵循同样的模式:从 ReactSharedInternals.H 读取当前 dispatcher,然后将调用委托给它。dispatcher 是一个普通对象,实现了 Dispatcher 接口——每个 hook 对应接口上的一个方法。

sequenceDiagram
    participant C as Component Code
    participant R as react/ReactHooks.js
    participant SI as ReactSharedInternals.H
    participant D as Reconciler Dispatcher

    C->>R: useState(0)
    R->>SI: resolveDispatcher()
    SI-->>R: current dispatcher object
    R->>D: dispatcher.useState(0)
    D-->>R: [state, setState]
    R-->>C: [state, setState]

属性名 H 刻意保持简短——它处于热路径中,需要在代码压缩后保持可预期的行为。SharedInternals 的完整属性集定义在 ReactSharedInternalsClient.js 中:

属性 用途
H Hooks dispatcher
A 异步 dispatcher(用于 Cache)
T 当前 transition
S startTransition 完成回调
G Gesture transition 回调

如果 Hnull(即在渲染阶段之外调用),resolveDispatcher() 不会主动抛出异常——它返回 null,让后续的方法调用自然触发 null 访问错误。这样设计是为了让该函数能被 V8 内联优化。

提示: 第 1 篇介绍的 fork 系统在这里至关重要。构建 react 包时,shared/ReactSharedInternals 会被 fork 为 react/src/ReactSharedInternalsClient.js(直接定义)。而在构建 react-dom 等其他包时,它会解析为 shared/ReactSharedInternals.js,通过读取 React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE 来获取。

Hooks 链表——fiber.memoizedState

在 reconciler 内部,渲染期间每次 hook 调用都会在以 fiber.memoizedState 为起点的单向链表上创建或遍历一个 Hook 节点mountWorkInProgressHook 函数负责创建新节点:

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,  // The hook's own state
    baseState: null,      // Base state for update rebasing
    baseQueue: null,      // Updates that weren't processed
    queue: null,          // Circular linked list of pending updates
    next: null,           // Link to next hook
  };

  if (workInProgressHook === null) {
    // First hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

这正是每次渲染都必须以相同顺序调用 hooks 的根本原因。在更新阶段,React 按顺序遍历已有的链表,将每次 hook 调用与对应节点一一匹配。如果某个 hook 被条件性地跳过,其后的所有 hook 都会读取到错误节点的状态。

graph LR
    FM["fiber.memoizedState"] --> H1["Hook 1<br/>(useState)"]
    H1 -->|next| H2["Hook 2<br/>(useEffect)"]
    H2 -->|next| H3["Hook 3<br/>(useMemo)"]
    H3 -->|next| N["null"]

    style H1 fill:#e1f5fe
    style H2 fill:#f3e5f5
    style H3 fill:#e8f5e9

不同 hook 在 memoizedState 中存储的内容各不相同:

Hook memoizedState 存储内容
useState 当前 state 值
useReducer 当前 state 值
useRef {current: value}
useMemo [computedValue, deps]
useCallback [callback, deps]
useEffect 一个 Effect 对象
useContext 无 Hook 节点(直接从 context 栈读取)

挂载与更新阶段的 Dispatcher 切换

reconciler 维护了三个独立的 dispatcher 对象,定义在第 3898 行

HooksDispatcherOnMount —— 创建新 Hook 节点并初始化状态:

const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  // ...
};

HooksDispatcherOnUpdate —— 遍历已有 Hook 节点并处理排队的更新:

const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  useEffect: updateEffect,
  useRef: updateRef,
  useMemo: updateMemo,
  // ...
};

HooksDispatcherOnRerender —— 用于在渲染过程中触发 state 更新的场景(即在渲染函数体内调用 setState):

const HooksDispatcherOnRerender: Dispatcher = {
  useState: rerenderState,
  useReducer: rerenderReducer,
  // most hooks delegate to the update version
};

切换逻辑发生在 renderWithHooks 中,该函数由 beginWork 在处理函数组件时调用:

export function renderWithHooks(current, workInProgress, Component, props, ...) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null; // Reset for mount
  workInProgress.updateQueue = null;

  ReactSharedInternals.H =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // Now call the component function
  let children = Component(props, secondArg);
  // ...
}

current 为 null(首次渲染),则安装挂载阶段 dispatcher;否则安装更新阶段 dispatcher。组件函数返回后,renderWithHooks 会将 ReactSharedInternals.H 重置为 null——这也是在渲染阶段之外调用 hooks 会报错的原因。

flowchart TD
    RWH["renderWithHooks(current, wip, Component)"]
    Check{current === null?}
    Mount["H = HooksDispatcherOnMount"]
    Update["H = HooksDispatcherOnUpdate"]
    Call["children = Component(props)"]
    Reset["H = null"]

    RWH --> Check
    Check -->|Yes| Mount
    Check -->|No| Update
    Mount --> Call
    Update --> Call
    Call --> Reset

useState 与 useReducer——更新队列

useState 实际上是基于 useReducer 实现的,内置了一个默认 reducer。mountState 函数创建一个带有更新队列的 Hook 节点:

function mountState(initialState) {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

调用 dispatch 函数(即你的 setState)时,dispatchSetState 会创建一个更新对象,并将其追加到 hook.queue 上的环形链表中。更新对象同时被分配一个 lane(优先级),fiber 随即被调度进行重渲染。

这里有一个重要的优化:dispatchSetState 会进行提前 state 计算。如果当前没有正在进行的渲染,且新 state 与当前 state 相同(通过 Object.is 比较),则可以完全跳过本次更新——不会触发任何重渲染。这就是为什么用相同值调用 setState(prevState) 代价极低。

在更新渲染阶段,updateReducer 按顺序重放所有排队的更新。在并发渲染场景下,部分更新可能需要变基(rebase)——当高优先级渲染中断低优先级渲染时,低优先级的更新会保留在 baseQueue 中,并在 baseState 之上重新执行。

Effect 系统——Passive、Layout 与 Insertion Effects

Effects 的存储方式与 state hook 有所不同。调用 useEffect 时,hook 的 memoizedState 指向一个 Effect 对象,同时该 effect 也会被追加到 fiber.updateQueue 上(对于函数组件,这是一个由 effect 组成的环形链表)。

ReactHookEffectTags.js 定义了各标志位:

export const NoFlags   = 0b0000;
export const HasEffect = 0b0001;  // Effect needs to fire
export const Insertion = 0b0010;  // useInsertionEffect
export const Layout    = 0b0100;  // useLayoutEffect
export const Passive   = 0b1000;  // useEffect

HasEffect 是核心标志——当 effect 的依赖项发生变化时会被设置。没有它,effect 对象仍然存在于 fiber 上(用于追踪清理函数),但 commit 阶段会知道无需重新执行该 effect。

三种 effect 类型在 commit 阶段的不同时机触发(详见第 3 篇):

flowchart LR
    subgraph "Commit Phase Timing"
        IE["useInsertionEffect<br/>tag: Insertion | HasEffect<br/>Fires in: Mutation phase<br/>Before DOM updates visible"]
        LE["useLayoutEffect<br/>tag: Layout | HasEffect<br/>Fires in: Layout phase<br/>After DOM mutation, before paint"]
        PE["useEffect<br/>tag: Passive | HasEffect<br/>Fires in: Passive phase<br/>Asynchronous, after paint"]
    end

    IE --> LE --> PE

mountEffect 函数创建一个带有 Passive 标签的 effect,并在 fiber 上设置 Passive 标志(该标志会向上冒泡到 subtreeFlags,使 commit 阶段能够跳过不含 passive effect 的子树)。

其他 Hooks——useRef、useMemo、useCallback、useContext

其余 hooks 的实现相对简单:

useRef —— mountRef 创建一个 {current: initialValue} 对象并存储在 hook.memoizedState 中。更新时,updateRef 直接返回已有对象——ref 永远不会被重新创建。

function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

useMemouseCallback 存储 [value, deps] 元组。更新时,React 逐一用 Object.is 比较新旧依赖项。若所有依赖项均未变化,则返回缓存值;否则重新执行函数(useMemo)或存储新回调(useCallback)。

useContext 独树一帜——它完全不创建 Hook 节点,而是通过 readContext 直接从 reconciler 的 context 栈中读取。从理论上讲,useContext 不需要遵守 hooks 调用顺序规则,不过 React 在开发模式下仍会追踪它以保持一致性。

useTransition 值得关注,因为它与 lane 系统深度集成。调用 startTransition(callback) 时,React 会在执行回调前临时将更新优先级设为 TransitionLane。回调内部的所有 setState 调用都会被分配 transition lane,并以时间切片方式渲染。

提示: "hooks 规则"并非某个神奇的 linter 强制执行的——它们是链表结构的固有要求。每次 hook 调用在每次渲染中都必须对应链表中的同一位置。一旦条件性地插入或跳过某个 hook,Hook 3 可能会读取到 Hook 2 的 state,引发难以排查的隐性 bug。

下一步

至此,我们已经了解了 hooks 如何桥接 reactreact-reconciler 包、state 如何存储在 fiber 上,以及 effect 系统如何与 commit 阶段协同工作。下一篇文章将跨越 reconciler 与渲染器之间的边界,深入探讨让 React 保持渲染器无关性的 host config 契约,并追踪 react-dom-bindings 如何为浏览器 DOM 实现这一契约——包括事件委托系统与 DOM 变更操作。