14|Hook 的实现视角:从 API 到 Fiber Update Queue 的连接点
本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从运行时到核心算法的关键设计。开源仓库:https://github.com/facebook/react
在第 13 篇我们把 React Server Components(RSC)在仓库里的分层与边界讲清楚了;这一篇我们把镜头拉回"所有人都天天写、但很少人真正理解它怎么落地"的那一层:Hook。
这篇要回答几个经常读到一半就卡住的问题:
useState/useReducer的更新到底是怎么"接到" Fiber 与 lane 调度上的?- 为什么 Hook API 看起来极薄:几乎只是
dispatcher.useState(...)?React 把复杂度藏到哪了? - "Rules of Hooks" 的强约束(只能顶层调用、顺序不能变)到底如何落到源码里?
本文核心文件:
packages/react/src/ReactHooks.jspackages/react/src/ReactSharedInternalsClient.jspackages/react-reconciler/src/ReactFiberHooks.jspackages/react-reconciler/src/ReactFiberConcurrentUpdates.jspackages/react-reconciler/src/ReactFiberWorkLoop.js
0) 先立一个"非直觉结论":Hook API 的"薄"不是偷懒,而是为了把语义钉在 render phase
从 ReactHooks.js 看,Hook 的公共 API 非常薄:
useState(initial)useEffect(create, deps)
它们几乎只是:
- 取出一个
dispatcher - 调用
dispatcher.useState/useEffect/...
源码(packages/react/src/ReactHooks.js):
js
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
if (__DEV__) {
if (dispatcher === null) {
console.error('Invalid hook call...');
}
}
return ((dispatcher: any): Dispatcher);
}
export function useState(initialState) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
如果你把"薄"理解成"简单",就会误判:
- 真正复杂的是:React 必须在 render phase 建立一套"当前正在渲染的 Fiber + 当前 Hook 指针 + 当前 Dispatcher"的运行时上下文。
- 而 Hook API 的职责是:强制所有 Hook 都从这条上下文里取能力,从而保证"只能在 render 中调用""调用顺序稳定"这两条铁律可以被实现与被检测。
换句话说:
- Hook API 的薄 = 把调用入口做成"唯一通道"。
- Dispatcher 的厚 = 把"当前阶段/当前 Fiber/当前 Hook 链表/更新队列/调度"全部绑在一起。
1) ReactSharedInternals.H:Hook 调用能成立的前提,是 Renderer 在 render phase 把 Dispatcher 塞进去
ReactSharedInternals 是 Hook 公共 API 与 Reconciler 之间的一根"暗线"。
在 client 版本里(packages/react/src/ReactSharedInternalsClient.js),你能直接看到这几个关键槽位:
js
export type SharedStateClient = {
H: null | Dispatcher, // ReactCurrentDispatcher for Hooks
A: null | AsyncDispatcher, // ReactCurrentCache for Cache
T: null | Transition, // ReactCurrentBatchConfig for Transitions
S: null | onStartTransitionFinish,
...
};
这意味着:
H是 Hook 的"事实源"(谁提供useState/useEffect的真正实现)。T是 Transition 上下文(它会影响requestUpdateLane的 lane 选择)。
再反过来看 packages/shared/ReactSharedInternals.js:
- 共享对象本身来自
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE。
所以 Hook 公共 API 之所以能"薄",是因为:
- React 运行时提供了一个共享槽位。
- Renderer/Reconciler 在 render phase 负责写入正确的 dispatcher。
- Hook API 永远从槽位读,并且在槽位为空时(非 render)直接报错。
2) renderWithHooks:Dispatcher 是在这里被安装的(而且会在 render 结束后立刻卸载)
文件:packages/react-reconciler/src/ReactFiberHooks.js
renderWithHooks 是 Hook 的"真正入口"。它做的第一件事不是调用组件,而是建立渲染上下文:
renderLanes = nextRenderLanescurrentlyRenderingFiber = workInProgress- 清空
workInProgress.memoizedState/updateQueue/lanes
然后最关键的一步:根据 mount/update 选择 dispatcher 并写入 ReactSharedInternals.H:
js
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
直觉上你可以把它理解成:
- mount dispatcher:
useState走mountState,会创建 hook/queue,并创建稳定 dispatch。 - update dispatcher:
useState走updateState,会消费队列、计算新 state。
更"硬"的约束在 render 结束时:
finishRenderingHooks 会把 ReactSharedInternals.H 立刻切回 ContextOnlyDispatcher:
js
ReactSharedInternals.H = ContextOnlyDispatcher;
这一步的意义是:
- 一旦离开 render phase,再调用
useState,resolveDispatcher()拿到的就不是 Hook dispatcher。 - 这就是"Hook 只能在 function component body 中调用"的运行时地基。
3) Hook 不是数组,是链表:fiber.memoizedState 上挂着一条 Hook list
同一个文件里,React 把 Hook 的数据结构写得很直白:
js
export type Hook = {
memoizedState: any,
baseState: any,
baseQueue: Update<any, any> | null,
queue: any,
next: Hook | null,
};
并且紧跟着就是最重要的全局指针:
js
let currentlyRenderingFiber: Fiber = (null: any);
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;
这就是"Rules of Hooks"在实现层面的真正含义:
- 同一次 render 中,Hook 的调用顺序必须稳定,才能让
mountWorkInProgressHook/updateWorkInProgressHook在链表上前进。 - 一旦你条件调用 Hook,链表指针就会错位,React 要么读错队列,要么直接抛错。
3.1 mount:每调用一次 Hook,就 append 一个节点
js
function mountWorkInProgressHook(): Hook {
const hook: Hook = { memoizedState: null, baseState: null, baseQueue: null, queue: null, next: null };
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
3.2 update:从 current fiber 的 hook 克隆一份到 work-in-progress
updateWorkInProgressHook 的核心行为是:
- 找到
nextCurrentHook - 找到(或复用)
nextWorkInProgressHook - 如果没有可复用的 wip hook,就 clone:
js
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
然后把它 append 到 currentlyRenderingFiber.memoizedState 这条链上。
你会注意到:
- update 时 queue 是"沿用"的(
queue: currentHook.queue)。 - 这就是为什么同一个
useState在多次 render 之间能保持同一个 queue。
4) useState 的本体:queue 才是 Hook 更新与调度的连接点
还是在 ReactFiberHooks.js,mountStateImpl 会创建 UpdateQueue:
js
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
然后 mountState 会创建一个稳定的 dispatch:
js
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
这一行非常关键:
dispatch捕获了 fiber + queue。- 因而一次
setState(action)并不是"调用组件函数",而是"向 queue 里追加 update,并把更新从 fiber 传导到 root 调度"。
这就是 Hook 更新链路的第一块拼图。
5) dispatchSetState:Hook 更新如何选择 lane,并接入 Root 调度
dispatchSetState 入口(同文件):
- 先用
requestUpdateLane(fiber)决定 lane。 - 构造一个
update对象(包含lane/action/next等)。 - 非 render-phase update:走并发更新队列与 root 调度。
关键代码(省略 DEV 分支):
js
const lane = requestUpdateLane(fiber);
const didScheduleUpdate = dispatchSetStateInternal(fiber, queue, action, lane);
5.1 lane 从哪里来:requestUpdateLane 会读 Transition 上下文
在 ReactFiberWorkLoop.js:
js
export function requestUpdateLane(fiber: Fiber): Lane {
if (!disableLegacyMode && (fiber.mode & ConcurrentMode) === NoMode) {
return SyncLane;
}
const transition = requestCurrentTransition();
if (transition !== null) {
return requestTransitionLane(transition);
}
return eventPriorityToLane(resolveUpdatePriority());
}
这让你把三条线连起来:
- 普通事件更新:优先级来自事件系统,映射到 lane。
startTransition内的更新:通过ReactSharedInternals.T(Transition 上下文)走 transition lane。- legacy 模式:直接走
SyncLane。
5.2 "快路径":eager bailout 解释了为什么某些 setState 不会触发 re-render
dispatchSetStateInternal 里有一段很典型的优化:
- 当 fiber 与 alternate 都没有 pending lanes(
NoLanes)时 - 且
queue.lastRenderedReducer存在 - React 会用上一次 render 的 reducer 预计算
eagerState - 如果
eagerState与currentState相等:直接 enqueue 一个 "bailout update",但不 schedule render
js
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
这段代码是理解"为什么 Hook 需要 lastRenderedReducer/lastRenderedState"的钥匙。
5.3 正常路径:enqueueConcurrentHookUpdate → scheduleUpdateOnFiber
当需要真正调度时:
js
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true;
}
这就是 Hook 更新跨过"Hook 私有世界",进入 "Fiber/Root/WorkLoop" 的那个连接点。
6) enqueueConcurrentHookUpdate:为什么 Hook queue 没有 root backpointer?React 怎么补这个洞?
文件:packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
js
export function enqueueConcurrentHookUpdate(fiber, queue, update, lane) {
enqueueUpdate(fiber, queue, update, lane);
return getRootForUpdatedFiber(fiber);
}
getRootForUpdatedFiber 里有一段非常"工程现实"的注释:
- 由于 update queue 没有指向 root 的 backpointer,React 只能沿着
fiber.return一路向上走到HostRoot。
js
// update queues do not have a backpointer to the root, the only way ...
// is to walk up the return path.
并且还留了 TODO:
Consider adding a root backpointer on the update queue.
这告诉你一个现实取舍:
- Hook queue 设计成轻量、可复用、可 clone。
- root 指针会增加内存/写入复杂度。
- 于是 React 在 schedule 时用"沿 return path 走到 root"的方式补齐。
另外,你也能看到并发更新为什么要先"暂存"到一个 module-level 数组:
concurrentQueues收集在 render 进行中发生的更新- render 结束后(或合适时机)再
finishQueueingConcurrentUpdates()真正把 update 挂进 circular list(queue.pending)并更新childLanes
这也是并发模式下"更新进队列的时机"与"Fiber 上 lanes 冒泡到 root 的时机"可以分离的原因。
7) scheduleUpdateOnFiber:Hook 更新最终进入 WorkLoop 的"总开关"
文件:packages/react-reconciler/src/ReactFiberWorkLoop.js
scheduleUpdateOnFiber(root, fiber, lane) 做的事情可以压缩成三段:
- 把 root 标记为有 pending update :
markRootUpdated(root, lane) - 处理中断与 interleaved updates(如果当前 root 正在 render,或 render/commit 被 suspend)
- 确保 root 被调度 :
ensureRootIsScheduled(root)
关键片段:
js
markRootUpdated(root, lane);
...
ensureRootIsScheduled(root);
并且 legacy 同步更新还有一个经典分支:
- 如果是
SyncLane且不在 batch 中:直接flushSyncWorkOnLegacyRootsOnly()。
也就是说:
- Hook 更新不会"自己跑起来"。
- Hook 更新只负责把 update 挂到队列、把 lane 标记到 fiber/root。
- 真正把 CPU 时间分配给这次更新的,是 WorkLoop 的 root scheduling 机制。
8) 一图看懂:从 useState 到 root 调度的最短链路
ReactHooks.useState
resolveDispatcher -> ReactSharedInternals.H
HooksDispatcherOnMount/Update.useState
mountState: queue + dispatchSetState.bind(fiber, queue)
dispatch(action)
dispatchSetState
requestUpdateLane (WorkLoop)
dispatchSetStateInternal: create update
enqueueConcurrentHookUpdate (ConcurrentUpdates)
getRootForUpdatedFiber: walk return path
scheduleUpdateOnFiber (WorkLoop)
markRootUpdated + ensureRootIsScheduled
总结:Hook 的"薄"是表象,真正的契约是"render phase 上下文 + queue + lane"
把本文压缩成三句话:
- Hook 公共 API 之所以薄,是因为 React 把能力集中到
ReactSharedInternals.H的 Dispatcher 上,并且只在 render phase 安装它。 - Hook 状态不是"挂在函数上",而是挂在 Fiber 的
memoizedState链表里;useState的核心连接点是queue,而不是返回的 state 值。 dispatchSetState的本质是:选择 lane → 生成 update → 进入并发更新队列 → 找到 root →scheduleUpdateOnFiber触发 WorkLoop 调度。
下一篇预告
第 15 篇我们把这条链路再拉长一点,做一次真正的"全链路复盘":
- 从
updateContainer/scheduleUpdateOnFiber进入 work loop - lane 如何影响
getNextLanes与时间切片策略 - render/commit 如何把一次更新落到真实 DOM(以及与 SSR/RSC 的拼装关系)