React 源码探秘(五):Hooks 的秘密,useState 背后的链表

从一条 React 报错说起

几乎所有 React 开发者都见过这条报错:

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render.

为什么 Hooks 必须「每次渲染都以相同顺序调用」?答案藏在 fiber 上的一条链表里。

Hook 是什么?一条挂在 fiber 上的链表

在 React 17/18 中,函数组件对应的 fiber 节点上有一个属性 memoizedState。类组件里它保存的是 state,而函数组件里,它指向第一条 Hook。每个 Hook 都是一个对象(源码见 ReactFiberHooks.js):

JAVASCRIPT
type Hook = { memoizedState: any, // 当前值:useState 的 state、useEffect 的 effect baseState: any, // 基础状态(并发中断后重算的起点) baseQueue: Update | null, queue: UpdateQueue | null, // 更新队列,dispatch 产生的 update 挂在这里 next: Hook | null, // 指向下一个 Hook };

next 字段把组件里所有 Hook 串成一条单向链表:第一个 useState 是链表头,接着是第二个 useState、useEffect、useRef……顺序与你在组件里书写的顺序完全一致。渲染期间,React 用一个内部游标 workInProgressHook 在这条链表上「走指针」,每调用一个 Hook,就取下一个节点。

换句话说:Hook 没有名字,只有编号。它们靠「第几个」来区分,这既是这套设计的核心,也是一切使用规则的根源。

两套 Dispatcher:mount 与 update

同一个 useState,在首次渲染和更新渲染时执行的是完全不同的函数。ReactFiberHooks.js 里维护了两套分发器:

JAVASCRIPT
const HooksDispatcherOnMount = { useState: mountState, useEffect: mountEffect, // ... }; const HooksDispatcherOnUpdate = { useState: updateState, useEffect: updateEffect, // ... };
  • 首次渲染:走 mount 系列,负责创建 Hook 节点并建链
  • 更新渲染:走 update 系列,负责读取已有节点并计算新值

React 通过 ReactCurrentDispatcher.current 这个全局变量在两者间切换。render 阶段一结束,它会被重置——所以你不能在事件回调、setTimeout、Promise 里调用 Hooks:那一刻「当前 dispatcher」已经不在了。

mountState:把链表建起来

首次渲染调用 mountState,大致逻辑如下:

JAVASCRIPT
function mountState(initialState) { const hook = mountWorkInProgressHook(); // 新建 Hook 并串到链表尾部 if (typeof initialState === 'function') { initialState = initialState(); // 支持懒初始化 } hook.memoizedState = hook.baseState = initialState; const queue = { pending: null, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: initialState, }; hook.queue = queue; const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue); queue.dispatch = dispatch; return [hook.memoizedState, dispatch]; }

mountWorkInProgressHook 只做两件事:new 一个 Hook 对象,再把它接到上一个 Hook 的 next 上。首次渲染结束时,fiber.memoizedState 就是这条链的头节点,之后每次更新都能顺着它找到全部 Hook。

updateState:读链表、算新值

再看更新分支,useState 的实现短得让人意外:

JAVASCRIPT
function updateState(initialState) { return updateReducer(basicStateReducer, initialState); }

useState 的更新逻辑其实就是 useReducer,只不过它内置了一个极简 reducer:

JAVASCRIPT
function basicStateReducer(state, action) { return typeof action === 'function' ? action(state) : action; }

action 是函数(setCount(c => c + 1)),就走函数式更新;否则直接把 action 当作新值。这就是 setState 既能传值又能传函数的根本原因——传函数时,你其实是在给 reducer 传一个「把旧状态映射到新状态的函数」。

updateReducer 会读取当前 Hook 的 queue,把其中的 update 依次执行,算出新的 memoizedState,并同步维护 baseState/baseQueue——这部分是并发渲染下可中断更新的基石:即使渲染被更高优先级的任务打断,也能从正确的起点重算。

dispatchSetState:更新进入队列

setCount(1) 返回的 dispatch,源码里是绑定好 fiber 与 queue 的 dispatchSetState

JAVASCRIPT
function dispatchSetState(fiber, queue, action) { const update = { lane, action, eagerState: null, next: null }; const pending = queue.pending; if (pending === null) { update.next = update; // 第一条 update,自己形成环 } else { update.next = pending.next; // 插入环形链表尾部 pending.next = update; } queue.pending = update; // scheduleUpdateOnFiber(fiber, lane, eventTime); }

三个关键点:

  1. dispatch 持有 fiber 与 queue 的引用,所以它可以在事件回调、定时器、异步请求里随意调用,React 总能顺着 fiber 找到对应组件并触发更新;
  2. queue.pending 是一条环形链表,新 update 插在环尾,保证更新严格按产生顺序被消费;
  3. 每次 dispatch 都会调用 scheduleUpdateOnFiber 进入调度流程,之后由 render 阶段来消费这个环。

eager state:能省则省的优化

dispatchSetState 里还有一段「乐观计算」逻辑:

JAVASCRIPT
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) { const lastRenderedReducer = queue.lastRenderedReducer; if (lastRenderedReducer !== null) { const eagerState = lastRenderedReducer(currentState, action); update.eagerState = eagerState; // 把提前算好的结果缓存起来 if (is(eagerState, currentState)) { return; // 值没变,连调度都省了 } } }

当 fiber 上没有任何待处理更新时,React 会提前用 reducer 算一次新状态:

  • 算出的结果与当前 state 相同(用 Object.is 比较)→ 直接 return,连一次渲染都不触发。这就是为什么连续 setState 相同值不会引起重渲染;
  • 结果不同 → 把结果缓存到 update.eagerState。render 阶段消费这个 update 时如果发现缓存还在,直接复用,省一次重复计算。

一个 update 对象里缓存着 eagerState,既避免多余渲染、又避免重复计算——典型的「用空间换时间」。

为什么 Hooks 不能写在 if 里

现在答案水到渠成:React 靠链表位置而非名字区分 Hook。看这个例子:

JAVASCRIPT
function App({ flag }) { if (flag) { const [a] = useState(1); // 有时是 Hook #1,有时根本不存在 } const [b] = useState(2); // flag 为 false 时,它从 #2 变成了 #1 }

第一次渲染 flag = true:a 占据位置 1,b 占据位置 2,一切正常。第二次渲染 flag = false:a 没有执行,b 落到了位置 1——于是 b 读到的 memoizedState 其实是上一次 a 存的值。数据错位、状态串台就这么发生了。

React 无从知道「哪个值属于哪个变量」,只能靠顺序对齐。一旦某次渲染的 Hook 数量变少、链表对不上,它无法安全猜测,于是直接抛错阻止你。这就是报错信息里「must be called in the exact same order」的源码级含义,也解释了为什么 eslint-plugin-react-hooksrules-of-hooks 是 error 级:它是在编译期帮你守护这条链表顺序。

小结

  • fiber.memoizedState 是函数组件的 Hook 单向链表头;
  • 首次渲染走 HooksDispatcherOnMountmountState 建链),更新走 HooksDispatcherOnUpdateupdateState 读链);
  • useState 本质是内置了 basicStateReduceruseReducer,dispatch 的 action 要么是值、要么是函数;
  • dispatchSetState 把 update 塞进 queue.pending 环形链表并触发调度,eager state 让「值没变的更新」连渲染都直接跳过;
  • Hook 没有名字、只有顺序——这就是「Hooks 必须顶层调用、顺序恒定」的根本原因。

理解这条链表之后,React 文档里那些「不要这么做」的规则就不再是死记硬背,而是能自己推导出来的结论了。下一篇我们聊调度器:React 是怎么靠时间切片,让再长的任务也不卡住主线程的。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 知己知事知理

阅读 0 | 发布于 2026-09-04
暂无评论