第 13 章:Hooks 总架构——dispatcher 与 hook 链表
第 10 章看到函数组件渲染时会走 renderWithHooks。本章回答一个更基础的问题:React 怎么记住"哪个 hook 是哪个"?
function Counter() {
const [count, setCount] = useState(0); // hook 1
const [name, setName] = useState(''); // hook 2
useEffect(() => {document.title = count;}, [count]); // hook 3
return <button onClick={() => setCount((c) => c + 1)}>{name}{count}</button>;
}
第二次渲染时,useState 又会被调用三次。React 怎么知道这次的第一个 useState 对应上次的 count,而不是 name?答案是:不靠名字,靠顺序。每个 hook 是一个链表节点,按调用顺序串在 fiber 上,更新时一个对一个地取。
先记住这一句:hooks 存在
fiber.memoizedState的链表上,首次挂载、更新时按调用顺序配对。这就是"Hooks 为什么不能写在条件里"的全部原因。
一图流:一次 useState 调用的幕后
hook 长什么样
每个 hook 就是一个普通对象(ReactFiberHooks.js:195):
type Hook = {
memoizedState: any, // 这个 hook 存的值:useState 存 state,useRef 存 ref,useEffect 存 effect
baseState: any, // 计算用的基础 state
baseQueue: Update | null,
queue: any, // 这个 hook 自己的更新队列(第 14 章主角)
next: Hook | null, // 指向下一个 hook
};
不同 hook 复用同一个 Hook 结构,memoizedState 里装的东西不一样而已。整串链表挂在 fiber 的 memoizedState 上,和第 3 章说"fiber.memoizedState 是挂架"对上号了。
首次挂载:往链表尾追加
mountWorkInProgressHook(ReactFiberHooks.js:980)干的事极其朴素:造一个空 hook,串到链表尾:
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook; // 第一个,当链表头
} else {
workInProgressHook = workInProgressHook.next = hook; // 追加到尾部
}
组件函数第一次执行时,useState 们按书写顺序,一个接一个挂上去,形成 hook1 → hook2 → hook3 的链表。workInProgressHook 是个游标,记录"当前挂到第几个了"。
更新:按顺序克隆旧节点
第二次渲染,走 updateWorkInProgressHook(ReactFiberHooks.js:1001)。它同时推进两个游标:currentHook(上一次渲染的链表)和 workInProgressHook(本次渲染的链表),每次调用各前进一步,一一对应:
nextCurrentHook = currentHook.next; // 旧链表的下一步
nextWorkInProgressHook = workInProgressHook.next; // 新链表的下一步
// 没有新节点,就从旧节点克隆
const newHook = {
memoizedState: currentHook.memoizedState, // 把旧值带过来
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue, // 更新队列也共用
next: null,
};
关键就在 currentHook.next:它假设旧链表和本次调用一一对应。这个假设成立的前提,是你每次渲染都按同样的顺序调同样的 hook。
为什么顺序不能变:报错就藏在源码里
如果这个假设被打破,React 会抛错。错误信息就在 updateWorkInProgressHook 里(ReactFiberHooks.js:1045):
throw new Error('Rendered more hooks than during the previous render.');
"这次渲染比上次多调了 hook"。为什么?因为 currentHook.next 走完了(旧的没那么多),但你还在调 useState,游标对不上号了。反过来少调也会有专门的 DEV 警告。所以 React 的官方规则那么死板:不能写在条件、循环、嵌套函数里。不是 React 不想灵活,是它的存储结构(顺序链表)决定了只能按顺序配对。
每个 hook 调用的入口:resolveDispatcher
你写的 useState()、useEffect() 最终都调到 React 内部的同名函数。每个函数第一件事就是调 resolveDispatcher(ReactFiberHooks.js:3860):
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
if (__DEV__) {
if (dispatcher === null) {
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component.'
);
}
}
return dispatcher;
}
然后 useState 的实现(3887)直接转发:
function useState(initialState) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
所有 hook 都是这个模式:resolveDispatcher() 拿当前 dispatcher,然后 dispatcher.xxx() 转发。所以关键是 ReactSharedInternals.H 指到谁。
renderWithHooks:切换 dispatcher 的地方
第 10 章提过 renderWithHooks,这里看它怎么"选对实现"(ReactFiberHooks.js:812)。核心逻辑在函数末尾:
// 根据是首次挂载还是更新,选不同的 dispatcher
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 首次:全部走 mountXxx
: HooksDispatcherOnUpdate; // 更新:全部走 updateXxx
current 是上次渲染的 fiber。如果它没有 memoizedState(即从未挂过 hook),就走 mount 实现;否则走 update 实现。判断一次,整个组件函数执行期间 ReactSharedInternals.H 不变,所以所有 hook 调用走同一套实现。
两套 dispatcher 的结构
HooksDispatcherOnMount(3894)和 HooksDispatcherOnUpdate(3924)是两个大对象,每个 hook 都有 mount/update 两个版本:
// 首次挂载用的 dispatcher
const HooksDispatcherOnMount = {
useState: mountState, // 造新 hook 节点 + 初始化 state
useReducer: mountReducer,
useEffect: mountEffect, // 造新 effect 节点 + 挂到 fiber.updateQueue
useLayoutEffect: mountLayoutEffect,
useRef: mountRef,
useMemo: mountMemo,
useCallback: mountCallback,
useContext: readContext, // 注意:mount 和 update 都用同一个 readContext
useId: mountId,
// ... 其他 hook
};
// 更新用的 dispatcher
const HooksDispatcherOnUpdate = {
useState: updateState, // 从旧 hook 节点克隆 + 跑 update 队列
useReducer: updateReducer,
useEffect: updateEffect,
useLayoutEffect: updateLayoutEffect,
useRef: updateRef,
useMemo: updateMemo,
useCallback: updateCallback,
useContext: readContext, // 同一个 readContext
useId: updateId,
// ... 其他 hook
};
useContext 是例外——mount 和 update 都指向同一个 readContext,因为它只管"读当前值",不需要区分首次还是更新。
dispatcher 选实现,链表存数据,两者合起来就是 Hooks 的全貌。
动手实验
- 亲手触发报错:把
useState包进一个if,第二次渲染时少调一次,或换成条件Math.random()。看控制台那条 "Rendered more hooks than during the previous render.",对照 ReactFiberHooks.js:1045。 - DevTools 看链表:React DevTools → Components → 选中组件 → 展开 Hooks 列表。它展示的就是
fiber.memoizedState这条链表,每个 hook 一个节点。 - 在 mountWorkInProgressHook 打断点:一个用了三个 hook 的组件,观察 hook 节点怎么一个个
next串起来,workInProgressHook游标怎么推进。
小结
- hook 是链表节点,串在
fiber.memoizedState上,next指针连成链。 - 首次挂载往尾追加,更新时按顺序克隆旧节点,一一对应。
- "Rendered more hooks than during the previous render." 就是顺序配不上时的报错(ReactFiberHooks.js:1045)。
- dispatcher 选实现,链表存数据,合起来就是 Hooks 全貌。
- 下一步:链表里那个
queue是干什么的?第 14 章看 useState/useReducer 的更新队列。