很多前端开发者都背过“只能在函数组件顶层调用 Hook”这条规则,eslint-plugin-react-hooks 也会把循环、条件分支里的 Hook 调用标红。但这条限制并不是框架层面的口头约定,而是由 React 内部保存 Hook 的数据结构决定的:函数组件对应的 Fiber 节点上,所有 Hook 会被串成一条单向链表,链表头挂在 fiber.memoizedState 上,React 重新渲染时只能按调用顺序逐个还原状态。
一、Hook 节点与 fiber.memoizedState 链表
在 React 源码中,每个函数组件对应一个 Fiber 节点。组件内按顺序声明的 useState、useEffect、useMemo 等 Hook,在底层都会被实例化为 Hook 对象,并通过 next 指针串联。原文给出的 Hook 结构如下:
- export interface Hook {
- memoizedState: any; // 保存当前 Hook 的状态(useState 的值、useEffect 的销毁函数与依赖项等)
- baseState: any; // 初始基准状态
- baseQueue: Update<any> | null; // 待处理的更新队列
- queue: UpdateQueue<any> | null; // 本轮产生的更新环状链表
- next: Hook | null; // 指向组件中声明的下一个 Hook
- }
复制代码
这条链表的走向可以概括为:Fiber 节点的 memoizedState 指向第一个 Hook,第一个 Hook 的 next 指向第二个 Hook,第二个 Hook 的 next 指向第三个 Hook,最后一个 Hook 的 next 为 null。例如组件里依次写了 useState(count)、useEffect(log)、useMemo(heavyCalc),它们就会按这个顺序成为链表上的三个节点。
关键点在于,Hook 对象本身没有名字,也没有唯一的 Key 标识。React 在组件更新时无法通过“名称”找到某个 Hook,只能依靠链表的遍历顺序来还原每个 Hook 对应的历史状态。这也解释了为什么条件分支会引发问题:如果某次渲染用 if (condition) 跳过了中间的 Hook 2,那么原本属于 Hook 3 的 useMemo 就会错误地读取 Hook 2 的旧状态,导致整条状态链表错位,严重时直接崩溃。
二、挂载与更新使用两套 Dispatcher
React 针对组件生命周期的不同阶段,准备了两套不同的 Dispatcher 实现字典:HooksDispatcherOnMount 和 HooksDispatcherOnUpdate。首次挂载时走挂载逻辑,组件因 setState 重新执行时切换到更新逻辑,两者操作的是同一条链表的不同阶段。
1. 首次挂载阶段
组件第一次执行时,每次调用 useState 或 useEffect,都会进入 mountWorkInProgressHook()。它的职责很明确:实例化一个新的 Hook 对象,用尾插法追加到当前 Fiber 的 memoizedState 链表末尾,并推进 workInProgressHook 这个全局游标指针。
- function mountWorkInProgressHook() {
- const 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;
- }
复制代码
从代码可以看到,第一个 Hook 会同时成为 Fiber 的 memoizedState 和当前游标;后续 Hook 则通过 workInProgressHook.next = hook 接到尾部。整个挂载过程不依赖任何 Hook 名称,只依赖调用次序。
2. 更新阶段
当组件因为 setState 重新执行时,Dispatcher 切换为 HooksDispatcherOnUpdate,此时调用的是 updateWorkInProgressHook()。它要完成的事情是:从已有的 currentFiber.memoizedState(上一次渲染完成的 Fiber 链表)中按顺序取出旧 Hook 节点,克隆或复用该 Hook,读取其保存的 memoizedState 和未决的更新队列 queue,再处理所有积压的更新 Action,算出新状态并返回。
- function updateWorkInProgressHook() {
- let nextCurrentHook;
- if (currentHook === null) {
- const current = currentlyRenderingFiber.alternate;
- if (current !== null) {
- nextCurrentHook = current.memoizedState; // 从旧 Fiber 根部读取
- } else {
- nextCurrentHook = null;
- }
- } else {
- nextCurrentHook = currentHook.next; // 顺着链表后移一位
- }
- currentHook = nextCurrentHook;
- // 创建对应的 WorkInProgress Hook 副本并按序推进
- const newHook = {
- memoizedState: currentHook.memoizedState,
- baseState: currentHook.baseState,
- baseQueue: currentHook.baseQueue,
- queue: currentHook.queue,
- next: null,
- };
- if (workInProgressHook === null) {
- currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
- } else {
- workInProgressHook = workInProgressHook.next = newHook;
- }
- return workInProgressHook;
- }
复制代码
这里最值得注意的是 currentHook 的推进方式:第一次从 currentlyRenderingFiber.alternate 的 memoizedState 取头节点,之后每次取 currentHook.next,相当于沿着旧链表一位一位向后走。新 Hook 会把旧 Hook 的 memoizedState、baseState、baseQueue、queue 复制过来,next 先置空,再按同样的顺序挂到新的 workInProgress 链表上。因此,新旧两条链表必须保持完全相同的节点顺序,否则复制状态时就会发生“张冠李戴”。
三、useEffect 的环形 Effect 链表
除了保存状态的 Hook,useEffect 产生的副作用还会被打包成一个独立的环形链表,挂载在 fiber.updateQueue 上。它和 memoizedState 上的 Hook 链表是两套结构,但同样依赖声明顺序和调度阶段。
在 Commit 阶段,React 的处理时序可以拆成三部分:Before Mutation / Mutation 阶段处理 DOM 节点的增删改;Layout 阶段同步执行 useLayoutEffect 的销毁与挂载回调;Passive Effects 阶段则通过 Scheduler 异步调度,执行 useEffect 的清理函数并触发新的 effect 回调,从而不阻塞浏览器的首屏绘制。理解这个时序,有助于排查“为什么 useEffect 在绘制后才执行”“为什么清理函数先于下一次 effect 执行”这类问题。
四、从链表结构反推开发约束
看清 mountWorkInProgressHook 的尾插和 updateWorkInProgressHook 的顺序取值,就能明白 Hook 规则的本质:状态不是按变量名存储的,而是按调用槽位存储的。条件调用、循环调用或嵌套函数中调用,都会改变某次渲染的槽位数量或顺序,让更新阶段读错节点。自定义 Hook 同样如此,它只是把多个 Hook 调用封装起来,内部依然要保证每次渲染顺序稳定。
实际排查时,如果遇到状态串位、useEffect 依赖错乱或组件更新后行为异常,可以先检查是否存在提前 return、条件分支包裹 Hook、动态循环生成 Hook 等写法,再借助 eslint-plugin-react-hooks 定位。规则不是形式主义,而是单向链表结构对调用顺序提出的硬性要求。 |