Skip to main content

第 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 是挂架"对上号了。

首次挂载:往链表尾追加

mountWorkInProgressHookReactFiberHooks.js:980)干的事极其朴素:造一个空 hook,串到链表尾:

if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook; // 第一个,当链表头
} else {
workInProgressHook = workInProgressHook.next = hook; // 追加到尾部
}

组件函数第一次执行时,useState 们按书写顺序,一个接一个挂上去,形成 hook1 → hook2 → hook3 的链表。workInProgressHook 是个游标,记录"当前挂到第几个了"。

更新:按顺序克隆旧节点

第二次渲染,走 updateWorkInProgressHookReactFiberHooks.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 切换(HooksDispatcherOnMountOnUpdate)。dispatcher 选实现,链表存数据,两者合起来就是 Hooks 的全貌

动手实验

  1. 亲手触发报错:把 useState 包进一个 if,第二次渲染时少调一次,或换成条件 Math.random()。看控制台那条 "Rendered more hooks than during the previous render.",对照 ReactFiberHooks.js:1045。
  2. DevTools 看链表:React DevTools → Components → 选中组件 → 展开 Hooks 列表。它展示的就是 fiber.memoizedState 这条链表,每个 hook 一个节点。
  3. 在 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 的更新队列。