React 19.3.1 源码深度剖析-第 7 章 Hooks 原理全解(React 函数组件的灵魂)
源码版本:React 19.3.1 本章主文件:
packages/react/src/ReactHooks.js(公共 API 的转发层)packages/react-reconciler/src/ReactFiberHooks.js(Hooks 引擎,约 5265 行)packages/react-reconciler/src/ReactFiberNewContext.js(useContext 的底层 readContext)阅读建议:本章所有结论都落到真实源码行号,建议边读边在编辑器里对照打开上述文件。
ReactFiberHooks.js是本章绝对核心,所有"魔法"都在这里。
目录
- 动机与背景:Hooks 为什么出现,又为什么长成这样
- 整体心智模型:一句话 + 一张总览图
- 核心数据结构:Hook / Update / UpdateQueue / Effect / 更新队列
- Dispatcher 机制:全局指针
ReactSharedInternals.H的精妙切换 - 链表存储与
mount/updateWorkInProgressHook:为什么是链表、为什么不能乱序 renderWithHooks与finishRenderingHooks:渲染前的准备与渲染后的校验- 为什么"不能在条件/循环里调用 Hook"——完整证据链
useState与useReducer:状态的本质- 状态更新队列:环形链表、复用与丢弃的 lane 语义、eager 优化
- 渲染阶段更新(render-phase update)与最多 25 次重渲染
useEffect:effect 的环形链表、三种标记、异步 flush 与 deps 比较useRef/useMemo/useCallback:稳定性与短路useContext:不占 hook 链表位置的订阅机制use:统一读取 Context 与 Promise 的新入口- React 19 新 Hook:
useOptimistic/useActionState/useDeferredValue/useTransition - 与其他模块的交互
- 边界场景与坑(穷举)
- 性能影响与量化
- 用法与最佳实践(正例 + 反例)
- 源码索引表
- 自测 FAQ(20 问)
- 本章要点小结
1. 动机与背景:Hooks 为什么出现,又为什么长成这样
1.1 类组件的痛点
在 React 16.8 之前,所有"有状态"或"有副作用"的逻辑只能写在 class 组件里。class 组件有几类根深蒂固的问题:
- 状态逻辑难以复用:
mixins被废弃后,复用逻辑只能靠 render props 或高阶组件(HOC)。这两种方式都会把组件树"套娃"式地包裹起来,造成"wrapper hell",同时让 DevTools 里出现大量无意义的包裹节点。 - 复杂组件变得难以理解:一个
componentDidMount里往往同时做了数据订阅、事件监听、定时器设置;componentWillUnmount里又要把它们一一清理。相关的逻辑被拆散到不同生命周期,不相关的逻辑又被迫挤在同一个生命周期里。 this指向与类语法的门槛:类字段、bind、this让新手困惑;而this在事件回调里丢失又是经典 bug。- 难以被编译器优化:class 组件的生命周期耦合了"何时调用",静态分析难以预测副作用边界。
1.2 Hooks 的设计目标
Hooks 的核心主张是:把"状态"和"副作用"从组件实例(this)上剥离,变成一组可以任意组合、可以局部调用的函数。同一个组件里可以调用多次 useState、useEffect,每次调用都是独立的状态单元。
但这就带来一个根本性的工程难题:函数组件每次渲染都会重新执行,函数内部的局部变量每次都是全新的。那状态、effect、ref 这些"需要跨渲染存活的东西"保存在哪里?
答案不是保存在函数里,而是保存在 React 内部的 fiber(纤程)节点上。每个 fiber 通过一条单向链表串起它所有 hook 的状态。这正是 ReactFiberHooks.js 存在的根本原因。
1.3 为什么用"链表"而不是"数组"
这是一个反复被问到的问题。数组([state0, state1, ...])看起来更简单,但 React 选择了单向链表。原因有二:
- 调用顺序的稳定性依赖"位置"而非"索引":链表天然按"调用顺序"组织,每个 hook 就是链表上的一个节点,渲染时从头顺序遍历。这和"Hooks 必须在顶层、按固定顺序调用"的规则严丝合缝。
- 增量创建、不预分配:数组需要预先知道长度,链表可以在渲染过程中"走到哪建到哪"。配合
mountWorkInProgressHook(新建节点)和updateWorkInProgressHook(克隆节点),React 能在渲染进行中动态地把 hook 挂到 fiber 上。
本章会反复回到"链表 = 调用顺序"这个核心不变量。它是理解 Hooks 一切行为的钥匙。
2. 整体心智模型:一句话 + 一张总览图
一句话:每个函数组件在渲染时,React 会把它体内按调用顺序产生的每个 Hook 状态,以单向链表的形式挂在 workInProgress.memoizedState 上;并由一个全局 dispatcher(ReactSharedInternals.H)把 useState/useEffect 等公共 API 重定向到"挂载版/更新版/重渲染版"的具体实现。
flowchart TB
subgraph API["公共 API 层 (ReactHooks.js)"]
U1["useState / useEffect / useRef ..."]
end
subgraph DISP["Dispatcher 层 (全局指针 ReactSharedInternals.H)"]
D0["ContextOnlyDispatcher<br/>(渲染外: 全抛错)"]
D1["HooksDispatcherOnMount<br/>(首渲染)"]
D2["HooksDispatcherOnUpdate<br/>(更新)"]
D3["HooksDispatcherOnRerender<br/>(重渲染)"]
end
subgraph ENGINE["引擎层 (ReactFiberHooks.js)"]
RW["renderWithHooks"]
M["mountWorkInProgressHook /<br/>updateWorkInProgressHook"]
FR["finishRenderingHooks"]
end
subgraph FIBER["Fiber 节点"]
F["workInProgress.memoizedState → Hook 链表<br/>workInProgress.updateQueue → Effect 环形链表"]
end
U1 --> D0
U1 --> D1
U1 --> D2
U1 --> D3
D1 --> RW
D2 --> RW
D3 --> RW
RW --> M
M --> F
RW --> FR
这张图把全章的骨架立起来了。后面的每一章小节,都是在展开其中某一个方框的内部细节。
3. 核心数据结构:逐字段解释
3.1 Hook 类型(ReactFiberHooks.js:197)
// packages/react-reconciler/src/ReactFiberHooks.js:197
export type Hook = {
memoizedState: any, // 当前渲染"已稳定"的状态(对 useState 就是 state;对 useEffect 是 Effect 对象;对 useRef 是 {current})
baseState: any, // 基础状态:所有被"本次渲染 lane 之外优先级"跳过的更新,最终收敛到的状态
baseQueue: Update<any, any> | null, // 被跳过的更新形成的"延迟队列"
queue: any, // 该 hook 的更新队列(UpdateQueue),dispatch 存于此
next: Hook | null, // 链表指针,指向同一个组件里的下一个 hook
};
逐字段说明:
memoizedState:这个词在 React 源码里出现极多,字面意思是"被记忆(memoize)的状态"。对不同的 hook 它含义不同:useState/useReducer:就是当前的 state。useEffect/useLayoutEffect/useInsertionEffect:存的是Effect对象(见 3.4)。useRef:存的是{current: initialValue}这个 ref 对象本身。useMemo/useCallback:存的是[value, deps]或[callback, deps]这个二元数组。useDeferredValue:存的是被延迟的值。 所以不要把它简单理解成"state",它是"该 hook 想要跨渲染保留的主值"。
baseState:状态计算的"起点"。在updateReducerImpl里,更新被逐个执行,只有那些"本次渲染优先级足够"的更新才会真正改变memoizedState;优先级不够被跳过的更新,会被克隆进baseQueue,并把它们执行前的状态作为新的baseState。这是"并发渲染时低优先级更新不丢失"的基础(详见第 9 章)。baseQueue:被跳过的更新构成的环形链表首节点(的下一个,因为环形链表用next回指自身)。它需要被保留到下一次更高优先级的渲染里重新处理。queue:UpdateQueue对象(见 3.3),持有dispatch(稳定引用)、pending(环形待处理更新)、lastRenderedReducer、lastRenderedState(用于 eager 优化)。next:把同一个组件体内的多个 hook 串成单向链表。
3.2 Update 与 UpdateQueue(ReactFiberHooks.js:167 / :177)
// packages/react-reconciler/src/ReactFiberHooks.js:167
export type Update<S, A> = {
lane: Lane, // 本次更新所属车道(优先级)
revertLane: Lane, // 乐观更新的"回滚车道";非乐观更新为 NoLane
action: A, // 动作:要么是值(useState),要么是函数(useReducer)
hasEagerState: boolean, // 是否已在 dispatch 时提前算出 state
eagerState: S | null, // 提前算出的 state
next: Update<S, A>, // 环形链表指针
gesture: null | ScheduledGesture, // 仅在 enableGestureTransition 下使用
};
// packages/react-reconciler/src/ReactFiberHooks.js:177
export type UpdateQueue<S, A> = {
pending: Update<S, A> | null, // 环形链表的"最后一个"更新(next 回指首节点)
lanes: Lanes, // 所有被纠缠的车道(用于 transition 纠缠)
dispatch: (A => mixed) | null, // setState / dispatch 的稳定引用
lastRenderedReducer: ((S, A) => S) | null, // 上次渲染用的 reducer(eager 优化用)
lastRenderedState: S | null, // 上次渲染得到的 state(eager 优化比较用)
};
注意 pending 是环形链表:用 last.next = first 的环形结构表示"待处理更新集合"。enqueueRenderPhaseUpdate 和 dispatchSetStateInternal 都靠这个环形结构把多次 setState 串起来。
3.3 Effect 与 EffectInstance(ReactFiberHooks.js:219 / :223)
// packages/react-reconciler/src/ReactFiberHooks.js:219
type EffectInstance = {
destroy: void | (() => void), // effect 清理函数;卸载或重建时为 undefined
};
// packages/react-reconciler/src/ReactFiberHooks.js:223
export type Effect = {
tag: HookFlags, // 标记:Passive / Layout / Insertion 及 HasEffect 等
inst: EffectInstance, // 留存 destroy 的可变容器(类似 RefCell)
create: () => (() => void) | void, // 用户的 effect 回调(返回清理函数或不返回)
deps: Array<mixed> | void | null, // 依赖数组
next: Effect, // 环形链表指针(挂在 fiber.updateQueue.lastEffect)
};
EffectInstance单独拆出来,是为了让"清理函数destroy"这个有状态的东西能跨渲染存活而不被不可变的Effect结构覆盖。注释里官方直言这是"额外的内存开销,但概念上正确"。Effect本身也是环形链表,挂在fiber.updateQueue.lastEffect(见 3.5)。
3.4 FunctionComponentUpdateQueue(ReactFiberHooks.js:249)
// packages/react-reconciler/src/ReactFiberHooks.js:249
export type FunctionComponentUpdateQueue = {
lastEffect: Effect | null, // effect 环形链表的尾节点
events: Array<EventFunctionPayload<...>> | null, // useEffectEvent 收集的事件
stores: Array<StoreConsistencyCheck<...>> | null, // useSyncExternalStore 一致性检查
memoCache: MemoCache | null, // useMemoCache(React 编译器用)
};
这个对象挂在 fiber.updateQueue(注意:和 class 组件用的是同名不同结构的 updateQueue)。它承载了函数组件所有"副作用型"数据:effect 环形链表、event 函数、外部 store 检查、memo 缓存。
3.5 存储模型的全局视图
flowchart LR
F["Fiber(workInProgress)"]
F -->|memoizedState| H1["Hook#0<br/>memoizedState/baseState/queue/next"]
H1 -->|next| H2["Hook#1"]
H2 -->|next| H3["Hook#2 ... 单向链表"]
F -->|updateQueue| UQ["FunctionComponentUpdateQueue"]
UQ -->|lastEffect| E1["Effect(环形)<br/>next 回指首节点"]
H1 -->|queue| Q["UpdateQueue<br/>pending(环形)/dispatch"]
两个独立链表:
- 一个是
fiber.memoizedState上的 Hook 链表(状态型 hook 串这里); - 另一个是
fiber.updateQueue.lastEffect上的 Effect 环形链表(副作用型 hook 串这里)。
useContext 两者都不占——它走 fiber.dependencies.firstContext(见第 13 章)。
4. Dispatcher 机制:全局指针 ReactSharedInternals.H 的精妙切换
4.1 公共 API 只是转发器
打开 packages/react/src/ReactHooks.js,你会发现 useState、useEffect、useRef 等函数体高度雷同:
// packages/react/src/ReactHooks.js:66
export function useState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
每个公共 API 都做同一件事:调用 resolveDispatcher() 拿到当前 dispatcher,然后把调用转给它。
resolveDispatcher(ReactHooks.js:24):
// packages/react/src/ReactHooks.js:24
function resolveDispatcher() {
const dispatcher = ReactSharedInternals.H;
if (__DEV__) {
if (dispatcher === null) {
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component...'
);
}
}
// 故意不自己抛错,因为这条路径是"热路径",且有助于内联
return dispatcher as any as Dispatcher;
}
关键点:
- 所有 hook 的真正实现都不在
react包,而在react-reconciler包的HooksDispatcherOnXxx里。公共包只是个"取指针 + 转发"的壳。这就是为什么react和react-dom(或react-reconciler)版本必须匹配——ReactSharedInternals.H由 renderer 写入,如果两边版本错配,指针为null,就触发上面那段 "Invalid hook call"。 ReactSharedInternals.H是全局单例指针。它决定了当组件体内调用useState时,实际执行的是mountState还是updateState还是rerenderState。
4.2 四个 dispatcher(ReactFiberHooks.js:3898 起)
源码里定义了四个生产环境 dispatcher(DEV 还有各自带校验的变体):
// packages/react-reconciler/src/ReactFiberHooks.js:3898
export const ContextOnlyDispatcher: Dispatcher = {
readContext,
use,
useCallback: throwInvalidHookError,
useContext: throwInvalidHookError,
useEffect: throwInvalidHookError,
// ... 其余 hook 全部 throwInvalidHookError
};
// :3926
const HooksDispatcherOnMount: Dispatcher = {
readContext,
use,
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
// ... 全部指向 mountXxx
useState: mountState,
useReducer: mountReducer,
useRef: mountRef,
// ...
};
// :3954
const HooksDispatcherOnUpdate: Dispatcher = {
// ... 全部指向 updateXxx
useState: updateState,
useReducer: updateReducer,
useRef: updateRef,
useEffect: updateEffect,
// ...
};
// :3982
const HooksDispatcherOnRerender: Dispatcher = {
// ... 全部指向 updateXxx 或 rerenderXxx
useState: rerenderState,
useReducer: rerenderReducer,
useEffect: updateEffect,
};
它们的分工:
| Dispatcher | 触发时机 | 行为特征 |
|---|---|---|
ContextOnlyDispatcher | 任何渲染阶段之外(组件函数体之外) | 除了 use / readContext,其余全部抛错 |
HooksDispatcherOnMount | 组件的首次渲染 | 每个 hook 都"从零建立":新建链表节点、新建队列 |
HooksDispatcherOnUpdate | 组件因 props/state/context 变化而重新渲染 | 每个 hook "克隆并复用"上一次的节点 |
HooksDispatcherOnRerender | 因渲染阶段更新触发的额外重渲染(renderWithHooksAgain) | 状态型 hook 重新消费更新队列,副作用型复用 update 逻辑 |
4.3 为什么要有"渲染外抛错"的 dispatcher
ContextOnlyDispatcher 的存在是一个防御性设计。设想如果用户把 useState 写在了 if 里、或写在了事件回调里、或写在了普通函数里:这些地方不在 React 渲染组件函数体的上下文中,于是 ReactSharedInternals.H 仍然是 ContextOnlyDispatcher,调用 useState 就会命中 throwInvalidHookError(见 ReactFiberHooks.js:445):
// packages/react-reconciler/src/ReactFiberHooks.js:445
function throwInvalidHookError() {
throw new Error(
'Invalid hook call. Hooks can only be called inside of the body of a function component...'
);
}
注意 react 包里的 resolveDispatcher 在 DEV 下只是 console.error(不抛错,因为热路径),而真正的"抛错"发生在 ContextOnlyDispatcher 的 throwInvalidHookError。两者配合,既在热路径上保持轻量,又在违例时给出清晰错误。
4.4 dispatcher 切换的精妙之处
切换逻辑集中在 renderWithHooks(ReactFiberHooks.js:563)生产分支:
// packages/react-reconciler/src/ReactFiberHooks.js:563
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
这里用一个非常巧妙(也有已知局限)的判据区分 mount / update:current 为 null(首次挂载),或者 current.memoizedState 为 null(上一次也没用任何"有状态"的 hook),就当作 mount;否则是 update。
为什么用 memoizedState === null 而不是"是否挂载"来判定的注释里也写清楚了(:546):
Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used. Non-stateful hooks (e.g. context) don't get added to memoizedState, so memoizedState would be null during updates and mounts.
也就是说:如果一个组件只用 useContext(不写进 memoizedState),那么它的 current.memoizedState 在更新时也可能为 null,于是会被误判成 mount。这正是 DEV 下需要 HooksDispatcherOnMountWithHookTypesInDEV 这个"带类型校验的挂载 dispatcher"来兜底的原因(:552),它能在没有有状态 hook 的更新场景里,仍用 mount 行为但附加顺序校验。
切换的收尾在 finishRenderingHooks(:660):
// packages/react-reconciler/src/ReactFiberHooks.js:660
ReactSharedInternals.H = ContextOnlyDispatcher;
渲染一结束,立刻把指针拨回 ContextOnlyDispatcher。这样组件函数体之外的任何 hook 调用都会立刻抛错——既防止误用,也保证下一次渲染开始时指针是干净的(虽然 renderWithHooks 开头会重新赋值,但保持"用后即废"是良好实践)。
5. 链表存储与 mount/updateWorkInProgressHook
5.1 模块级光标(ReactFiberHooks.js:264)
// packages/react-reconciler/src/ReactFiberHooks.js:264
let currentlyRenderingFiber: Fiber = null as any;
let currentHook: Hook | null = null; // 指向"当前"hook 链表游标(用于 update 克隆)
let workInProgressHook: Hook | null = null; // 指向"进行中"hook 链表游标(正在构建的 wip 链表)
这三个不是 fiber 的字段,而是 模块级(文件级)的全局变量。React 同一时刻只渲染一个组件,所以用全局游标是安全的,且避免了把游标塞进每个 fiber 的开销。它们由 renderWithHooks 在渲染前设置(currentlyRenderingFiber = workInProgress),由 finishRenderingHooks 在渲染后复位为 null。
5.2 mountWorkInProgressHook(ReactFiberHooks.js:983)
首次渲染时,每个 hook 都通过它创建新节点并挂到链表:
// packages/react-reconciler/src/ReactFiberHooks.js:983
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 这是链表里的第一个 hook
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 追加到链表末尾
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
逻辑很直白:若当前 wip 链表为空,就让 fiber.memoizedState 指向这个 hook,同时 workInProgressHook 也指向它;否则把它接到 workInProgressHook.next,并前移游标。这就是链表"边渲染边生长"的过程。
5.3 updateWorkInProgressHook(ReactFiberHooks.js:1004)
更新渲染时,hook 不能新建,必须从 current(上一次已提交的 fiber)对应的 hook 克隆过来:
// packages/react-reconciler/src/ReactFiberHooks.js:1004
function updateWorkInProgressHook(): Hook {
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}
if (nextWorkInProgressHook !== null) {
// 已经有进行中的(来自上一次重渲染尝试),直接复用
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
// 从当前 hook 克隆
if (nextCurrentHook === null) {
const currentFiber = currentlyRenderingFiber.alternate;
if (currentFiber === null) {
throw new Error(
'Update hook called on initial render. This is likely a bug in React. Please file an issue.'
);
} else {
throw new Error('Rendered more hooks than during the previous render.');
}
}
currentHook = nextCurrentHook;
const newHook: Hook = {
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.next来定位"下一个应该从 current 克隆的 hook"。如果本次渲染的 hook 数量或顺序与上次不同,游标就会错位。 - 当链表已经走到末尾(
nextCurrentHook === null)却还有 hook 要调用,就抛 "Rendered more hooks than during the previous render."(渲染了比上次更多的 hook)。 - 反过来,如果本次 hook 比上次少,游标停在中途,会在
finishRenderingHooks里被currentHook.next !== null捕获,抛 "Rendered fewer hooks than expected. This may be caused by an accidental early return statement."(渲染的 hook 比预期少,可能是提前 return 了)。
这套机制就是"Rules of Hooks"能在运行时被强制执行的底层保障。详情见第 7 章。
6. renderWithHooks 与 finishRenderingHooks
6.1 renderWithHooks:渲染入口(ReactFiberHooks.js:505)
这是函数组件被协调器(beginWork)调用时的真正入口。它做四件事:
- 设置全局上下文(
renderLanes、currentlyRenderingFiber,:513、:514)。 - 选择 dispatcher(
ReactFiberHooks.js:563,见 4.4)。 - 执行组件函数体(
Component(props, secondArg),ReactFiberHooks.js:599),期间所有useState等调用会通过ReactSharedInternals.H落到mountXxx/updateXxx。 - 处理渲染阶段更新与 StrictMode 双调用,最后调用
finishRenderingHooks。
一段值得精读的逻辑是 StrictMode 双调用(ReactFiberHooks.js:595):
// packages/react-reconciler/src/ReactFiberHooks.js:595
const shouldDoubleRenderDEV =
__DEV__ && (workInProgress.mode & StrictLegacyMode) !== NoMode;
shouldDoubleInvokeUserFnsInHooksDEV = shouldDoubleRenderDEV;
let children = __DEV__
? callComponentInDEV(Component, props, secondArg)
: Component(props, secondArg);
shouldDoubleInvokeUserFnsInHooksDEV = false;
// 检查是否有渲染阶段更新
if (didScheduleRenderPhaseUpdateDuringThisPass) {
children = renderWithHooksAgain(workInProgress, Component, props, secondArg);
}
if (shouldDoubleRenderDEV) {
// 开发模式下组件函数被调用两次,用于检测副作用
setIsStrictModeForDevtools(true);
try {
children = renderWithHooksAgain(workInProgress, Component, props, secondArg);
} finally {
setIsStrictModeForDevtools(false);
}
}
注意一个精妙细节:StrictMode 下,用户函数(如 init 初始化器、useMemo 的工厂)在第一次调用组件时被双调用,第二次不再双调用(shouldDoubleInvokeUserFnsInHooksDEV 只在第一次为 true)。官方注释解释:这是因为 use 在 suspend 时会复用第一次传入的 promise,而 useMemo 的 deps 在第二次会命中,从而复用第一次的结果——必须保证"用户函数的 reactive 输入来自同一次组件调用"。
6.2 finishRenderingHooks:渲染收尾与校验(ReactFiberHooks.js:636)
// packages/react-reconciler/src/ReactFiberHooks.js:636
function finishRenderingHooks(current, workInProgress, Component): void {
// ... DEV 下保存 _debugHookTypes、thenableState ...
// 关键:渲染结束,指针拨回 ContextOnlyDispatcher
ReactSharedInternals.H = ContextOnlyDispatcher; // :660
// 校验:是否渲染了过少的 hook
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null; // :664
renderLanes = NoLanes;
currentlyRenderingFiber = null as any; // :668
currentHook = null; // :670
workInProgressHook = null; // :671
// ... DEV 下复位 hookTypesDev 等 ...
didScheduleRenderPhaseUpdate = false; // :700
紧接其后的 :704 是真正的校验抛错:
// packages/react-reconciler/src/ReactFiberHooks.js:704(位于 if (didRenderTooFewHooks) 内)
throw new Error(
'Rendered fewer hooks than expected. This may be caused by an accidental ' +
'early return statement.'
);
而"渲染了更多 hook"的报错在 updateWorkInProgressHook 的 :1048(见 5.3)。两者合起来,在运行时强制了"hook 数量与顺序在每次渲染中必须一致"。
此外,finishRenderingHooks 末尾还有一个上下文变化检查(:714):
// packages/react-reconciler/src/ReactFiberHooks.js:714
if (current !== null) {
if (!checkIfWorkInProgressReceivedUpdate()) {
const currentDependencies = current.dependencies;
if (
currentDependencies !== null &&
checkIfContextChanged(currentDependencies)
) {
markWorkInProgressReceivedUpdate();
}
}
}
这段解决了一个微妙问题:如果组件本次渲染没有任何 props/state 变化,但某处 useContext 读取的 context 值变了,那么即使"看起来没更新",也要标记 WorkInProgressReceivedUpdate,以确保 effect 等能正确重新评估。这是 context 与 hook 联动的收口。
6.3 renderWithHooksAgain:重渲染循环(ReactFiberHooks.js:791)
当渲染阶段发生了 setState(render-phase update),组件需要"就地再渲染一次"来消费这些更新。它也是一个 dispatcher 切换点:
// packages/react-reconciler/src/ReactFiberHooks.js:791(节选)
function renderWithHooksAgain(workInProgress, Component, props, secondArg): any {
// 把指针切到 "re-render" dispatcher
ReactSharedInternals.H = ...HooksDispatcherOnRerender...;
// 然后再次调用组件函数体
...
}
重渲染用的是 HooksDispatcherOnRerender(:3982),它的 useState→rerenderState、useReducer→rerenderReducer,这些实现会重新消费 hook.queue 里的更新,而不是像 updateState 那样再从 current 克隆。这也是为什么渲染阶段更新能"立刻看到结果"。
7. 为什么"不能在条件/循环里调用 Hook"——完整证据链
这是 Hooks 最著名的规则,但很多文章只说"因为顺序"。本章给你从源码层面完整闭环的证据链。
7.1 规则的技术本质
Hook 的状态不保存在函数闭包里,而是保存在 fiber.memoizedState 上的单向链表,且每个 hook 对应链表上的第 N 个节点(N = 调用次序)。渲染时,更新路径通过 updateWorkInProgressHook(:1004)按顺序从 current 克隆节点:
currentHook = nextCurrentHook; // 取上一个 hook 的 next
const newHook = { ...currentHook ... }; // 克隆
workInProgressHook = workInProgressHook.next = newHook; // 接到 wip 链表
这个克隆过程完全依赖"每个 hook 调用都对应 current 链表上的同位置节点"。一旦调用顺序变了,克隆到的就是"错误的那个旧状态"。
7.2 三条报错路径(完整证据)
把代码里所有相关报错点串起来,就得到一条铁证:
证据 A(渲染了更多 hook):updateWorkInProgressHook :1048
} else {
throw new Error('Rendered more hooks than during the previous render.');
}
触发条件:nextCurrentHook === null(current 链表已走到末尾),但组件还想再调一个 hook。典型场景:本次渲染比上次多了一个 useState(比如新增了一个没被条件保护的 hook,或上次因为 if(false) 少调了一个)。
证据 B(渲染了更少 hook):finishRenderingHooks :664 + :704
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null; // :664
// ... 之后:
if (didRenderTooFewHooks) {
throw new Error('Rendered fewer hooks than expected... early return...'); // :704
}
触发条件:渲染结束时 currentHook 不是链表的最后一个(说明还有"本应被调用"的 hook 没被调用)。典型场景:if (condition) return; 提前退出,导致后面的 useEffect 等没执行。
证据 C(渲染外调用):ContextOnlyDispatcher :3898 + throwInvalidHookError :445
export const ContextOnlyDispatcher: Dispatcher = {
useCallback: throwInvalidHookError,
useEffect: throwInvalidHookError,
useReducer: throwInvalidHookError,
useRef: throwInvalidHookError,
useState: throwInvalidHookError,
// ...
};
触发条件:在组件函数体之外(事件回调、普通函数、setTimeout 等)调用 hook,此时 ReactSharedInternals.H 是 ContextOnlyDispatcher,直接抛 "Invalid hook call"。
证据 D(DEV 顺序表校验):updateHookTypesDev :323 + warnOnHookMismatchInDev :351
// packages/react-reconciler/src/ReactFiberHooks.js:323
function updateHookTypesDev(): void {
if (__DEV__) {
const hookName = currentHookNameInDev as any as HookType;
if (hookTypesDev !== null) {
hookTypesDev.push(hookName); // 首次:记录顺序
hookTypesUpdateIndexDev++;
if (hookTypesDev[hookTypesUpdateIndexDev] !== hookName) {
warnOnHookMismatchInDev(hookName); // 后续:比对顺序
}
}
}
}
DEV 下,React 在首次渲染记录每个 hook 的"类型顺序"(hookTypesDev),之后每次渲染逐个比对。即使数量恰好相同,只要类型顺序变了(比如把 useState 和 useEffect 换了位置),也会在控制台打出一张"上一轮 vs 这一轮"的对照表并警告。这是数量之外的第二道防线。
7.3 用图说话
flowchart TD
A["组件函数体执行"]
A --> B{"是否在渲染上下文中?<br/>(H === ContextOnlyDispatcher?)"}
B -- 否 --> C["证据C: Invalid hook call 抛错"]
B -- 是 --> D["按调用顺序逐个调 mountXxx/updateXxx"]
D --> E{"本次调用第 N 个 hook"}
E --> F["update: 克隆 current 链表第 N 个节点"]
F --> G{"克隆时 current 链表<br/>已到末尾?"}
G -- 是 --> H["证据A: Rendered more hooks 抛错"]
G -- 否 --> I["继续"]
I --> J{"渲染结束, currentHook<br/>是否为最后一个?"}
J -- 否(提前return) --> K["证据B: Rendered fewer hooks 抛错"]
J -- 是 --> L["正常"]
D -.DEV.-> M["证据D: 类型顺序表比对, 不一致则警告"]
7.4 那"为什么不报错但行为诡异"的情况
最危险的情况不是抛错,而是数量相同但位置语义错位:例如把 const [a] = useState(0) 放在 if (x) 里,且 x 在两次渲染间从 true 变 false,导致该 useState 这次没调用。结果:原本"第 2 个 hook"的 useEffect 这次变成了"第 1 个 hook",克隆到了上一次 useState 的状态——类型不匹配,行为完全错乱,但未必抛错(除非 DEV 顺序表救场)。这正是规则"必须在顶层、按固定顺序"的根本原因:它不是 React 的随意限制,而是链表存储模型的必然约束。
8. useState 与 useReducer:状态的本质
8.1 useState 是 useReducer 的特例
这是 React 源码里最优雅的设计之一:useState 的 reducer 就是 basicStateReducer。
// packages/react-reconciler/src/ReactFiberHooks.js:1265
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
- 如果传的是函数(
setCount(c => c + 1)),就执行它得到新 state; - 如果传的是值(
setCount(5)),就直接作为新 state。
于是 updateState 完全委托给 updateReducer:
// packages/react-reconciler/src/ReactFiberHooks.js:1962
function updateState<S>(initialState): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, initialState);
}
rerenderState 同理委托 rerenderReducer(:1968)。所以本章后面所有对 useReducer 的剖析,对 useState 100% 成立。
8.2 mountStateImpl 与 mountState(ReactFiberHooks.js:1920 / :1948)
// packages/react-reconciler/src/ReactFiberHooks.js:1920
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
const initialStateInitializer = initialState;
initialState = initialStateInitializer(); // 懒初始化执行一次
if (shouldDoubleInvokeUserFnsInHooksDEV) {
// StrictMode 双调用该初始化器
setIsStrictModeForDevtools(true);
try {
initialStateInitializer();
} finally {
setIsStrictModeForDevtools(false);
}
}
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState as any,
};
hook.queue = queue;
return hook;
}
// packages/react-reconciler/src/ReactFiberHooks.js:1948
function mountState<S>(initialState): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountStateImpl(initialState);
const queue = hook.queue;
const dispatch: Dispatch<BasicStateAction<S>> = dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
) as any;
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
要点:
- 懒初始化:若
initialState是函数,只在 mount 时执行一次(得到初始值),之后更新渲染不会再执行它。这正是useState(() => expensiveComputation())优化生效的原因。 dispatch是稳定引用:dispatchSetState.bind(null, fiber, queue)在 mount 时创建一次,之后更新渲染通过updateState → updateReducer返回同一个queue.dispatch(因为updateWorkInProgressHook克隆了queue)。这就是为什么你可以在useEffect依赖里放心省略setState而不触发无限循环——引用永远不变。lastRenderedReducer/lastRenderedState是为 eager 优化准备的(见第 9 章)。
8.3 mountReducer(ReactFiberHooks.js:1270)
与 mountStateImpl 几乎一致,只是 reducer 由调用方传入,且没有"懒初始化"分支(初始化函数 init 是可选的第三参数):
// packages/react-reconciler/src/ReactFiberHooks.js:1270(节选)
function mountReducer(reducer, initialArg, init?): [S, Dispatch<A>] {
const hook = mountWorkInProgressHook();
let initialState;
if (init !== undefined) {
initialState = init(initialArg);
// StrictMode 双调用 init
} else {
initialState = initialArg as any as S;
}
hook.memoizedState = hook.baseState = initialState;
const queue = { pending: null, lanes: NoLanes, dispatch: null,
lastRenderedReducer: reducer, lastRenderedState: initialState };
hook.queue = queue;
const dispatch = queue.dispatch = dispatchReducerAction.bind(null, currentlyRenderingFiber, queue);
return [hook.memoizedState, dispatch];
}
注意 dispatchReducerAction 与 dispatchSetState 是不同函数(见 9.1),但本质都是把更新入队。
9. 状态更新队列:环形链表、复用与丢弃的 lane 语义、eager 优化
这是全书最硬核的一段。理解了它,你就理解了 React 并发更新的"复用与丢弃"哲学。
9.1 派发函数 dispatchSetState / dispatchReducerAction
mountState 用的是 dispatchSetState,mountReducer 用的是 dispatchReducerAction。两者都调用同一个核心:dispatchSetStateInternal(ReactFiberHooks.js:3656)。
// packages/react-reconciler/src/ReactFiberHooks.js:3656
function dispatchSetStateInternal<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
lane: Lane,
): boolean {
const update: Update<S, A> = {
lane,
revertLane: NoLane,
gesture: null,
action,
hasEagerState: false,
eagerState: null,
next: null as any,
};
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update); // 渲染阶段更新
} else {
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 队列当前为空 → 可以尝试 eager 优化
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
// 提前算出 next state
const currentState = queue.lastRenderedState as any;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 算出结果和当前 state 相同 → 短路 bailout
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true;
}
}
return false;
}
逐段拆解:
9.2 渲染阶段更新(render-phase update)
isRenderPhaseUpdate(ReactFiberHooks.js:3838)判断:当前 fiber 是否正是 currentlyRenderingFiber(或它的 alternate)。
// packages/react-reconciler/src/ReactFiberHooks.js:3838
function isRenderPhaseUpdate(fiber: Fiber): boolean {
const alternate = fiber.alternate;
return (
fiber === currentlyRenderingFiber ||
(alternate !== null && alternate === currentlyRenderingFiber)
);
}
如果在渲染阶段调用了 setState,就会走 enqueueRenderPhaseUpdate(ReactFiberHooks.js:3846):
// packages/react-reconciler/src/ReactFiberHooks.js:3846
function enqueueRenderPhaseUpdate(queue, update): void {
didScheduleRenderPhaseUpdateDuringThisPass =
didScheduleRenderPhaseUpdate = true;
const pending = queue.pending;
if (pending === null) {
update.next = update; // 自环
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
}
它把更新塞进同一个 queue.pending 环形链表,并置 didScheduleRenderPhaseUpdate = true。renderWithHooks 检测到这个标志后,会调用 renderWithHooksAgain 再渲染一次,就地消费掉这些更新,于是"在 render 里 setState 能立刻生效"。
⚠️ 虽然能这么做,但 React 官方强烈不推荐在渲染阶段 setState(除非是为了"根据上一次渲染派生状态"这种极少数场景)。因为它会触发额外重渲染,次数过多会被限制在
RE_RENDER_LIMIT = 25(ReactFiberHooks.js:295)。renderWithHooksAgain内部会累加重渲染次数,超过 25 直接抛错,防止无限循环。
9.3 eager 优化(短路条件)
回到 dispatchSetStateInternal 的这段:
if (fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)) {
// 队列为空
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
const currentState = queue.lastRenderedState as any;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 关键短路:算出 state 没变
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 不调度重渲染!
}
}
}
eager 优化的完整条件(全部满足才触发):
- 队列当前为空(
fiber.lanes === NoLanes且 alternate 也无 lanes)——即没有待处理的并发更新; lastRenderedReducer存在(useOptimistic 故意设为null,因此乐观更新不走 eager 优化,见 15.1);- 用上次的
lastRenderedState+ 本次action提前算出eagerState; is(eagerState, currentState)为true——即新 state 与当前 state 用Object.is比较相等。
满足则调用 enqueueConcurrentHookUpdateAndEagerlyBailout:把更新(带着 hasEagerState=true、eagerState)仍入队(以保证语义正确、可被 future render 复用),但不调用 scheduleUpdateOnFiber,即不触发重渲染,直接 return false。
这就是 setState 传一个"和当前值相等的值"时组件不重渲染的底层原因,也是性能优化点。注意:它比较的是 queue.lastRenderedState(上次渲染后稳定的 state),不是 fiber.memoizedState,两者在并发场景可能不同。
如果更新导致 state 真的变化,则落到普通路径 enqueueConcurrentHookUpdate → scheduleUpdateOnFiber,正常安排一次渲染。
9.4 updateReducerImpl:复用与丢弃的 lane 语义(ReactFiberHooks.js:1316)
这是状态计算的心脏。它展示了 React 在并发渲染中如何"按优先级复用部分更新、丢弃(延迟)另一部分"。
// packages/react-reconciler/src/ReactFiberHooks.js:1316(节选,保留主干)
function updateReducerImpl(hook, current, reducer): [S, Dispatch<A>] {
const queue = hook.queue;
if (queue === null) {
throw new Error(
'Should have a queue. You are likely calling Hooks conditionally...'
);
}
queue.lastRenderedReducer = reducer;
let baseQueue = hook.baseQueue; // 上次被跳过、延迟到本次的更新
const pendingQueue = queue.pending; // 本次新进来的更新
if (pendingQueue !== null) {
if (baseQueue !== null) {
// 合并 baseQueue 与 pendingQueue 两个环形链表
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
const baseState = hook.baseState;
if (baseQueue === null) {
hook.memoizedState = baseState; // 没有待处理更新
} else {
const first = baseQueue.next;
let newState = baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
do {
const updateLane = removeLanes(update.lane, OffscreenLane);
const isHiddenUpdate = updateLane !== update.lane;
let shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// ★ 复用与丢弃:优先级不足的更新被"克隆"进 newBaseQueue 保留
const clone = { ...update, lane: updateLane, /* ... */ next: null };
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes, updateLane);
markSkippedUpdateLanes(updateLane);
} else {
// ★ 优先级足够的更新被真正应用
if (update.hasEagerState) {
newState = update.eagerState; // 用 eager 阶段算好的 state
} else {
newState = reducer(newState, update.action);
}
}
update = update.next;
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = newBaseQueueFirst; // 闭环
}
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast; // 保留被跳过的更新到下一次
queue.lastRenderedState = newState;
}
const dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];
}
这是整章最值得反复咀嚼的算法。我们用一个具体例子理解"复用与丢弃":
例子:优先级混合的连续更新
假设 state 初始为 0,renderLanes 当前只包含 Lane2(中等优先级)。先后发生三个 setState:+1(Lane1,高)、+2(Lane3,低)、+3(Lane2,中)。
- 三个更新全部进入
queue.pending环形链表(按 dispatch 顺序排列)。 - 本次渲染的
renderLanes是{Lane2}。 - 遍历时:
+1(Lane1):isSubsetOfLanes({Lane2}, Lane1)为false→ shouldSkip,克隆进newBaseQueue,newBaseState = 0(此时 newState 仍为 0)。+2(Lane3):isSubsetOfLanes({Lane2}, Lane3)为false→ 同样 skip,克隆进newBaseQueue,newBaseState仍 0。+3(Lane2):isSubsetOfLanes({Lane2}, Lane2)为true→ 应用,newState = 0 + 3 = 3。
- 遍历结束:
hook.memoizedState = 3(本次渲染提交的状态)。hook.baseState = 0(被跳过的更新对应的起点)。hook.baseQueue= 克隆后的[+1, +2]环形链表(保留!)。- 顺带把 Lane1、Lane3 merge 进
fiber.lanes,让调度器知道"还有更低优先级的活要干"。
于是当 Lane1 的高优先级渲染到来时:
baseState = 0,baseQueue = [+1, +2],加上本次新可能的 pending。- 这次
renderLanes包含 Lane1,+1被应用 →newState = 1,然后若也包含 Lane3 则+2应用 →3,否则+2继续被克隆保留。 - 最终每个更新都在正确的优先级上被精确消费一次,既不会丢失,也不会重复应用——这就是"复用与丢弃"的真正含义:丢弃的不是更新本身,而是在当前优先级下"暂时不应用",但保留到未来合适的渲染中复用。
再看几个关键细节:
clone而非reuse:被跳过的更新会被 clone 一份(lane: updateLane等字段拷贝),因为原始的update属于上一轮 render 的环形链表,不能原地修改(它可能被别的 fiber/渲染共享引用)。这是不可变更新思想的体现。markSkippedUpdateLanes+mergeLanes:把被跳过的 lane 标记到 fiber 上,调度器据此安排后续渲染。这正是 React 并发"可中断、可恢复"的底层支撑。- eagerState 优先:若更新在 dispatch 时已 eager 算出 state(
hasEagerState),这里直接取eagerState,省掉一次 reducer 调用。 - OffscreenLane 处理:隐藏树(Offscreen)里的更新会被加一个额外 bit,渲染时若不在该隐藏树上下文中就 skip,避免隐藏内容抢占主渲染。
flowchart TD
subgraph Q["queue.pending (环形)"]
P1["update +1 (Lane1)"]
P2["update +2 (Lane3)"]
P3["update +3 (Lane2)"]
P1 --> P2 --> P3 --> P1
end
Q --> R["updateReducerImpl 遍历"]
R --> C{"isSubset(renderLanes, update.lane)?"}
C -- 否 --> S["clone 进 baseQueue 保留<br/>markSkippedUpdateLanes"]
C -- 是 --> A["reducer 应用<br/>newState 更新"]
S --> BK["hook.baseQueue 留待下次"]
A --> MS["hook.memoizedState 提交"]
9.5 dispatchReducerAction 与 transition 纠缠
dispatchSetStateInternal 在普通路径末尾调用 entangleTransitionUpdate(ReactFiberHooks.js:3867):
// packages/react-reconciler/src/ReactFiberHooks.js:3867
function entangleTransitionUpdate(root, queue, lane): void {
if (isTransitionLane(lane)) {
let queueLanes = intersectLanes(queueLanes, root.pendingLanes);
const newQueueLanes = mergeLanes(queueLanes, lane);
queue.lanes = newQueueLanes;
markRootEntangled(root, newQueueLanes);
}
}
如果本次更新是一个 transition lane,就把队列里的所有 transition lane "纠缠(entangle)"在一起:要么一起完成,要么一起被中断重来。这保证了 useTransition 的"要么都提交,要么都回滚"语义。
10. 渲染阶段更新与最多 25 次重渲染
回顾 9.2:enqueueRenderPhaseUpdate 置 didScheduleRenderPhaseUpdate = true。renderWithHooks(:605)检测到后调用 renderWithHooksAgain。
重渲染次数受 RE_RENDER_LIMIT = 25(ReactFiberHooks.js:295)约束。renderWithHooksAgain 内部有一个计数器,每次重渲染自增,超过 25 直接抛错——这是防止"render 里 setState 触发 setState 触发 setState…"无限循环的最后防线。
实战建议:
- 渲染阶段
setState的唯一正当用途是"状态依赖于上一次渲染的结果,且无法用派生计算表达"的极少数场景(如const [derived, setDerived] = useState(...); if (prev !== derived) { setDerived(...); return; })。 - 任何"为了在计算后修正状态"的 render-phase setState,都应重构为
useMemo派生或用useEffect在提交后处理。
11. useEffect:effect 的环形链表、标记差异、异步 flush 与 deps 比较
11.1 三个 effect 标记的差异
useEffect、useLayoutEffect、useInsertionEffect 在底层都用同一套 mountEffectImpl / updateEffectImpl,只是传入的 fiberFlags(打在 fiber 上)和 hookFlags(打在 Effect.tag 上)不同:
| Hook | fiberFlags(fiber.flags) | hookFlags(Effect.tag) | 执行时机 |
|---|---|---|---|
useEffect | PassiveEffect | PassiveStaticEffect(DEV Strict 下再加 MountPassiveDevEffect) | HookPassive | commit 之后异步(scheduler 调度 flushPassiveEffects) |
useLayoutEffect | UpdateEffect | LayoutStaticEffect(DEV 加 MountLayoutDevEffect) | HookLayout | commit 阶段同步、在浏览器绘制前 |
useInsertionEffect | UpdateEffect | HookInsertion | DOM 变更前、layout 之前(用于注入样式,避免闪烁) |
源码对照(ReactFiberHooks.js:2697 起):
// packages/react-reconciler/src/ReactFiberHooks.js:2697
function mountEffect(create, deps): void {
if (__DEV__ && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode) {
mountEffectImpl(
MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
HookPassive, create, deps,
);
} else {
mountEffectImpl(PassiveEffect | PassiveStaticEffect, HookPassive, create, deps);
}
}
function updateEffect(create, deps): void {
updateEffectImpl(PassiveEffect, HookPassive, create, deps); // :2721
}
function mountInsertionEffect(create, deps): void {
mountEffectImpl(UpdateEffect, HookInsertion, create, deps); // :2782
}
function mountLayoutEffect(create, deps): void {
let fiberFlags = UpdateEffect | LayoutStaticEffect;
if (__DEV__ && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode) {
fiberFlags |= MountLayoutDevEffect;
}
return mountEffectImpl(fiberFlags, HookLayout, create, deps); // :2796
}
11.2 mountEffectImpl 与 effect 环形链表(ReactFiberHooks.js:2640)
// packages/react-reconciler/src/ReactFiberHooks.js:2640
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags; // 在 fiber 上打标记
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags, // 首次必定 HasEffect(需要执行)
createEffectInstance(),
create,
nextDeps,
);
}
pushSimpleEffect(ReactFiberHooks.js:2588 附近)→ pushEffectImpl(ReactFiberHooks.js:2605)把 effect 挂到 fiber.updateQueue.lastEffect 的环形链表:
// packages/react-reconciler/src/ReactFiberHooks.js:2605
function pushEffectImpl(effect): Effect {
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
}
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect; // 自环
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect; // 尾指针前移
}
return effect;
}
注意环形链表用 lastEffect.next 指向"首 effect"。commit 阶段遍历 effect 时,从 lastEffect.next(首节点)开始,顺 next 走到 lastEffect 为止(用 firstEffect !== lastEffect 判断结束),覆盖全部 effect。
flowchart LR
UQ["fiber.updateQueue.lastEffect"]
UQ --> E3["Effect#3 (last)"]
E3 --> E1["Effect#1 (first)"]
E1 --> E2["Effect#2"]
E2 --> E3
style E3 stroke:#f66
11.3 updateEffectImpl 与 deps 比较(ReactFiberHooks.js:2657)
// packages/react-reconciler/src/ReactFiberHooks.js:2657
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const effect = hook.memoizedState;
const inst = effect.inst;
if (currentHook !== null) {
if (nextDeps !== null) {
const prevEffect = currentHook.memoizedState;
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
// deps 没变 → 跳过执行,只改变 create,不带 HasEffect
hook.memoizedState = pushSimpleEffect(hookFlags, inst, create, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags; // 需要执行
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags, inst, create, nextDeps,
);
}
逻辑:
- 如果上一次有 deps,且本次 deps 经
areHookInputsEqual比较相等,则 effect 的 tag 不带HookHasEffect,commit 阶段遍历时就会跳过它的执行(但仍更新create、deps,以便下次比较)。 - 若 deps 变了(或无 deps——
nextDeps === null),则打上HookHasEffect,commit 阶段会先执行上一次的destroy,再执行新的create。
11.4 areHookInputsEqual:deps 比较的陷阱(ReactFiberHooks.js:456)
// packages/react-reconciler/src/ReactFiberHooks.js:456
function areHookInputsEqual(nextDeps, prevDeps): boolean {
if (__DEV__) {
if (ignorePreviousDependencies) return false; // 热重载时强制重跑
}
if (prevDeps === null) {
if (__DEV__) console.error('...final argument during this render, but not previous...');
return false;
}
if (__DEV__) {
if (nextDeps.length !== prevDeps.length) {
console.error('The final argument passed to %s changed size between renders...');
}
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false; // 有一项不等 → 认为变了
}
return true;
}
关键点:
- 比较用的是
is(shared/objectIs,即Object.is),不是===的宽松语义,但和===几乎一致,唯一区别是NaN === NaN为false而Object.is(NaN, NaN)为true,以及-0/+0。所以{a:1}这种每次 render 都是新对象的 deps 永远不等——这是 deps 比较的头号陷阱(见 17 章)。 - 长度变化会报警(DEV),但比较循环用
prevDeps.length && nextDeps.length取较小值,所以长度变化不会 crash,只是行为可能诡异。 - 只在
nextDeps !== null时比较:如果useEffect(fn)不传 deps(即deps === undefined→nextDeps = null),则updateEffectImpl里nextDeps !== null为false,每次渲染都重新执行 effect。
11.5 effect 的异步执行(Passive effect 的 flush)
useEffect 打的是 PassiveEffect 标记。commit 阶段(在 ReactFiberWorkLoop.js 的 commitRootImpl 中)会把带 PassiveEffect 的 fiber 收集起来,并在 commit 完成、浏览器有机会绘制之后,通过 scheduler 异步调用 flushPassiveEffects(约 ReactFiberWorkLoop.js:3806)来:① 执行上一次 effect 的 destroy;② 执行本次 effect 的 create。
这就是为什么 useEffect 里的 setState 不会"同步阻塞 commit"、不会引发"同步又触发渲染又 commit"的递归——它被推迟到一个微任务/宏任务里异步进行。而 useLayoutEffect 的 HookLayout 在 commit 的同步阶段、浏览器绘制前执行,因此它能"读 DOM 布局并同步修正",但代价是会阻塞绘制(这也是为什么官方建议优先用 useEffect)。
注:
flushPassiveEffects的具体实现位于ReactFiberWorkLoop.js(react 19 中约第 3806 行附近),它遍历rootWithPendingPassiveEffects收集的 fiber 上的 effect 环形链表,逐个调用destroy/create。本章聚焦ReactFiberHooks.js,对 work loop 的调用约定在此说明即可,避免编造行号。
12. useRef / useMemo / useCallback:稳定性与短路
12.1 useRef:永不重建(ReactFiberHooks.js:2628)
// packages/react-reconciler/src/ReactFiberHooks.js:2628
function mountRef<T>(initialValue: T): {current: T} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState; // 直接复用旧的 ref 对象
}
useRef 的奥秘在于:更新渲染时 updateRef 直接返回 hook.memoizedState,也就是 mount 时创建的那个 {current} 对象,绝不会新建。这意味着:
- 你每次渲染拿到的
ref是同一个对象引用,ref.current的修改会跨渲染保留。 - 在 StrictMode 双调用下,
useRef也不会被"重置"——它的memoizedState在两次组件调用之间被复用,所以ref.current的值在双调用之间是连续的(这是 StrictMode 下 ref 双调用陷阱的来源,见 17 章)。 - 因为引用稳定,
useRef永远不需要 deps,也不会触发 re-render。
12.2 useMemo:deps 命中则短路(ReactFiberHooks.js:2944)
// packages/react-reconciler/src/ReactFiberHooks.js:2944
function mountMemo(nextCreate, deps): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate();
if (shouldDoubleInvokeUserFnsInHooksDEV) {
// StrictMode 双调用工厂
setIsStrictModeForDevtools(true);
try { nextCreate(); } finally { setIsStrictModeForDevtools(false); }
}
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
function updateMemo(nextCreate, deps): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (nextDeps !== null) {
const prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0]; // 短路:直接返回旧值,不执行 nextCreate
}
}
const nextValue = nextCreate(); // deps 变了才执行
// ...StrictMode 双调用
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
要点:
useMemo存的是[value, deps]二元组。updateMemo仅在nextDeps !== null且areHookInputsEqual为true时返回旧value,否则执行nextCreate重新计算。useMemo不是语义保证:React 官方明确说明,它只是"优化提示",在内存紧张或并发中断时可能丢弃缓存重新计算。所以不要依赖useMemo来"记忆"副作用或必须只执行一次的逻辑——那是useRef或useEffect的职责。- StrictMode 下
nextCreate会被双调用,因此工厂函数必须是纯函数(无副作用),否则会执行两次。
12.3 useCallback:本质是 useMemo 的语法糖(ReactFiberHooks.js:2923)
// packages/react-reconciler/src/ReactFiberHooks.js:2923
function mountCallback(callback, deps): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
function updateCallback(callback, deps): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (nextDeps !== null) {
const prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0]; // 返回旧的函数引用
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
useCallback(fn, deps) 等价于 useMemo(() => fn, deps):它缓存的是函数引用本身。目的不是"性能"(函数创建几乎零成本),而是让函数引用在 deps 不变时保持稳定,从而不会因"父组件重渲染导致内联函数引用变化"而破坏子组件的 React.memo 优化或 useEffect 的 deps 比较。
13. useContext:不占 hook 链表位置的订阅机制
13.1 它根本不在 hook 链表里
ReactHooks.js:53 的 useContext 转发到 dispatcher.useContext,而生产 dispatcher(ReactFiberHooks.js:3931)里 useContext: readContext——是的,useContext 直接就是 readContext,完全不走 mountWorkInProgressHook。
13.2 readContext / readContextForConsumer(ReactFiberNewContext.js:553 / :580)
// packages/react-reconciler/src/ReactFiberNewContext.js:553
export function readContext<T>(context: ReactContext<T>): T {
if (__DEV__) {
if (isDisallowedContextReadInDEV) {
console.error('Context can only be read while React is rendering...');
}
}
return readContextForConsumer(currentlyRenderingFiber, context);
}
// packages/react-reconciler/src/ReactFiberNewContext.js:580
function readContextForConsumer(consumer, context): T {
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2;
const contextItem = { context, memoizedValue: value, next: null };
if (lastContextDependency === null) {
if (consumer === null) {
throw new Error('Context can only be read while React is rendering...');
}
lastContextDependency = contextItem;
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
// _debugThenableState ...
};
consumer.flags |= NeedsPropagation;
} else {
lastContextDependency = lastContextDependency.next = contextItem;
}
return value;
}
机制拆解:
- 读取当前值:
context._currentValue(主渲染器)。Provider 更新 value 时就是改写这个字段。 - 登记订阅:每次
useContext都会创建一个contextItem,通过模块级游标lastContextDependency串成链表,挂到consumer.dependencies.firstContext。 NeedsPropagation标记:在 fiber 上打标记,使 context 变化能向上/向下传播。
13.3 为什么这很重要
- 不占 hook 链表位置:所以你可以条件式地
useContext(虽然仍建议顶层),它不会破坏 Rules of Hooks 的"数量一致性"——因为它根本不通过mount/updateWorkInProgressHook。这也是为什么updateReducerImpl之外,context 的"是否更新"检查要单独在finishRenderingHooks(:714)里做。 - Provider value 变化触发重渲染:当 Provider 的
value变化,所有消费该 context 且Object.is比较不等价的 fiber 都会被标记为需要更新。React 在propagateContextChange时,沿fiber.dependencies.firstContext链表找到受影响的消费者并安排重渲染。 - 多个
useContext同一 context 只订阅一次:因为readContextForConsumer按"调用次数"挂 item,但 Provider 变化时是对整个 context 的所有消费者统一比较。
注意:React 19 推荐用
use(Context)替代useContext(见第 14 章),因为use还能读 promise 并触发 Suspense。
14. use:统一读取 Context 与 Promise 的新入口
14.1 use 的实现(ReactFiberHooks.js:1159)
// packages/react-reconciler/src/ReactFiberHooks.js:1159
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
// thenable → 读 Promise,可 suspend
const thenable = usable;
return useThenable(thenable);
} else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
return undefined; // Fiber 是最终渲染器,无需 recover
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context = usable;
return readContext(context); // 读 Context
}
}
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}
use 是 React 19 的统一读取入口,能处理三种输入:
- thenable(Promise-like):调用
useThenable,其内部用trackUsedThenable(ReactFiberThenable.js)把 promise 登记到thenableState,并在 promise 未决议时抛出 Suspense 异常,从而挂起组件,等 promise 完成后再用同一 promise(被 memo 化)重渲染。 REACT_RECOVERABLE_TYPE:服务器恢复用的特殊类型,客户端直接返回undefined。REACT_CONTEXT_TYPE:等价于readContext(context),即useContext的能力。
14.2 useThenable 与 dispatcher 切换(ReactFiberHooks.js:1098)
// packages/react-reconciler/src/ReactFiberHooks.js:1098(节选)
function useThenable(thenable): T {
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
const result = trackUsedThenable(thenableState, thenable, index,
__DEV__ ? currentlyRenderingFiber : null);
// 当 use 导致 suspend,重放组件时用 "re-render" dispatcher;
// 但如果 use 之后还有其它 hook,需要切回 mount/update dispatcher
const workInProgressFiber = currentlyRenderingFiber;
const nextWorkInProgressHook =
workInProgressHook === null
? workInProgressFiber.memoizedState
: workInProgressHook.next;
if (nextWorkInProgressHook !== null) {
// 还有 hook 未处理
} else {
// 没有剩余 hook → 切回正常的 mount/update dispatcher
}
return result;
}
use(promise) 的核心价值:在条件/循环里也能安全地读 promise 并 suspend——因为 use 不像 useState 那样依赖"在 hook 链表固定位置",它把 thenable 登记在 thenableState(挂 fiber.dependencies._debugThenableState),并通过 thenableIndexCounter 在重渲染时按位置复用同一个 promise。这正是 use 能突破 Rules of Hooks 限制(可在条件里调用)的原因——它的状态不存于 hook 链表,而存于 thenableState。
14.3 use 的约束
- 不能在
try/catch里用use(DEV 会警告,见finishRenderingHooks:734):因为 suspense 靠抛异常工作,try/catch会吞掉它,导致悬挂失效。 - 不能在渲染外调用:
use也经由resolveDispatcher,渲染外H是ContextOnlyDispatcher,但ContextOnlyDispatcher里use是有效实现(:3901),所以use在渲染外的行为由usable类型决定——实际生产中仍应在渲染期调用。
15. React 19 新 Hook:useOptimistic / useActionState / useDeferredValue / useTransition
15.1 useOptimistic:每帧重置的乐观状态(ReactFiberHooks.js:1974 / :2013)
// packages/react-reconciler/src/ReactFiberHooks.js:1974
function mountOptimistic(passthrough, reducer): [S, (A) => void] {
const hook = mountWorkInProgressHook();
hook.memoizedState = hook.baseState = passthrough;
const queue = {
pending: null, lanes: NoLanes, dispatch: null,
lastRenderedReducer: null, // ★ 故意 null:不走 eager 优化
lastRenderedState: null,
};
hook.queue = queue;
const dispatch = dispatchOptimisticSetState.bind(null, currentlyRenderingFiber, true, queue);
queue.dispatch = dispatch;
return [passthrough, dispatch];
}
更新时(updateOptimisticImpl,:2013):
// packages/react-reconciler/src/ReactFiberHooks.js:2013
function updateOptimisticImpl(hook, current, passthrough, reducer): [S, (A) => void] {
// 关键:每次渲染都把 baseState 重置为 passthrough
hook.baseState = passthrough;
const resolvedReducer = typeof reducer === 'function' ? reducer : basicStateReducer;
return updateReducerImpl(hook, currentHook, resolvedReducer);
}
useOptimistic 的精妙:
baseState每帧重置为passthrough:passthrough是最新的"真实"状态(来自 props/上一次提交)。所有乐观更新都被叠加在它之上。一旦底层状态变化,下一次渲染passthrough更新,乐观叠加自动"对齐"到新真实值。revertLane实现自动回滚:dispatchOptimisticSetState(ReactFiberHooks.js:3728)创建的更新带revertLane: requestTransitionLane(transition)。在updateReducerImpl里,当revertLane对应的 transition 已完成(isSubsetOfLanes(renderLanes, revertLane)为true)时,该乐观更新被continue跳过(:1482)——即"关联 transition 结束后,乐观更新自动失效回滚到真实状态"。lastRenderedReducer: null:故意不设 reducer,因此不走 9.3 的 eager 优化(乐观更新不该被"提前 bailout")。- 必须在 transition/action 内调用:
dispatchOptimisticSetState在 DEV 下若检测到transition === null且无 pending async action,会警告 "An optimistic state update occurred outside a transition or action"。
sequenceDiagram
participant U as 用户事件(action)
participant O as useOptimistic dispatch
participant Q as UpdateQueue
participant R as updateReducerImpl
U->>O: setOptimistic(next)
O->>Q: 入队 update(revertLane=transitionLane)
Note over R: 渲染时 baseState=passthrough(真实值)
R->>R: 叠加乐观更新 → 显示乐观值
Note over R: transition 完成, revertLane 命中
R->>R: 跳过乐观更新 → 自动回滚到 passthrough
15.2 useActionState:useState + 乐观 pending + 串行 action 队列(ReactFiberHooks.js:2380)
useActionState(action, initialState) 返回 [state, dispatch, isPending],它的底层由三个 hook 组合而成(这是"hook 组合"思想的绝佳范例):
// packages/react-reconciler/src/ReactFiberHooks.js:2380(节选)
function mountActionState(action, initialStateProp, permalink?): [S, (P) => void, boolean] {
// Hook 1:状态 hook(用 dispatchSetState,state 存于 thenable,由 use 算法解包)
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = stateHook.baseState = initialState;
const stateQueue = { pending: null, lanes: NoLanes, dispatch: null,
lastRenderedReducer: actionStateReducer,
lastRenderedState: initialState };
stateHook.queue = stateQueue;
const setState = dispatchSetState.bind(null, currentlyRenderingFiber, stateQueue);
stateQueue.dispatch = setState;
// Hook 2:pending 状态 hook(用 dispatchOptimisticSetState,类似 transition pending)
const pendingStateHook = mountStateImpl(false);
const setPendingState = dispatchOptimisticSetState.bind(null, currentlyRenderingFiber, false, pendingStateHook.queue);
// Hook 3:action 队列 hook(串行执行 action)
const actionQueueHook = mountWorkInProgressHook();
const actionQueue = { state: initialState, dispatch: null, action, pending: null };
actionQueueHook.queue = actionQueue;
const dispatch = dispatchActionState.bind(null, currentlyRenderingFiber, actionQueue, setPendingState, setState);
actionQueue.dispatch = dispatch;
actionQueueHook.memoizedState = action; // 记下 action 函数,用于检测变化
return [initialState, dispatch, false];
}
工作机制:
- 串行执行:
dispatchActionState(ReactFiberHooks.js:2106)把 action payload 入队到actionQueue.pending(环形链表)。若当前没有正在运行的 action(last === null),立即runActionStateAction;否则追加到队列尾部,等前一个完成。这保证了"多个 action 顺序执行,且每个都能拿到上一个 action 的结果"——这正是 form action 语义要求的。 - 乐观 pending:进入 action 前,
setPendingState(true)通过dispatchOptimisticSetState乐观地把isPending置为true,并在 action 全部完成后由revertLane自动回滚到false(见 15.1 的回滚机制)。 isPending通过 thenable 解包:stateHook的 state 实际是一个 thenable(未决议时 suspend),在use算法里被useThenable解包(见updateActionState,:2465起),使得 action 进行中组件能"挂起等待结果"。- 异常隔离:若某 action 报错,
actionQueue.action被置null,后续 action 直接return(:2118),防止错误级联。
15.3 useDeferredValue:把值延迟到更低优先级(ReactFiberHooks.js:2990 / :3028)
// packages/react-reconciler/src/ReactFiberHooks.js:2990
function mountDeferredValue(value, initialValue?): T {
const hook = mountWorkInProgressHook();
return mountDeferredValueImpl(hook, value, initialValue);
}
// packages/react-reconciler/src/ReactFiberHooks.js:3028
function mountDeferredValueImpl(hook, value, initialValue?): T {
if (initialValue !== undefined && !isRenderingDeferredWork()) {
// 首次用 initialValue,并调度一个 DeferredLane 切换到最终值
hook.memoizedState = initialValue;
const deferredLane = requestDeferredLane();
currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes, deferredLane);
markSkippedUpdateLanes(deferredLane);
return initialValue;
} else {
hook.memoizedState = value;
return value;
}
}
useDeferredValue 的工作方式:
- 当提供
initialValue且当前渲染不是 deferred work 时,先返回initialValue,同时请求一个DeferredLane(低优先级车道),把"切换到真实value"这件事推迟到后台低优先级渲染。这避免了"输入即卡顿"——输入框立刻响应(用旧/初始值渲染),重内容在空闲时再更新。 updateDeferredValueImpl(:3056)在值未变(is)时快速 bailout,变了则在隐藏树/普通树里分别走 mount/update 路径,并通过isCurrentTreeHidden判断是否需要隐藏树特殊处理。- 它是
useTransition的"值版"封装:本质都是把非紧急更新放到低优先级车道。
15.4 useTransition:把回调包进 transition(ReactFiberHooks.js:3425 / :3117)
// packages/react-reconciler/src/ReactFiberHooks.js:3425
function mountTransition(): [boolean, (callback, options?) => void] {
const stateHook = mountStateImpl(false); // 内部用 useState 存 isPending
const start = startTransition.bind(null, currentlyRenderingFiber, stateHook.queue, true, false);
const hook = mountWorkInProgressHook();
hook.memoizedState = start; // start 函数引用稳定
return [false, start];
}
mountTransition 内部就是:①一个 useState(false) 表示 isPending;②把 startTransition 绑定到该 state 的 queue。startTransition(ReactFiberHooks.js:3117)做的关键事:
- 提升当前更新优先级到
ContinuousEventPriority(setCurrentUpdatePriority); - 设置
ReactSharedInternals.T = currentTransition(标记进入 transition 上下文); - 调
dispatchOptimisticSetState(..., pendingState=true)乐观地把isPending设为true(带revertLane,transition 结束后自动回滚到false)——这就是isPending在 transition 进行中显示true、结束后自动变false的来源; - 执行用户
callback,把其中的 setState 都标记为 transition lane(低优先级,可中断); - transition 关联的异步 action scope 完成时,回滚
isPending。
注意:
updateTransition/rerenderTransition(:3443/:3458)第 445 行useThenable(booleanOrThenable)说明isPending实际可能是一个 thenable(当 action 是异步时),由use算法解包为boolean。这与 15.2 的useActionState共享同一套"isPending via thenable"机制。
16. 与其他模块的交互
Hooks 引擎不是孤立的,它的协作方包括:
| 模块 | 文件 | 与 Hooks 的交互 |
|---|---|---|
| 协调器(beginWork) | ReactFiberBeginWork.js | 函数组件进入 renderWithHooks 的起点;mountIndeterminateComponent 首次渲染后再决定是函数组件还是 class |
| 工作循环 | ReactFiberWorkLoop.js | scheduleUpdateOnFiber、getWorkInProgressRootRenderLanes、flushPassiveEffects(异步 flush effect) |
| 车道系统 | ReactFiberLane.js | renderLanes、isSubsetOfLanes、mergeLanes、removeLanes 等决定更新复用/丢弃 |
| 并发更新 | ReactFiberConcurrentUpdates.js | enqueueConcurrentHookUpdate、enqueueConcurrentHookUpdateAndEagerlyBailout 把更新挂到 fiber 的 update 队列并 entanglement |
| 上下文 | ReactFiberNewContext.js | readContext、checkIfContextChanged 供 useContext 与 finishRenderingHooks 使用 |
| 共享内部 | shared/ReactSharedInternals.js | H(dispatcher 指针)、T(transition 上下文)、A(cache dispatcher) |
| thenable | ReactFiberThenable.js | trackUsedThenable、createThenableState 供 use 的 Suspense 能力 |
| 异步 action | ReactFiberAsyncAction.js | peekEntangledActionLane、chainThenableValue 供 action state 与 transition 纠缠 |
| 编译器缓存 | ReactFiberCacheComponent.js | useMemoCache 的 createCache、memo cache 数据结构 |
数据流一句话总结:React 入口 → 协调器 → renderWithHooks(切 dispatcher)→ 各 mount/updateXxx(建/克隆 hook 链表 + 登记 effect/context/thenable)→ finishRenderingHooks(校验 + 复位指针)→ 提交阶段 work loop 消费 effect 环形链表 / 响应状态更新重新进入协调器。
17. 边界场景与坑(穷举)
17.1 条件式 hook 报错(运行时)
见第 7 章完整证据链。三种报错:more hooks(updateWorkInProgressHook:1048)、fewer hooks(finishRenderingHooks:704)、渲染外(ContextOnlyDispatcher:3898)。唯一正解:所有 hook 永远在组件顶层、无条件、无循环地调用。
17.2 deps 比较陷阱
- 每次 render 都是新对象/数组:
useEffect(fn, [{}])或[arr.map(...)]这种内联表达式,每次都是新引用,Object.is永远不等,effect 每次都跑。正确做法是把依赖"计算到基本类型"或useMemo化。 - 忘记依赖导致 stale closure:
useEffect(() => { fetchData(id) }, [])但用了id,lint 会警告,因为闭包捕获的是首次渲染的id。 - 函数依赖:
useEffect(fn, [doThing])而doThing没被useCallback包裹,每次都是新函数,effect 每次重跑。用useCallback稳定它。 useEffectEvent解围:React 19 的useEffectEvent(ReactFiberHooks.js:2728)能把"每次都不一样但又不想作为依赖"的函数标记为 event,避免上述陷阱。
17.3 渲染阶段 setState
- 会触发
renderWithHooksAgain就地重渲染(renderWithHooks:605)。 - 超过
RE_RENDER_LIMIT = 25(ReactFiberHooks.js:295)抛错。 - 不要在事件处理里依赖它,它只应用于"派生状态"极端场景。
17.4 StrictMode 下 ref 的双调用陷阱
useRef 的 memoizedState 在两次组件调用之间被复用(见 12.1)。在 StrictMode(DEV)下组件函数被调用两次,但 ref.current 是同一个对象。后果:
function Buggy() {
const ref = useRef(0);
ref.current++; // 双调用下会 +2,而不是 +1!
// ...
}
因为 useRef 不重建,ref.current++ 在双调用里执行了两次。同理,在 useEffect 里做"订阅/动画"时 StrictMode 会故意挂载→卸载→再挂载来检测清理不彻底的 bug。正解:副作用的初始化必须可重入,清理函数要完整撤销副作用。
17.5 useState 的函数式更新 vs 值更新
setCount(5)→basicStateReducer直接返回5;setCount(c => c + 1)→ 返回reducer(state, action)即c + 1。- 并发陷阱:若依赖"上一次 state"但用值形式(
setCount(count + 1)),在多次连续更新被批处理/重渲染时,count可能是过时的闭包值。永远用函数式更新来读最新 state。
17.6 setState 传相同值的 eager bailout
如 9.3 所述,传与 lastRenderedState 相等的值会触发 eager bailout,不重渲染。如果你"依赖 setState 一定触发 effect"就会踩坑。注意比较基准是 queue.lastRenderedState(上次渲染稳定值),不是当前 render 的临时值。
17.7 useMemo 不是保证
不要依赖 useMemo 做一次性副作用缓存(见 12.2)。需要"只算一次且绝对保留"用 useRef + useState 懒初始化。
17.8 useContext 的"白嫖"与 Provider 值
useContext不占 hook 链表,条件调用不破规则;但仍建议在顶层。- Provider 的
value若每次 render 都是新对象(<Ctx.Provider value={{a:1}}>),会导致所有消费者 re-render(即使内容没变)。用useMemo稳定 value,或用useReducer/useState持有。
17.9 use 不能在 try/catch
DEV 下 finishRenderingHooks(:734)会检查 checkIfUseWrappedInTryCatch() 并警告。use(promise) 靠抛 Suspense 异常工作,try/catch 会破坏它。
17.10 useOptimistic 必须配 transition/action
单独在普通事件里调 setOptimistic 会 DEV 警告(:3764),因为 revertLane 依赖 transition 上下文来自动回滚。
17.11 渲染中读取 document/window
这不是源码强制,但是惯例:useLayoutEffect 在浏览器环境同步执行,可用;而 useEffect 异步,期间若依赖布局需注意时序。SSR 时 useLayoutEffect 会警告(服务端无 layout),用 useEffect 或 useInsertionEffect 替代。
17.12 多个 state 更新的批处理
React 18+ 默认自动批处理(automatic batching)。一次事件里的多个 setState 合并为一次渲染。dispatchSetStateInternal 的 eager 优化与 enqueueConcurrentHookUpdate 都服务于批处理与优先级。
18. 性能影响与量化
18.1 时间复杂度
mountXxx:每个 hook 是 O(1) 的链表节点创建。updateWorkInProgressHook:O(1) 克隆(不遍历整条链表,只前移游标)。updateReducerImpl:O(U),U =baseQueue + pending的更新数量。最坏情况(大量未决并发更新)与更新数线性相关。areHookInputsEqual:O(D),D = deps 数组长度。
18.2 空间复杂度
- 每个状态和副作用 hook 各占一个
Hook/Effect对象 +UpdateQueue/EffectInstance。 useMemo/useCallback额外存[value, deps]二元组。useContext不增加 hook 对象(只增加一个contextItem挂dependencies)。useMemoCache(编译器)在updateQueue.memoCache存二维数组;enableNoCloningMemoCache开启时跨 render 共享同一实例(:1221),节省拷贝。
18.3 瓶颈与规避
- 长链表遍历:一个组件若有几百个 hook,每次渲染都要走一遍链表。虽是 O(1) 每节点,但绝对量大时仍可观。规避:拆分小组件,减少单组件 hook 数。
- effect 环形链表过大:大量
useEffect会让 commit 阶段的 effect 遍历变长。规避:合并相关 effect,用useEffectEvent减少依赖敏感。 - 不必要的 re-render:
useState值更新触发 re-render 是常态。eager bailout(9.3)已优化"值未变"场景;还可用React.memo+ 稳定useCallback减少子组件重渲染。 useContext广播:一个高频变化的 context 会让所有消费者重渲染。规避:拆分 context、用useSyncExternalStore做细粒度订阅、或用 selector 模式。- deps 比较开销:超大 deps 数组每次渲染都比较。规避:把依赖收敛为基本类型,或用
useEffectEvent移除不稳定函数依赖。
19. 用法与最佳实践(正例 + 反例)
19.1 状态派生优先于状态拷贝
// 反例:render 里 setState 修正(触发重渲染,最多 25 次)
const [list, setList] = useState(items);
const [filtered, setFiltered] = useState(items);
useEffect(() => { setFiltered(list.filter(f)); }, [list]);
// 正例:useMemo 派生,无额外 state
const [list] = useState(items);
const filtered = useMemo(() => list.filter(f), [list, f]);
19.2 函数式更新读最新 state
// 反例:依赖闭包 count(并发下可能过期)
const onClick = () => setCount(count + 1);
// 正例:函数式
const onClick = () => setCount(c => c + 1);
19.3 稳定回调传给子组件
// 反例:每次 render 新函数,破坏 React.memo
<Child onDo={payload => doSomething(payload)} />
// 正例:useCallback 稳定 + 依赖收敛
const onDo = useCallback(payload => doSomething(payload), [doSomething]);
<Child onDo={onDo} />
19.4 effect 清理完整
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // 必须清理,否则 StrictMode 双挂载会叠加定时器
}, []);
19.5 用 use 替代条件式 context 读取场景
React 19 起,读 promise 用 use(promise) 触发 Suspense;读 context 也可用 use(Ctx)(语义等同 useContext)。注意别在 try/catch 里用 use。
19.6 乐观更新用 useOptimistic + action
const [optState, addOptimistic] = useOptimistic(state, (cur, next) => [...cur, next]);
// 在 transition/action 内:
addOptimistic(newItem); // 立即显示,底层 state 更新后自动对齐/回滚
19.7 不要把 useMemo 当缓存保证
若需"只计算一次且绝不丢弃",用 useState 懒初始化或 useRef:
const [computed] = useState(() => expensiveOnce()); // 只算一次
20. 源码索引表
| 主题 | 文件 | 符号 / 行号 |
|---|---|---|
| 公共 API 转发 | packages/react/src/ReactHooks.js | resolveDispatcher:24、useState:66、useReducer:73、useRef:82、useEffect:87、useMemo:143、useContext:53、use:207、useOptimistic:226、useActionState:234 |
| Hook 类型 | ReactFiberHooks.js | Hook:197、Effect:223、EffectInstance:219、FunctionComponentUpdateQueue:249 |
| Update / UpdateQueue | ReactFiberHooks.js | Update:167、UpdateQueue:177 |
| 模块级光标 | ReactFiberHooks.js | currentlyRenderingFiber:264、currentHook:270、workInProgressHook:271 |
| 渲染阶段更新标志 | ReactFiberHooks.js | didScheduleRenderPhaseUpdate:277、RE_RENDER_LIMIT:295 |
| DEV 顺序校验 | ReactFiberHooks.js | hookTypesDev:303、updateHookTypesDev:323、warnOnHookMismatchInDev:351 |
| deps 比较 | ReactFiberHooks.js | areHookInputsEqual:456 |
| 渲染入口 | ReactFiberHooks.js | renderWithHooks:505、dispatcher 选择 :563、StrictMode 双渲染 :595、重渲染触发 :605 |
| 渲染收尾校验 | ReactFiberHooks.js | finishRenderingHooks:636、复位指针 :660、过少 hook :664/:704、context 变化 :714 |
| 重播/重渲染 | ReactFiberHooks.js | replaySuspendedComponentWithHooks:755、renderWithHooksAgain:791 |
| 链表节点 | ReactFiberHooks.js | mountWorkInProgressHook:983、updateWorkInProgressHook:1004、过多 hook 抛错 :1048 |
| use / useThenable | ReactFiberHooks.js | use:1159、useThenable:1098、useMemoCache:1181 |
| reducer/state | ReactFiberHooks.js | basicStateReducer:1265、mountReducer:1270、updateReducerImpl:1316、updateReducer:1307、updateState:1962、rerenderState:1968 |
| mountState | ReactFiberHooks.js | mountStateImpl:1920、mountState:1948 |
| 乐观/action | ReactFiberHooks.js | mountOptimistic:1974、updateOptimisticImpl:2013、ActionStateQueue:2068、dispatchActionState:2106、mountActionState:2380、updateActionState:2465 |
| ref | ReactFiberHooks.js | mountRef:2628、updateRef:2635 |
| effect | ReactFiberHooks.js | pushSimpleEffect/mountEffectImpl:2640、updateEffectImpl:2657、mountEffect:2697、updateEffect:2721、mountInsertionEffect:2782、mountLayoutEffect:2796 |
| callback/memo | ReactFiberHooks.js | mountCallback:2923、updateCallback:2930、mountMemo:2944、updateMemo:2963 |
| deferredValue | ReactFiberHooks.js | mountDeferredValue:2990、mountDeferredValueImpl:3028、updateDeferredValueImpl:3056 |
| startTransition | ReactFiberHooks.js | startTransition:3117、mountTransition:3425、updateTransition:3443 |
| 派发 | ReactFiberHooks.js | dispatchSetStateInternal:3656、dispatchOptimisticSetState:3728、isRenderPhaseUpdate:3838、enqueueRenderPhaseUpdate:3846、entangleTransitionUpdate:3867 |
| 四个 dispatcher | ReactFiberHooks.js | ContextOnlyDispatcher:3898、HooksDispatcherOnMount:3926、OnUpdate:3954、OnRerender:3982 |
| useContext 底层 | ReactFiberNewContext.js | readContext:553、readContextForConsumer:580、prepareToReadContext:540 |
21. 自测 FAQ(20 问)
Q1:useState 和 useReducer 到底什么关系?
A:useState 是完全基于 useReducer 的语法糖,它的 reducer 是 basicStateReducer(ReactFiberHooks.js:1265)——传函数就执行、传值就用值。updateState 直接 return updateReducer(basicStateReducer, initialState)(:1962)。
Q2:为什么 setState 是稳定的、可以放进依赖数组?
A:mountState 用 dispatchSetState.bind(null, fiber, queue) 在 mount 时创建一次(ReactFiberHooks.js:1953),之后更新渲染克隆 queue,queue.dispatch 引用不变,所以 setState 永远同一引用。
Q3:Hooks 的状态存在哪?为什么函数重新执行不丢?
A:存在 workInProgress.memoizedState 上的单向链表(每个 hook 一个节点)。函数执行只是"读/写"这些节点,状态本身在 fiber 上,不随函数调用消亡。
Q4:为什么不能在 if/循环里写 hook?
A:见第 7 章证据链。更新时 updateWorkInProgressHook(:1004)按"调用顺序"从 current 克隆节点;顺序变了会克隆到错误的旧状态,数量变了会在 :1048(more)或 :704(fewer)抛错。
Q5:渲染外调用 hook 会怎样?
A:ReactSharedInternals.H 此时是 ContextOnlyDispatcher,除了 use/readContext 其余全部 throwInvalidHookError(ReactFiberHooks.js:3898/:445),报 "Invalid hook call"。
Q6:给 setState 传和当前一样的值,为什么不重渲染?
A:触发 eager 优化(dispatchSetStateInternal,:3699)。当队列空且 is(eagerState, currentState) 为 true 时,调用 enqueueConcurrentHookUpdateAndEagerlyBailout 并 return false,不调度重渲染。
Q7:useEffect 的 deps 怎么比较?
A:areHookInputsEqual(:456)用 Object.is(shared/objectIs,即 is)逐个比较。不传 deps(undefined)则 nextDeps = null,每次都执行。
Q8:useEffect 和 useLayoutEffect 执行时机有何不同?
A:useEffect 打 PassiveEffect,commit 后由 scheduler 异步 flush(浏览器绘制后);useLayoutEffect 打 LayoutStaticEffect/UpdateEffect,在 commit 同步、绘制前执行,可同步读布局。
Q9:useRef 在 StrictMode 下为什么不"重置"?
A:updateRef(:2635)直接返回 hook.memoizedState(mount 创建的对象),不重建。双调用之间该对象被复用,所以 ref.current 跨双调用保留(ref.current++ 会 +2)。
Q10:useMemo 能保证只算一次吗?
A:不能,它只是优化提示,并发中断时可能丢弃重算。需"绝对只算一次"请用 useState 懒初始化或 useRef。
Q11:渲染阶段 setState 会怎样?
A:走 enqueueRenderPhaseUpdate(:3846)入队并置 didScheduleRenderPhaseUpdate,renderWithHooks 检测到后 renderWithHooksAgain 再渲染一次。超过 25 次(RE_RENDER_LIMIT)抛错。
Q12:useContext 占 hook 链表吗?
A:不占。它直接是 readContext(ReactFiberHooks.js:3931),把订阅挂到 fiber.dependencies.firstContext(ReactFiberNewContext.js:580),所以条件调用也不破 Rules of Hooks。
Q13:use 和 useContext 区别?
A:use 还能读 thenable/promise 并 suspend(ReactFiberHooks.js:1159),是 React 19 统一入口;读 context 时 use(Ctx) 等价于 useContext(Ctx)。但 use(promise) 不能在 try/catch 中(靠抛异常工作)。
Q14:useOptimistic 的"自动回滚"怎么实现的?
A:dispatchOptimisticSetState(:3728)给更新带 revertLane = requestTransitionLane(transition);updateReducerImpl(:1482)当 revertLane 对应 transition 已完成时 continue 跳过该更新,从而回滚到 passthrough 真实值。且 baseState 每帧重置为 passthrough(:2025)。
Q15:useActionState 返回什么,底层几个 hook?
A:返回 [state, dispatch, isPending]。底层三个 hook:状态 hook(用 dispatchSetState)、pending hook(用 dispatchOptimisticSetState)、action 队列 hook(串行执行,:2380)。
Q16:并发更新里"低优先级更新不丢失"怎么保证?
A:updateReducerImpl(:1316)遍历更新时,优先级不足(!isSubsetOfLanes(renderLanes, updateLane))的更新被 clone 进 newBaseQueue 并 markSkippedUpdateLanes,保留到 hook.baseQueue 供下次更高优先级渲染复用(详见第 9 章)。
Q17:为什么 useOptimistic 不走 eager 优化?
A:mountOptimistic 故意把 lastRenderedReducer 设为 null(:1985),而 eager 优化要求 lastRenderedReducer !== null(:3684)。乐观更新不该被"提前 bailout"。
Q18:effect 在 fiber 上怎么存?
A:挂在 fiber.updateQueue.lastEffect 的环形链表(pushEffectImpl,:2605)。每个 Effect 有 tag/create/deps/inst/next。
Q19:多个 useEffect 执行顺序是?
A:按组件体内调用顺序,也就是 effect 环形链表从 lastEffect.next(首)到 lastEffect(尾)的顺序,先执行 destroy(上一次)再 create(本次)。
Q20:dispatcher 有哪几个,怎么切换?
A:四个生产 dispatcher:ContextOnlyDispatcher(渲染外,抛错)、HooksDispatcherOnMount(首渲染)、HooksDispatcherOnUpdate(更新)、HooksDispatcherOnRerender(重渲染)。在 renderWithHooks(:563)按 current === null || current.memoizedState === null 切 mount/update,在 finishRenderingHooks(:660)切回 ContextOnlyDispatcher,重渲染时 renderWithHooksAgain(:791)切到 re-render。
21.5 Hook 链表的内存布局:三个指针如何协作
第 7 章前面讲过"hook 是挂在 fiber.memoizedState 上的单向链表"。这一节把链表的构建与遍历拆到源码级别,因为它是"为什么 hook 不能条件调用"的全部证据。
mount:尾插法建链
packages/react-reconciler/src/ReactFiberHooks.js:983
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
注意:第一个 hook 被同时赋给 fiber.memoizedState 与模块级变量 workInProgressHook;后续 hook 用 workInProgressHook = workInProgressHook.next = hook 尾插。因此链表顺序 = 调用顺序,没有任何名字、索引或键——这就是 hook 规则的全部依据。
update:双指针并行推进
:1004
function updateWorkInProgressHook(): Hook {
// This function is used both for updates and for re-renders triggered by a
// render phase update. It assumes there is either a current hook we can
// clone, or a work-in-progress hook from a previous render pass that we can
// use as a base.
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}
if (nextWorkInProgressHook !== null) {
// There's already a work-in-progress. Reuse it.
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
// Clone from the current hook.
if (nextCurrentHook === null) {
const currentFiber = currentlyRenderingFiber.alternate;
if (currentFiber === null) {
throw new Error(
'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
);
这段揭示了两件事:
- 两个指针同步前进:
currentHook沿旧链表走,workInProgressHook沿新链表走。每调用一次 hook,两者各前进一步。它们在位置上严格对应——不是靠名字,是靠次序。 - 重渲染(re-render)会复用已有的 WIP hook:
if (nextWorkInProgressHook !== null)分支说明渲染阶段更新触发的第二次渲染不会重新 clone,而是复用上一次 pass 已经建好的 hook。这让"渲染阶段 setState 后重跑渲染"变得便宜。
为什么条件调用必然出错
把上面的推进逻辑代入一个条件分支:
if (cond) {
useState(); // 第 1 次调用
}
useEffect(); // cond 为 false 时,它变成了第 1 次调用
- 首次渲染
cond === true:链表是[useState, useEffect]。 - 二次渲染
cond === false:useEffect成为第 1 次调用 →updateWorkInProgressHook试图 clone 第 1 个 hook(那是useState的 hook)→ 类型不匹配,后续updateEffectImpl读到的是 state 的queue,直接崩溃或行为错乱。
如果反过来(这次调用得更多),会走到 nextCurrentHook === null 且 currentFiber !== null 的分支,抛出 "Rendered more hooks than during the previous render"。这就是为什么 React 的报错能精确到"多了"还是"少了"——因为链表推进是严格位置对应的。
21.6 渲染阶段更新:为什么允许,25 次上限从哪来
在渲染函数里调用 setState 是被允许的(不像一些框架直接禁止),React 会立即重跑这个组件的渲染。它是"派生状态的兜底写法"(例如"props 变化时重置 state"),官方推荐的替代方案是渲染期直接比较并赋值。
上限常量
:295
const RE_RENDER_LIMIT = 25;
:810 起的循环:
let numberOfReRenders: number = 0;
...
if (numberOfReRenders >= RE_RENDER_LIMIT) {
...
}
...
numberOfReRenders += 1;
超过 25 次会抛 "Too many re-renders. React limits the number of renders to prevent an infinite loop."。这个数字是经验阈值:正常的"渲染期调整状态"一次就收敛,25 次足够覆盖合法场景,又能快速终止死循环。
入队路径
enqueueRenderPhaseUpdate(:3846)把更新放进 queue.pending,并置 didScheduleRenderPhaseUpdate = true。渲染结束后 renderWithHooks 检测到这个标志,会重新调用组件函数(而不是走完整的调度流程——因为还在同一个渲染任务里)。
三条实践结论
- 渲染期 setState 不会触发新的调度:它在当前渲染内同步重跑,所以不会让
isPending变化,也不会被时间切片。 - 上限 25 是"整个渲染过程"的累计,不是每次 hook 的。多个 hook 各触发一次也会累计到同一个计数器。
- 能不用就不用:绝大多数场景应该改成"渲染期比较并直接赋值"(官方推荐的 "adjusting state when props change" 模式),或者在 effect 里处理。渲染期 setState 会让这次渲染至少跑两遍。
21.7 三种 effect 的执行时机与标志对照
React 19 有三种 effect,它们共用同一套存储结构(fiber.updateQueue.lastEffect 环形链表),靠 tag 位区分:
标志(ReactHookEffectTags.js) | Hook | 执行时机 | 典型用途 |
|---|---|---|---|
HookInsertion | useInsertionEffect | commit 的 mutation 阶段之前,同步 | CSS-in-JS 库在读取布局前注入样式 |
HookLayout | useLayoutEffect | commit 的 layout 阶段,同步,DOM 已更新 | 测量 DOM、同步改样式避免闪烁 |
HookPassive | useEffect | commit 之后,由 Scheduler 异步 flush | 数据请求、订阅、日志 |
对应的 mount 实现:
2640 function mountEffectImpl(...) // Passive
2782 function mountInsertionEffect(...) // Insertion
2796 function mountLayoutEffect(...) // Layout
2657 function updateEffectImpl(...) // 三种共用同一套 deps 比较逻辑
关键差异只有两点:tag 位决定它在 commit 的哪一段被执行;时机决定它是否阻塞绘制。
useInsertionEffect与useLayoutEffect都是同步的,会阻塞绘制。区别是前者更早(在 DOM 变更之前),因此拿不到最新 DOM——它的设计目标就是"在读取布局前插入<style>",普通业务代码不该用。useEffect是异步的(第 6 章讲的flushPassiveEffects),不阻塞绘制。但它仍在主线程上跑,重计算放进去照样造成长任务。
updateEffectImpl 的 deps 比较(areHookInputsEqual)对三种 effect 完全一致:deps 未变就不执行,也不重新登记。所以"三种 effect 的 deps 语义相同",差别只在执行时机。
21.8 useSyncExternalStore:并发渲染下 tearing 的唯一正解
tearing 是什么
tearing(撕裂)指同一次渲染中,不同组件读到了外部数据源的不同版本。在同步渲染下不会发生:渲染是一个不可中断的整体。但在并发渲染下,渲染可能被让出——如果外部 store 在让出期间发生变化,先渲染的组件读到旧值、后渲染的读到新值,UI 就"撕裂"了。
Hook 的解法
mountSyncExternalStore(:1648)与 updateSyncExternalStore(:1739)的实现要点:
getSnapshot在渲染期被调用,返回值参与这次渲染。- 订阅在 commit 阶段建立(
useEffect语义),保证渲染期间不会被通知。 - 每次渲染都会重新读
getSnapshot并与上次比较,如果检测到"渲染期间发生了变化",React 会强制同步重渲染(走 sync lane),从而让整棵树读到同一个版本。
这就是为什么这个 hook 能防 tearing:它用"检测 + 强制同步重试"把并发的可中断性对外部数据源屏蔽掉。
三个必须遵守的约束
getSnapshot必须返回缓存后的值。如果它每次返回新对象({a, b}),React 会认为 store 一直在变 → 无限重渲染。源码里这个比较用的是is()。- 必须提供
getServerSnapshot(SSR 场景),否则服务端渲染会抛 "Missing getServerSnapshot"。因为服务端没有订阅机制,只能取一次快照。 - 订阅的回调必须是稳定的,否则每次渲染都会重新订阅/退订。
为什么不用 useState + useEffect 手写
手写版本无法处理"渲染期间 store 变化"这一情况——你的 effect 在 commit 之后才订阅,中间的变化会丢失,或者读到不一致的值。useSyncExternalStore 是 React 专门为这个并发语义提供的原语,也是所有状态管理库(Redux、Zustand 等)在 React 18+ 必须接入的原因。
22. 本章要点小结
- 存储模型:组件所有 hook 以单向链表挂在
fiber.memoizedState;Hook含memoizedState/baseState/baseQueue/queue/next;副作用型 hook 的Effect以环形链表挂在fiber.updateQueue.lastEffect;useContext不占链表,挂fiber.dependencies.firstContext。 - Dispatcher 机制:
ReactSharedInternals.H全局指针在renderWithHooks按 mount/update/rerender 切换,渲染外是ContextOnlyDispatcher(全抛错)。这是"hook 不能在渲染外/条件里调用"的强制保障。 - 链表必然性:函数组件每次执行都重新跑,状态必须存活在 fiber 上;链表按调用顺序组织,与 Rules of Hooks 严丝合缝。
- 状态更新:
useState是useReducer特例(basicStateReducer);更新存于queue.pending环形链表,dispatch稳定引用;updateReducerImpl按 lane 过滤,低优先级更新被 clone 进baseQueue保留(复用与丢弃);eager 优化在"队列空且算出 state 未变"时短路 bailout。 - 渲染阶段更新:走
enqueueRenderPhaseUpdate,触发最多 25 次重渲染(RE_RENDER_LIMIT),靠renderWithHooksAgain就地消费。 - effect:
mountEffectImpl打PassiveEffect标记 + 挂updateQueue;updateEffectImpl用areHookInputsEqual(Object.is)比较 deps,未变则跳过;Passive effect 在 commit 后由 scheduler 异步 flush。Passive/Layout/Insertion 三种标记的 fiber 标记与执行时机不同。 - ref 稳定性:
updateRef直接复用memoizedState,永不重建;StrictMode 下双调用仍共享同一对象。 useMemo/useCallback:靠areHookInputsEqual短路;useCallback是useMemo的语法糖。- React 19 新 hook:
use统一读 Context/Promise(可 suspend);useOptimistic每帧重置baseState为 passthrough +revertLane实现乐观叠加与自动回滚;useActionState是 useState + 乐观 pending + 串行 action 队列;useDeferredValue/useTransition把非紧急更新放到低优先级车道。
理解本章后,你对 React 函数组件"为什么能这样工作、为什么不能那样写"会有从根上的把握。下一章(如有)可深入并发渲染中 hook 与车道系统的交互细节。
本章所有行号基于 React 19.3.1 源码(react-19.3.1),引用前已在对应文件中核对。如运行时版本不同,请以实际源码为准。