第 12 章:Hooks 总架构——dispatcher 与 hook 链表
第 9 章看到函数组件渲染时会走 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 自己的更新队列(第 13 章主角)
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 不想灵活,是它的存储结构(顺序链表)决定了只能按顺序配对。
dispatcher 负责"选对实现"
至于首次该走 mountState 还是更新该走 updateState,第 9 章讲过,靠 ReactSharedInternals.H 这个 dispatcher 切换(HooksDispatcherOnMount 和 OnUpdate)。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是干什么的?第 13 章看 useState/useReducer 的更新队列。