从一条 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):
JAVASCRIPTtype 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 里维护了两套分发器:
JAVASCRIPTconst HooksDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect,
// ...
};
const HooksDispatcherOnUpdate = {
useState: updateState,
useEffect: updateEffect,
// ...
};
- 首次渲染:走 mount 系列,负责创建 Hook 节点并建链;
- 更新渲染:走 update 系列,负责读取已有节点并计算新值。
React 通过 ReactCurrentDispatcher.current 这个全局变量在两者间切换。render 阶段一结束,它会被重置——所以你不能在事件回调、setTimeout、Promise 里调用 Hooks:那一刻「当前 dispatcher」已经不在了。
mountState:把链表建起来
首次渲染调用 mountState,大致逻辑如下:
JAVASCRIPTfunction 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 的实现短得让人意外:
JAVASCRIPTfunction updateState(initialState) {
return updateReducer(basicStateReducer, initialState);
}
useState 的更新逻辑其实就是 useReducer,只不过它内置了一个极简 reducer:
JAVASCRIPTfunction 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:
JAVASCRIPTfunction 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);
}
三个关键点:
- dispatch 持有 fiber 与 queue 的引用,所以它可以在事件回调、定时器、异步请求里随意调用,React 总能顺着 fiber 找到对应组件并触发更新;
queue.pending是一条环形链表,新 update 插在环尾,保证更新严格按产生顺序被消费;- 每次 dispatch 都会调用
scheduleUpdateOnFiber进入调度流程,之后由 render 阶段来消费这个环。
eager state:能省则省的优化
dispatchSetState 里还有一段「乐观计算」逻辑:
JAVASCRIPTif (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。看这个例子:
JAVASCRIPTfunction 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-hooks 的 rules-of-hooks 是 error 级:它是在编译期帮你守护这条链表顺序。
小结
fiber.memoizedState是函数组件的 Hook 单向链表头;- 首次渲染走
HooksDispatcherOnMount(mountState建链),更新走HooksDispatcherOnUpdate(updateState读链); useState本质是内置了basicStateReducer的useReducer,dispatch 的 action 要么是值、要么是函数;dispatchSetState把 update 塞进queue.pending环形链表并触发调度,eager state 让「值没变的更新」连渲染都直接跳过;- Hook 没有名字、只有顺序——这就是「Hooks 必须顶层调用、顺序恒定」的根本原因。
理解这条链表之后,React 文档里那些「不要这么做」的规则就不再是死记硬背,而是能自己推导出来的结论了。下一篇我们聊调度器:React 是怎么靠时间切片,让再长的任务也不卡住主线程的。