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 回调 |
如果 H 为 null(即在渲染阶段之外调用),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;
}
useMemo 与 useCallback 存储 [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 如何桥接 react 与 react-reconciler 包、state 如何存储在 fiber 上,以及 effect 系统如何与 commit 阶段协同工作。下一篇文章将跨越 reconciler 与渲染器之间的边界,深入探讨让 React 保持渲染器无关性的 host config 契约,并追踪 react-dom-bindings 如何为浏览器 DOM 实现这一契约——包括事件委托系统与 DOM 变更操作。