React 19 内核探秘
第7章 Hooks 的实现原理
第7章 Hooks 的实现原理
本章要点
- Hooks 的数据结构:单向链表与 Fiber 节点的绑定关系
- useState 的完整实现:mount 阶段与 update 阶段的差异
- useReducer 的工作机制:与 useState 的同源性
- useEffect 与 useLayoutEffect 的内部调度差异
- useRef 为什么是最简单的 Hook——以及它为什么不触发重渲染
- useMemo 与 useCallback 的缓存策略与失效机制
- useContext 的订阅模型与性能陷阱
- Hook 规则的技术根因:为什么不能条件调用 Hook
- Dispatcher 切换机制:React 如何在不同阶段使用不同的 Hook 实现
Hooks 是 React 16.8 引入的最重大的范式变革。表面上,它让函数组件拥有了状态和副作用的能力;但从内核的角度看,Hooks 的设计远比 API 表面呈现的要精妙得多。
当你写下 const [count, setCount] = useState(0) 时,你可能会好奇:一个看似无状态的函数,每次调用都从头执行,它是怎么”记住”上一次的值的?更关键的是,当同一个组件中有多个 useState,React 是怎么知道哪个 state 对应哪个调用的?
答案藏在两个核心设计中:Fiber 节点上的 Hook 链表和基于调用顺序的索引机制。
7.1 Hook 的数据结构
每个 Hook 在 React 内部对应一个 Hook 对象,这些对象通过 next 指针串成一条单向链表,挂载在 Fiber 节点的 memoizedState 属性上:
// Hook 的核心数据结构
type Hook = {
memoizedState: any; // 当前状态值(不同 Hook 存储不同的东西)
baseState: any; // 基准状态(用于并发模式下的状态计算)
baseQueue: Update<any> | null; // 上次未处理完的更新队列
queue: UpdateQueue<any> | null; // 当前待处理的更新队列
next: Hook | null; // 指向下一个 Hook
};
// 不同 Hook 的 memoizedState 存储的内容
// useState → state 值
// useReducer → state 值
// useEffect → Effect 对象
// useRef → { current: value }
// useMemo → [cachedValue, deps]
// useCallback → [callback, deps]
// useContext → context 的当前值(但实际上不使用 Hook 链表)
当 React 渲染一个函数组件时,Hook 链表的构建过程如下:
function MyComponent() {
const [name, setName] = useState('React'); // Hook 1
const [count, setCount] = useState(0); // Hook 2
const ref = useRef(null); // Hook 3
useEffect(() => { /* ... */ }, [count]); // Hook 4
const memoized = useMemo(() => count * 2, [count]); // Hook 5
return <div>{name}: {count} (x2 = {memoized})</div>;
}
graph LR
F["Fiber<br/>memoizedState"] --> H1["Hook 1<br/>useState('React')<br/>memoizedState: 'React'"]
H1 -->|next| H2["Hook 2<br/>useState(0)<br/>memoizedState: 0"]
H2 -->|next| H3["Hook 3<br/>useRef(null)<br/>memoizedState: {current: null}"]
H3 -->|next| H4["Hook 4<br/>useEffect<br/>memoizedState: Effect"]
H4 -->|next| H5["Hook 5<br/>useMemo<br/>memoizedState: [0, [0]]"]
H5 -->|next| N["null"]
图 7-1:Hook 链表结构示意图
7.2 Dispatcher:Hook 的两张面孔
React 中的每个 Hook API(如 useState)并不是一个固定的实现,而是一个”门面”,它的实际行为取决于当前的 Dispatcher。React 维护了一个全局变量 ReactCurrentDispatcher,在不同的执行阶段会切换到不同的 Dispatcher:
// React 的 Dispatcher 机制
const ReactCurrentDispatcher = {
current: null as Dispatcher | null,
};
// 三种主要的 Dispatcher
const HooksDispatcherOnMount: Dispatcher = {
useState: mountState,
useEffect: mountEffect,
useRef: mountRef,
useMemo: mountMemo,
useCallback: mountCallback,
useReducer: mountReducer,
useContext: readContext,
// ...
};
const HooksDispatcherOnUpdate: Dispatcher = {
useState: updateState,
useEffect: updateEffect,
useRef: updateRef,
useMemo: updateMemo,
useCallback: updateCallback,
useReducer: updateReducer,
useContext: readContext,
// ...
};
const InvalidHooksDispatcher: Dispatcher = {
useState: throwInvalidHookError,
useEffect: throwInvalidHookError,
// ... 所有方法都抛错
};
当 React 开始渲染一个函数组件时,会根据情况设置 Dispatcher:
function renderWithHooks(
current: Fiber | null,
workInProgress: Fiber,
Component: Function,
props: any
) {
// 根据是首次渲染还是更新,设置不同的 Dispatcher
if (current !== null && current.memoizedState !== null) {
// 更新阶段
ReactCurrentDispatcher.current = HooksDispatcherOnUpdate;
} else {
// 首次挂载
ReactCurrentDispatcher.current = HooksDispatcherOnMount;
}
// 执行函数组件
let children = Component(props);
// 渲染完成后,将 Dispatcher 设为无效版本
// 这就是为什么在组件外调用 Hook 会报错
ReactCurrentDispatcher.current = InvalidHooksDispatcher;
return children;
}
这就是为什么在组件渲染函数外部调用 useState 会得到一个”Invalid hook call”错误——此时的 Dispatcher 已经被切换为 InvalidHooksDispatcher,所有 Hook 调用都会抛出异常。
7.3 useState 的完整实现
useState 是使用最广泛的 Hook,它的实现也是理解所有 Hook 工作原理的基石。
7.3.1 Mount 阶段:初始化
首次渲染时,useState 调用的是 mountState:
function mountState<S>(initialState: (() => S) | S): [S, Dispatch<SetStateAction<S>>] {
// 1. 创建一个新的 Hook 对象并添加到链表末尾
const hook = mountWorkInProgressHook();
// 2. 处理初始值(支持函数形式的惰性初始化)
if (typeof initialState === 'function') {
initialState = (initialState as () => S)();
}
// 3. 设置初始状态
hook.memoizedState = initialState;
hook.baseState = initialState;
// 4. 创建更新队列
const queue: UpdateQueue<S> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
};
hook.queue = queue;
// 5. 创建 dispatch 函数(即 setState)
const dispatch = (queue.dispatch = dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue
));
return [hook.memoizedState, dispatch];
}
// mountWorkInProgressHook 负责创建 Hook 对象并链接到链表
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 这是链表的第一个 Hook
currentlyRenderingFiber.memoizedState = hook;
workInProgressHook = hook;
} else {
// 追加到链表末尾
workInProgressHook.next = hook;
workInProgressHook = hook;
}
return hook;
}
有一个重要的细节:dispatch 函数通过 bind 绑定了 Fiber 节点和更新队列。这就是为什么 setState 可以在组件外部被调用(比如在 setTimeout 中)——它已经”记住”了要更新哪个组件。
7.3.2 Update 阶段:处理更新
当组件因为 setState 被调用而重新渲染时,useState 调用的是 updateState:
function updateState<S>(initialState: (() => S) | S): [S, Dispatch<SetStateAction<S>>] {
// useState 在 update 阶段本质上就是一个 useReducer
return updateReducer(basicStateReducer, initialState);
}
// useState 的 reducer 就是这么简单
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function'
? (action as (S) => S)(state)
: action;
}
这揭示了一个重要事实:useState 在内部就是一个使用了 basicStateReducer 的 useReducer。
function updateReducer<S, A>(
reducer: (S, A) => S,
initialArg: S
): [S, Dispatch<A>] {
// 1. 获取当前 Hook(从链表中按顺序取下一个)
const hook = updateWorkInProgressHook();
const queue = hook.queue!;
queue.lastRenderedReducer = reducer;
const current = currentHook!;
let baseQueue = current.baseQueue;
// 2. 将 pending 更新合并到 baseQueue
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
if (baseQueue !== null) {
// 合并两个环形链表
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
// 3. 逐个处理更新队列中的 update
if (baseQueue !== null) {
const first = baseQueue.next;
let newState = current.baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
do {
const updateLane = update.lane;
if (!isSubsetOfLanes(renderLanes, updateLane)) {
// 优先级不够,跳过此更新(保留到下次)
const clone = {
lane: updateLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: null as any,
};
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
} else {
// 优先级足够,计算新状态
if (update.hasEagerState) {
// 使用预计算的状态(性能优化)
newState = update.eagerState;
} else {
const action = update.action;
newState = reducer(newState, action);
}
}
update = update.next;
} while (update !== null && update !== first);
hook.memoizedState = newState;
hook.baseState = newBaseState ?? newState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
return [hook.memoizedState, queue.dispatch!];
}
7.3.3 dispatchSetState:更新的触发
当你调用 setCount(count + 1) 时,真正执行的是 dispatchSetState:
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A
) {
// 1. 获取更新优先级
const lane = requestUpdateLane(fiber);
// 2. 创建 update 对象
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: null as any,
};
if (isRenderPhaseUpdate(fiber)) {
// 在渲染过程中调用 setState(render phase update)
enqueueRenderPhaseUpdate(queue, update);
} else {
// 3. 🔑 关键优化:Eager State
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 当前没有其他待处理的更新
// 可以立即计算新状态,如果和旧状态相同就跳过调度
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (Object.is(eagerState, currentState)) {
// 新旧状态相同,无需调度更新!
enqueueUpdate(fiber, queue, update, lane);
return;
}
}
}
// 4. 将 update 加入队列
enqueueUpdate(fiber, queue, update, lane);
// 5. 调度更新
const root = scheduleUpdateOnFiber(fiber, lane);
}
}
这里有一个极其重要的优化——Eager State(急切状态计算)。当组件当前没有其他待处理的更新时,React 会立即计算新状态。如果新状态与旧状态相同(通过 Object.is 比较),React 可以完全跳过这次更新的调度——连 Render 阶段都不需要进入。
function Counter() {
const [count, setCount] = useState(0);
// 连续调用两次 setCount(1)
const handleClick = () => {
setCount(1); // 第一次:0 → 1,需要更新
setCount(1); // 第二次:1 → 1,Eager State 优化,跳过
};
console.log('render'); // 只会打印一次
return <button onClick={handleClick}>{count}</button>;
}
7.3.4 更新队列的环形链表
React 的更新队列使用环形链表来存储 update 对象。为什么不用普通链表或数组?
function enqueueUpdate<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
update: Update<S, A>,
lane: Lane
) {
// 环形链表:新节点插入到 pending 和 pending.next 之间
const pending = queue.pending;
if (pending === null) {
// 空队列:自己指向自己
update.next = update;
} else {
// 插入到 pending 之后
update.next = pending.next;
pending.next = update;
}
// pending 始终指向最后一个插入的 update
queue.pending = update;
}
环形链表的巧妙之处在于:queue.pending 指向最后一个 update,而 queue.pending.next 指向第一个 update。这样,既能 O(1) 地追加新 update 到末尾,也能 O(1) 地访问到第一个 update,无需维护头尾两个指针。
graph LR
subgraph "环形链表"
Q["queue.pending"] --> U3["Update 3<br/>(最新)"]
U3 -->|next| U1["Update 1<br/>(最早)"]
U1 -->|next| U2["Update 2"]
U2 -->|next| U3
end
图 7-2:更新队列的环形链表结构
7.3.4.1 源码核对:dispatchSetState 的第三个”匿名参数”陷阱 + InvalidNestedHooksDispatcherOnUpdateInDEV
真实 dispatchSetState(ReactFiberHooks.js:2748-2827)和教学版有三处值得细看的差别:
1、对”传第四个参数”的警告(第 2753-2761 行):
if (__DEV__) {
if (typeof arguments[3] === 'function') {
console.error(
"State updates from the useState() and useReducer() Hooks don't support the " +
'second callback argument. To execute a side effect after ' +
'rendering, declare it in the component body with useEffect().',
);
}
}
这条警告拦截一个只有从 React 15 迁移过来的老用户会犯的错误——this.setState(newState, callback)。React 函数式的 setState 只接受一个参数(新值或 updater),但有人会写 setSomething(newValue, () => { console.log('done') }) ——React 会静默忽略回调,bug 非常难发现。
React 团队把这个错误模式预埋一个 DEV 警告——只要你多传了一个 function 参数,立刻报错”请用 useEffect 实现 rendering 后的副作用”。这是从旧 API 用户的常见误用中学到的产品经验、固化到框架代码里。注意检测用的是 arguments[3]——因为 dispatchSetState 是通过 bind(null, fiber, queue) 预绑前 2 个参数、用户实际传的第 1 个参数是 arguments[2](action)、第 2 个是 arguments[3](那个被误传的 callback)。
2、Eager State 计算期间切换到”invalid hooks”Dispatcher(第 2788-2792 行):
if (__DEV__) {
prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current =
InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const eagerState = lastRenderedReducer(currentState, action);
...
}
为什么调 reducer 之前要切 Dispatcher?因为 reducer 里不应该调 Hook——React 设计里 reducer 是纯函数、无副作用、无状态。但用户可能不自觉地在 reducer 里调了 useRef 等——这种违规如果不拦截,会在 render 阶段造成不可预测的行为。InvalidNestedHooksDispatcherOnUpdateInDEV 里所有 Hook 都会立刻报错——让违规在 dispatch 阶段立刻显形,而不是等到实际 render 时才报。
3、enqueueConcurrentHookUpdateAndEagerlyBailout——这个函数名就描述了整个语义:“并发地入队这个 Hook update,并 eagerly bail out(不 schedule 再渲染)“。当 Eager State 判定新旧值相等时,update 仍然入队(保证 reducer 如果之后变了能 rebase)、但不 schedule render。注释里有一行暗示的承诺:“It’s still possible that we’ll need to rebase this update later, if the component re-renders for a different reason and by that time the reducer has changed.”——这是 React 对 “reducer 可能在后续 render 中改变” 的防御代码。
这三条机制叠加起来让 dispatchSetState 从一个”触发 re-render”的简单 API 变成了”检查+预算+发警告+条件 schedule”的精密机器——每一条都是从真实 bug/反模式中积累下的硬防御。
7.3.5 源码核对:updateWorkInProgressHook 的两种路径——新建 vs 复用 WIP
§7.3 给的 mountWorkInProgressHook 是新建 Hook 的路径。真实的 updateWorkInProgressHook(ReactFiberHooks.js:961-1030)做的事要复杂得多——它必须在两种场景下都能正确工作:
- 普通 update:从
current.memoizedState开始按顺序克隆 - render-phase update:在同一次 render 中再次渲染(比如
setState在 render 内被调)——这时 workInProgress 链表已经部分建好,需要复用而不是重建
真实代码里两种情况通过 nextWorkInProgressHook 是否存在来区分:
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) {
// 抛错:Rendered more hooks than during the previous render.
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,
};
// 追加到 workInProgress 链表
}
三条从这段代码能读出的深层机制:
1、“Rendered more hooks than during the previous render.” 的错误来源。这条错误消息几乎每个 React 开发者都见过——它其实是 updateWorkInProgressHook 里 nextCurrentHook === null 路径抛出的。当次渲染调的 Hook 个数比上次多时,在第 N+1 个 Hook 处 currentHook.next 已经为 null——没有可克隆的旧 Hook——只能抛错。
这条错误的根因和解决方案:不要在条件里调 Hook,哪怕是 if (cond) useState(0) 这样无害的条件。因为 cond 在第一次渲染为 true、第二次为 false 时,第二次渲染的 Hook 数比第一次少;反之,第二次多调的那个 Hook 在当前链表里找不到对应的 current——两种方向都会出错。Hook 规则的”严格同序、同数量”不是建议,是算法强制要求。
2、render-phase update 的”复用已建 WIP”机制。用户代码里 setState 直接在渲染函数体里被调时(比如 if (needToSync) setState(...)),React 不会启动新的渲染——而是在同一次渲染里记录更新、然后重新运行组件函数。这时第二轮调用走到 useState,WIP 链表已经建好了——nextWorkInProgressHook !== null 的分支让代码直接复用已有 Hook,而不是抛”more hooks”错。
这个分支存在的意义:允许用户在 render 内部无限(实际有上限)次地 setState 并重新渲染,直到状态稳定——这是 React 的 render-phase update 机制,用得少但偶尔非常有用(比如”从 prop 派生 state”的合法实现)。
3、currentHook 和 workInProgressHook 是两个独立的游标。前者指向 current.memoizedState 链表上的当前位置;后者指向 workInProgress.memoizedState 链表上的当前位置。每次 Hook 调用时两者同步往前走——这保证了”第 N 个 Hook 调用能对应旧 Hook 的第 N 个”。如果你想象成一个双指针算法——currentHook 是只读的”历史磁带”、workInProgressHook 是可写的”未来磁带”——这个模型就很清晰了。
7.3.6 源码核对:mountStateImpl 的 initialState lazy 求值
useState 能传两种 initialState:值(useState(0))和函数(useState(() => expensive()))。后者叫 “lazy initial state”——React 保证函数只在 mount 时调用一次。真实 mountStateImpl(ReactFiberHooks.js:1721-1737)体现这一点:
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState(); // ← 这里求值,只在 mount 时
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer, // ← §7.3.2 讲过的内置 reducer
lastRenderedState: (initialState: any),
};
hook.queue = queue;
return hook;
}
两条容易踩的细节:
1、只在 mount 阶段求值。updateState 走 updateReducer(basicStateReducer, initialState) 根本不看 initialState——update 阶段传入的 initialState 是被忽略的。这意味着你写 useState(() => expensive()),expensive 只在组件首次 mount 时执行一次;后续每次 update,React 完全不碰这个函数引用。
但如果你写 useState(expensive())(去掉箭头——立刻调用),expensive 每次 render 都会执行——虽然 React 只用第一次的结果。这是性能上肉眼可见的差别。为什么会有这条设计?JS 的函数调用语义决定的——useState(expensive()) 里 expensive 的执行发生在 useState 调用之前的参数求值阶段,React 无法拦截。只能依靠用户”把昂贵的计算包在函数里懒执行”——useState(() => expensive())。
2、lastRenderedState: (initialState: any) 的作用。这个字段是 §7.3.3 Eager State 优化的基础——React 需要记住”上次 render 时这个 state 的值”,才能在 setState 时立刻用 reducer 算出新值。mount 时 lastRenderedState === initialState——和 memoizedState 相同。每次 render 完成后 queue.lastRenderedState 会被更新(§7.3.2 的 updateReducer 第 318 行)。
这个字段是专门为”立即判断是否要 bailout”服务的——它存在的唯一理由是 eagerState 优化。如果没有 Eager State 机制,React 完全不需要记这个值。React 为了这一项优化专门维护一份额外的状态,说明这个优化在真实场景中的频率足够高——“连续两次 setState 同一个值”在现实 UI 代码里比想象的更常见(controlled input 的同键重复、防抖过程中的重复 fire 等)。
7.4 useReducer:useState 的泛化形式
从源码角度看,useState 是 useReducer 的特化版本。两者的区别仅在于 reducer 函数的来源:
// useState 的 mount
function mountState(initialState) {
const hook = mountWorkInProgressHook();
// ...
hook.queue.lastRenderedReducer = basicStateReducer; // 内置 reducer
// ...
}
// useReducer 的 mount
function mountReducer(reducer, initialArg, init) {
const hook = mountWorkInProgressHook();
const initialState = init !== undefined ? init(initialArg) : initialArg;
hook.memoizedState = initialState;
// ...
hook.queue.lastRenderedReducer = reducer; // 用户提供的 reducer
// ...
}
useReducer 的真正价值在于复杂状态逻辑的封装和复用:
type TodoAction =
| { type: 'ADD'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'DELETE'; id: number };
interface Todo {
id: number;
text: string;
completed: boolean;
}
function todoReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD':
return [...state, {
id: Date.now(),
text: action.text,
completed: false,
}];
case 'TOGGLE':
return state.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
case 'DELETE':
return state.filter((todo) => todo.id !== action.id);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []);
// dispatch 的引用在整个组件生命周期中是稳定的
// 可以安全地传递给子组件而不需要 useCallback
return <TodoList todos={todos} dispatch={dispatch} />;
}
一个常被忽视的优势是:dispatch 函数的引用在组件的整个生命周期中是稳定的(与 useState 的 setState 一样),可以安全地传递给 React.memo 包裹的子组件。
7.4.1 源码核对:mountReducer 的 init 函数 + useReducer 和 useState 的 dispatch 差异
§7.4 给的 mountReducer 代码只展示了 initialState 的三种来源(直接值 / init 函数 / lazy)。真实 mountReducer(ReactFiberHooks.js:1165-1192)里还有一个dispatch 绑定的差异:
function mountReducer<S, I, A>(reducer, initialArg, init) {
const hook = mountWorkInProgressHook();
let initialState;
if (init !== undefined) {
initialState = init(initialArg); // 懒初始化
} else {
initialState = initialArg;
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, A> = {
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];
}
注意 dispatch 绑的是 dispatchReducerAction——不是 useState 用的 dispatchSetState。两者的区别在哪?打开 dispatchReducerAction(ReactFiberHooks.js:2709):
function dispatchReducerAction<S, A>(fiber, queue, action) {
// ... 和 dispatchSetState 几乎一样,
// 但【没有】Eager State 分支——
// 因为 useReducer 的 reducer 可能是昂贵的、也可能有副作用
// 不能在 dispatch 时就调 reducer 求值
}
useReducer 不走 Eager State 优化。这是因为 useState 的 reducer 是 basicStateReducer——极其简单、无副作用、可以随便多调几次。useReducer 的 reducer 是用户提供的任意函数——可能很慢(比如深度计算)、可能有 console.log 副作用——React 不能假设它是幂等的、也不应该在 dispatch 阶段提前调用。
这条差别解释了一条微妙的性能观察:useReducer(r, 0) + dispatch('INC') 和 useState(0) + setState(n => n+1) 在”连续 dispatch 同样导致 state 不变”场景下行为不同。前者每次都会触发 re-render(因为没 Eager State 比较);后者可以完全 bail out。如果你的 reducer 是纯函数、值稳定、且 reducer 执行很快——两者性能相当;否则用 useState 性能更好。
useReducer 的优势不在性能,在复杂 state 逻辑的封装、type 安全的 action 类型、可测试性——这些价值足以抵消没有 Eager State 的代价。
7.5 useEffect 与 useLayoutEffect
Effect Hook 的实现比 State Hook 更复杂,因为它需要处理副作用的创建、清理和依赖比较。
7.5.1 Effect 的数据结构
type Effect = {
tag: HookFlags; // 标识 Effect 类型(Layout/Passive/Insertion)
create: () => (() => void) | void; // 副作用函数
destroy: (() => void) | void; // 清理函数(create 的返回值)
deps: Array<mixed> | null; // 依赖数组
next: Effect; // 指向下一个 Effect(也是环形链表)
};
Effect 对象不仅存储在 Hook 的 memoizedState 中,还会被添加到 Fiber 的 updateQueue 中——这是一个专门用于 Effect 的环形链表,Commit 阶段通过这个队列来执行副作用。
7.5.2 Mount 阶段
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
) {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps
);
}
function mountLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
) {
return mountEffectImpl(
UpdateEffect,
HookLayout,
create,
deps
);
}
function mountEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null
) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 在 Fiber 上标记存在 effect
currentlyRenderingFiber.flags |= fiberFlags;
// 创建 Effect 对象并挂载到 Hook 和 Fiber 的 updateQueue 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined, // destroy 在 mount 阶段还没有
nextDeps
);
}
注意 useEffect 和 useLayoutEffect 的差异仅在于它们的 flags 不同:
useEffect→PassiveEffect+HookPassiveuseLayoutEffect→UpdateEffect+HookLayout
这些 flags 决定了 Commit 阶段在哪个子阶段执行它们。
7.5.3 Update 阶段:依赖比较
function updateEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null
) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 🔑 逐项比较依赖
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变,创建一个没有 HookHasEffect 标记的 Effect
// 这个 Effect 在 Commit 阶段会被跳过
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
// 依赖变了,标记需要执行
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps
);
}
// 依赖比较函数
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null
): boolean {
if (prevDeps === null) return false;
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
关键点:当依赖没有变化时,React 仍然会创建一个新的 Effect 对象(因为 Hook 链表需要保持完整),但这个 Effect 没有 HookHasEffect 标记,所以 Commit 阶段会跳过它的执行。
7.6 useRef:最简单的 Hook
useRef 的实现简单到令人惊讶:
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();
// 直接返回之前创建的 ref 对象,什么都不做
return hook.memoizedState;
}
useRef 之所以不会触发重渲染,是因为它从不与 Fiber 的更新机制交互。它既不标记任何 flags,也不创建任何 update。修改 ref.current 就是普通的 JavaScript 对象属性赋值——React 完全不知道这件事发生了。
这也解释了为什么 useRef 可以用来存储任何可变值(不仅仅是 DOM 引用),而且修改它不会导致重渲染:
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const countRef = useRef(count);
// 保持 countRef 与最新的 count 同步
countRef.current = count;
useEffect(() => {
intervalRef.current = setInterval(() => {
// 使用 ref 读取最新的 count,避免闭包陷阱
console.log('当前 count:', countRef.current);
setCount((c) => c + 1);
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []); // 空依赖数组,只在挂载时执行
return <div>{count}</div>;
}
7.5.4 源码核对:pushEffect 的环形链表双写——Hook 链表 和 Fiber updateQueue
§7.5 讲了 Effect 被 push 到 Fiber.updateQueue,但没展开环形链表的维护。真实 pushEffect(ReactFiberHooks.js:1838-1870)是一个精巧的双端维护:
function pushEffect(tag, create, inst, deps): Effect {
const effect: Effect = {
tag, create, inst, deps,
next: null, // Circular
};
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect; // 自指环
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect; // 新 effect 接在尾
effect.next = firstEffect; // 指回头
componentUpdateQueue.lastEffect = effect; // 尾指针更新
}
}
return effect;
}
三条值得读懂的细节:
1、lastEffect 是尾指针,lastEffect.next 是头。这是 React 贯穿整个代码库的环形链表约定——和 §7.3.4 讲过的 queue.pending 的更新队列是同一个模式。用单一指针维护环形链表——O(1) 追加新 effect、O(1) 访问头——不需要维护独立的 head/tail 两个指针。
2、初始化时的 effect.next = effect 自指。第一个 effect 加入空队列时,它的 next 必须指向自己(形成长度为 1 的环)——保证”从 head 开始遍历一圈能回到起点”这条不变量。如果这里写 effect.next = null,后续 commit 阶段遍历 effect list 时会走到 null 而不是绕回头——语义完全错。
3、effect 同时出现在两个地方:Hook 链表的 hook.memoizedState(让下次 render 能通过 Hook 顺序找到它做依赖比较)+ Fiber 的 updateQueue(让 commit 阶段按 effect 顺序执行)。两个引用指向同一个 effect 对象——不是拷贝——这意味着 commit 阶段执行 effect.destroy 后把结果写回 effect.inst,下次 render 时 Hook 链表里读到的也是新 inst。
这条”双挂载”设计让 effect 既享受”按 Hook 调用顺序索引”(被 deps 比较时快速定位),又享受”按执行顺序遍历”(commit 阶段高效执行)。一个对象、两条链表、两套访问语义——这是 React 源码里很典型的对象复用模式。
7.6.1 源码核对:useRef 的 DEV 模式代理 + Object.seal
§7.6 说 useRef 的 mount 实现”简单到令人惊讶”——真实的 mountRef(ReactFiberHooks.js:1893-1959)在生产路径确实是 3 行:
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
但在 DEV 模式下、且 enableUseRefAccessWarning flag 开启时,mountRef 会用 getter/setter 代理包装 ref 对象(第 1908-1946 行),检测渲染期间的 mutable read/write:
const ref = {
get current() {
if (!hasBeenInitialized) {
// 懒初始化模式:允许读 undefined 一次
didCheckForLazyInit = true;
lazyInitGetterStack = getCallerStackFrame();
} else if (currentlyRenderingFiber !== null && !didWarnAboutRead) {
if (lazyInitGetterStack === null ||
lazyInitGetterStack !== getCallerStackFrame()) {
didWarnAboutRead = true;
console.warn(
'%s: Unsafe read of a mutable value during render.\n\n' +
'Reading from a ref during render is only safe if:\n' +
'1. The ref value has not been updated, or\n' +
'2. The ref holds a lazily-initialized value that is only set once.\n',
...
);
}
}
return current;
},
set current(value) {
if (currentlyRenderingFiber !== null && !didWarnAboutWrite) {
if (hasBeenInitialized || !didCheckForLazyInit) {
didWarnAboutWrite = true;
console.warn(
'%s: Unsafe write of a mutable value during render.\n\n' +
'Writing to a ref during render is only safe if the ref holds ' +
'a lazily-initialized value that is only set once.\n',
...
);
}
}
hasBeenInitialized = true;
current = value;
},
};
Object.seal(ref);
四条教学点:
1、 DEV 才有警告、生产零开销。通过 enableUseRefAccessWarning feature flag 门控——现代 React 开发工具能自动检测这条警告,提示你”哪个 useRef 在 render 中被读或写了”。生产 bundle 完全不走这段代码,useRef 开销和”手写 {current: x} 对象”一样。
2、懒初始化模式的例外。如果 initialValue == null 且你在 render 里第一次ref.current = x——被视为”懒初始化”,不警告。这是为了支持 React 官方文档推荐的 useRef(null) + if (!ref.current) ref.current = createInstance() 模式。lazyInitGetterStack 记录第一次读的调用栈,后续同位置读也不警告。
3、didWarnAboutRead / didWarnAboutWrite 每 ref 独立。同一个 ref 的多次违规只警告一次——避免刷屏。但不同 ref 违规分别警告。这条”每违规点最多一次警告”的设计在 React 的多个警告机制里都有出现。
4、Object.seal(ref) 禁止添加新属性。防止用户写 ref.foo = 1——ref 不是随意的可变容器,它只有 current 一个槽位。seal 后试图设置其他属性在严格模式下会 throw。生产版本没 seal 是因为生产 ref 对象没 getter/setter,无所谓额外属性。
这套复杂的 DEV 机制解释了为什么 React 的调试体验比很多框架好——框架在开发环境多花 CPU 换调试信号,警告不是泛泛而谈,而是”你的 ref 在 render 里被读取了,具体在哪一行”。这是一个库 vs 框架的分水岭:好的框架愿意为用户的 DX 付大量额外成本。
7.6.2 源码核对:updateRef 的一行——为什么 “什么都不做”
updateRef(ReactFiberHooks.js:1962-1965)的真实代码只有 3 行:
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
注意**initialValue 参数被完全忽略**。这个行为常让新手困惑——“我每次调 useRef(newValue) 传新的初值,为什么 ref.current 还是老值?“——因为 updateRef 根本不看 initialValue。这个参数只在 mount 时起作用。
对 React 来说这条设计一致于 useState——mount 阶段用 initial,update 阶段返回已存的 state。但 useState 至少能通过 setState 改值;useRef 的值只能通过直接 mutate ref.current 来改。
这也解释了为什么用户不该依赖 “useRef 的 initialValue 被赋给 .current”——事实上赋值只发生一次。如果你需要”每次 render 都用最新 prop 初始化 ref”——应该在 render 体或 useEffect 里手动写 ref.current = newValue,不要指望 useRef(newValue) 这种写法。
React 把这条”初值只用一次”的语义固化在源码的”什么都不做”的 updateRef 里——以无为表达规则,这是 API 设计的一种高级形态。
7.7 useMemo 与 useCallback
useMemo 和 useCallback 本质上是同一种缓存机制的两种表现形式:
function mountMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null
): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate(); // 立即执行计算函数
hook.memoizedState = [nextValue, nextDeps]; // 缓存值和依赖
return nextValue;
}
function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null
): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null && nextDeps !== null) {
const prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变,返回缓存的值
return prevState[0];
}
}
// 依赖变了,重新计算
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
function mountCallback<T>(
callback: T,
deps: Array<mixed> | void | null
): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps]; // 缓存回调和依赖
return callback;
}
function updateCallback<T>(
callback: T,
deps: Array<mixed> | void | null
): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null && 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)。区别仅在于 useMemo 缓存的是函数的返回值,而 useCallback 缓存的是函数本身。
7.7.1 何时使用 useMemo 和 useCallback
一个常见的误解是”到处使用 useMemo 和 useCallback 可以提高性能”。实际上,每个 useMemo/useCallback 都有成本:
// ❌ 不必要的 useMemo:简单计算不需要缓存
function Component({ a, b }: { a: number; b: number }) {
// 加法的计算成本远低于 useMemo 的依赖比较开销
const sum = useMemo(() => a + b, [a, b]);
return <div>{sum}</div>;
}
// ✅ 有意义的 useMemo:昂贵的计算
function Component({ items }: { items: Item[] }) {
const sorted = useMemo(() => {
// 排序是 O(n log n) 的操作,值得缓存
return [...items].sort((a, b) => a.score - b.score);
}, [items]);
return <List items={sorted} />;
}
// ✅ 有意义的 useCallback:传递给 memo 组件的回调
function Parent({ id }: { id: string }) {
const handleClick = useCallback(() => {
fetchData(id);
}, [id]);
// 如果不用 useCallback,ExpensiveChild 每次都会重渲染
return <ExpensiveChild onClick={handleClick} />;
}
const ExpensiveChild = React.memo(({ onClick }: { onClick: () => void }) => {
// ... 昂贵的渲染逻辑
});
7.7.1 源码核对:areHookInputsEqual 的 DEV 警告体系
§7.7 讲了依赖比较。真实 areHookInputsEqual(ReactFiberHooks.js:439-486)的严格性远比教学版高——它在 DEV 下对两种反模式都发警告:
function areHookInputsEqual(nextDeps, prevDeps) {
if (__DEV__) {
if (ignorePreviousDependencies) {
// Only true when this component is being hot reloaded.
return false;
}
}
if (prevDeps === null) {
if (__DEV__) {
console.error(
'%s received a final argument during this render, but not during ' +
'the previous render. Even though the final argument is optional, ' +
'its type cannot change between renders.',
currentHookNameInDev,
);
}
return false;
}
if (__DEV__) {
// Don't bother comparing lengths in prod because these arrays should be
// passed inline.
if (nextDeps.length !== prevDeps.length) {
console.error(
'The final argument passed to %s changed size between renders. The ' +
'order and size of this array must remain constant.\n\n' +
'Previous: %s\n' +
'Incoming: %s',
currentHookNameInDev,
`[${prevDeps.join(', ')}]`,
`[${nextDeps.join(', ')}]`,
);
}
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
三条值得读的细节:
1、HMR 路径:ignorePreviousDependencies 为 true 时直接返回 false。Hot Module Replacement 重新加载组件时,强制让所有 memo/effect 重新运行——因为依赖数组里可能引用了被 HMR 替换过的函数引用。这条 HMR 特殊路径在教学版里几乎从不提及,但它是 Fast Refresh 能保 state 的关键支撑之一(参见第 5 章 §5.5.3)。
2、“缺失依赖数组”警告:上次有 deps、这次没有(或反之)→ DEV 警告 “its type cannot change between renders.”。这条警告对应用户意外地在带 deps 和无 deps 之间切换的场景——语义完全不同(前者受控、后者每次 render 都 fire),React 把这个危险模式显式指出。
3、“数组长度变化”警告:一个 useMemo 的依赖数组从 [a, b] 变成 [a]——React 在 DEV 警告并在最大公共前缀上继续比较。prod 不检查长度是性能优化——React 团队假设你在开发时发现并修复了长度变化问题,prod 跑时不重复校验。
从这套警告体系能推断出一条 React 的 DX 哲学:开发时严格到近乎苛刻、生产时完全不收 CPU 税。这条原则贯穿 React 的每一个 DEV-only 分支。__DEV__ 在生产 bundle 里会被静态替换成 false、整个分支被 dead-code elimination 掉——零运行时代价。
7.7.2 源码核对:shouldDoubleInvokeUserFnsInHooksDEV 的双调用
mountMemo/updateMemo(ReactFiberHooks.js:2259-2293)里出现了一个看似奇怪的模式:
if (shouldDoubleInvokeUserFnsInHooksDEV) {
nextCreate();
}
const nextValue = nextCreate();
当 StrictMode 开启时,React 会故意调用 useMemo 的 create 函数两次——只保留第二次的结果。这是 StrictMode 故意暴露副作用的一种扩展:如果你的 useMemo create 函数有副作用(比如修改外部变量),连续两次调用会让副作用被观察到一次。
这条机制和 StrictMode 下组件函数被双重调用、effect 的 mount/unmount/mount 三段循环是同一条防御性哲学——让一切应该幂等的代码被强制测试幂等性。React 19 强化了这个机制(useMemo 也加入双调用)——因为 Concurrent Mode 下一个 render 可能被丢弃、那时用户的副作用就会被”白调用一次”——提前在 StrictMode 暴露这个可能。
这条机制的实际观察:console.log('memo compute') 在 StrictMode 里打印两次是正常的——这不是 bug,是 React 故意的测试。生产环境只调用一次。
7.8 useContext:没有 Hook 链表的 Hook
useContext 是一个特殊的 Hook——它不使用 Hook 链表来存储状态。它的实现直接读取 Context 的当前值:
function readContext<T>(context: ReactContext<T>): T {
const value = context._currentValue;
// 建立 Fiber 到 Context 的依赖关系
const contextItem: ContextDependency<T> = {
context,
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
currentlyRenderingFiber.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
lastContextDependency = contextItem;
} else {
lastContextDependency = lastContextDependency.next = contextItem;
}
return value;
}
当 Context Provider 的值发生变化时,React 会遍历 Provider 的子树,找到所有依赖该 Context 的 Fiber 节点,并为它们标记更新:
function propagateContextChange<T>(
workInProgress: Fiber,
context: ReactContext<T>,
renderLanes: Lanes
) {
let fiber = workInProgress.child;
while (fiber !== null) {
const list = fiber.dependencies;
if (list !== null) {
let dependency = list.firstContext;
while (dependency !== null) {
if (dependency.context === context) {
// 找到了依赖此 Context 的 Fiber
if (fiber.tag === ClassComponent) {
// 为 Class 组件创建一个强制更新
const update = createUpdate(renderLanes);
update.tag = ForceUpdate;
enqueueUpdate(fiber, update);
}
// 标记更新 lane
fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
// 从当前节点一路向上标记 childLanes
scheduleContextWorkOnParentPath(fiber.return, renderLanes);
break;
}
dependency = dependency.next;
}
}
fiber = fiber.child ?? fiber.sibling ?? /* 向上回溯找兄弟 */;
}
}
7.8.1 useContext 的性能陷阱
useContext 的一个重要特性(也是常见的性能陷阱)是:只要 Provider 的 value 变化,所有消费该 Context 的组件都会重渲染,即使组件只使用了 value 中的一小部分。
// ❌ 性能问题:整个 value 对象每次渲染都是新引用
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState('light');
// 每次渲染都创建新对象 → 所有消费者都重渲染
const value = { user, setUser, theme, setTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// ✅ 拆分 Context 或 memoize value
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState('light');
// 方案1:memoize value
const value = useMemo(
() => ({ user, setUser, theme, setTheme }),
[user, theme]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// 方案2:拆分为独立的 Context
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
7.8.1 源码核对:renderWithHooks 的 Dispatcher 选择五分支
§7.2 展示了 Dispatcher 机制的两张面孔(Mount / Update)。真实 renderWithHooks(ReactFiberHooks.js:488-582)里的选择逻辑有 5 个分支——Mount/Update 各有 DEV 变种,以及一个专门处理边角情况的 HooksDispatcherOnMountWithHookTypesInDEV:
if (__DEV__) {
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// 上次 render 用过 Hook 但这次 current.memoizedState === null
// 常发生在 "上次只用了 context"(不上链表)的 update 场景
// 我们要走 Mount 的生产行为、但保留 DEV 的 Hook 顺序校验
ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
}
} else {
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}
这里有一个深埋的设计难题:current.memoizedState === null 不一定代表”这是第一次 mount”。考虑一个只用 useContext 的组件——useContext 不上 Hook 链表、不写 memoizedState——第二次 render 时 current.memoizedState 仍然是 null。如果直接按 “null 等于 mount” 判断,React 会给这个 update 用 Mount Dispatcher——Hook 顺序校验会错乱。
React 的解法:在 DEV 里维护一个 hookTypesDev 数组——记录上次 render 每个 Hook 调用的类型。如果 hookTypesDev !== null 但 current.memoizedState === null——说明是”上次只调了 non-stateful Hook”的 update——走 HooksDispatcherOnMountWithHookTypesInDEV,它行为上等同于 Mount Dispatcher、但额外校验 Hook 类型与上次一致。
这个边角 case 非常小众——用户几乎不会刻意触发它——但 React 在源码里写了完整处理路径 + 详细注释。这是一个框架在”正确性覆盖率”上的工程投入——把概率 < 1% 的场景也兜住,让 Hook 规则的校验在所有路径下都严格执行。
7.8.2 源码核对:Fast Refresh 通过 ignorePreviousDependencies 跨越 Hook 规则
renderWithHooks 的第 505-508 行有这么一段:
// Used for hot reloading:
ignorePreviousDependencies =
current !== null && current.type !== workInProgress.type;
当 HMR 检测到 current.type !== workInProgress.type(类型变了)时,设置一个全局 flag 让 areHookInputsEqual 永远返回 false——让所有 useMemo/useEffect/useCallback 的 deps 比较失效、强制重新运行。
为什么要这么做?因为 HMR 替换了组件源码——用户在 useEffect 里引用的函数、在 useMemo 里计算的值,引用身份都变了,但依赖数组里的原始值可能没变(比如 [count] 还是 [count])。如果按正常 deps 比较,React 会认为”不需要重跑”——但实际上 effect 函数已经是新版本、不跑它 HMR 的意义就没了。
ignorePreviousDependencies 的存在让HMR 下所有 memo/effect 强制失效——与 §5.5.3 讲过的 isCompatibleFamilyForHotReloading(保 state)配对——state 保留、memo/effect 重跑——这是 Fast Refresh 的完整语义。
这条机制在普通 debug 时几乎不会被观察到——只在你真正修改组件源码、HMR 触发时生效。但理解它存在能帮你回答一些 “为什么我改代码后 useMemo 立刻重新计算了” 的困惑——答案不是 “React 识别到 deps 变了”,而是 “React 知道你改代码了、故意让所有 deps 比较失效”。
7.8.3 源码核对:didScheduleRenderPhaseUpdateDuringThisPass 的循环重入
renderWithHooks 第 586 行之后有一段关键循环:
let children = Component(props, secondArg);
// Check if there was a render phase update
if (didScheduleRenderPhaseUpdateDuringThisPass) {
// Keep rendering until the component stabilizes (there are no more render
// phase updates) or we hit the limit. (The original intent was to prevent
// infinite loops in case there's a bug.)
...
}
Render phase update 发生时(用户在 render 里 setState),React 不会立刻走完 render、而是重跑组件函数直到 state 稳定——最多重跑 25 次(React 的硬编码上限)——防无限循环。
这条机制支持的一个合法模式:从 prop 派生 state。官方不推荐但有时候必须:
function MyInput({ defaultValue }) {
const [value, setValue] = useState(defaultValue);
const [lastDefault, setLastDefault] = useState(defaultValue);
if (defaultValue !== lastDefault) {
// 在 render 里 setState——React 会重跑这个组件一次
setLastDefault(defaultValue);
setValue(defaultValue);
}
// ...
}
这段代码在 render 里调 setState——React 的 render phase update 机制会识别并重跑组件。重跑时 lastDefault 已经是新值、if 不再命中——组件稳定输出。这比 useEffect + setState 少一次 commit cycle——性能上肉眼可见的差别。
25 次上限是 React 的兜底。如果你无限触发 render phase update(比如 setFoo(x => x + 1) 总是改变)——React 会抛 “Too many re-renders.” 错误。这条上限是”无限循环检测”的硬防御。
7.9 Hook 规则的技术根因
React 的官方文档说”只在顶层调用 Hook,不要在循环、条件或嵌套函数中调用”。这不是一个任意的约定,而是由 Hook 的底层实现决定的硬性限制。
核心问题在于:React 通过调用顺序来匹配 mount 阶段和 update 阶段的 Hook。updateWorkInProgressHook 的实现就是简单地将指针移动到链表的下一个节点:
function updateWorkInProgressHook(): Hook {
// 从 current 树的 Hook 链表中获取下一个 Hook
let nextCurrentHook: Hook | null;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
nextCurrentHook = current !== null ? current.memoizedState : null;
} else {
nextCurrentHook = currentHook.next;
}
currentHook = nextCurrentHook;
// 复用或创建对应的 workInProgress Hook
const newHook: Hook = {
memoizedState: currentHook!.memoizedState,
baseState: currentHook!.baseState,
baseQueue: currentHook!.baseQueue,
queue: currentHook!.queue,
next: null,
};
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = newHook;
workInProgressHook = newHook;
} else {
workInProgressHook.next = newHook;
workInProgressHook = newHook;
}
return workInProgressHook;
}
如果在条件语句中调用 Hook,调用顺序可能在不同渲染之间发生变化:
// ❌ 这段代码会导致 Hook 错位
function BuggyComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
const [name, setName] = useState('Guest'); // Hook 1: useState
if (isLoggedIn) {
useEffect(() => { /* ... */ }); // Hook 2: useEffect(条件性的!)
}
const [count, setCount] = useState(0); // Hook 3: useState
// 当 isLoggedIn 从 true 变为 false 时:
// Mount: Hook1=useState, Hook2=useEffect, Hook3=useState
// Update: Hook1=useState, Hook2=useState(!!!) ← 错位了!
// React 会尝试将 useState 的 update 结构应用到 useEffect 上
// 结果:运行时错误或状态混乱
}
graph TD
subgraph "Mount(isLoggedIn=true)"
M1["Hook 1: useState('Guest')"]
M2["Hook 2: useEffect"]
M3["Hook 3: useState(0)"]
M1 -->|next| M2
M2 -->|next| M3
end
subgraph "Update(isLoggedIn=false)"
U1["Hook 1: useState ✅"]
U2["Hook 2: useState ❌<br/>期望 useEffect<br/>实际得到 useState"]
U1 -->|next| U2
style U2 fill:#FF6B6B
end
图 7-3:条件调用 Hook 导致的链表错位
7.10 useId:服务端与客户端的一致性
useId 是 React 18 引入的一个看似简单但设计精妙的 Hook,它解决了 SSR 场景下的 ID 一致性问题:
function mountId(): string {
const hook = mountWorkInProgressHook();
const root = getWorkInProgressRoot();
const identifierPrefix = root.identifierPrefix; // 可配置的前缀
let id: string;
if (getIsHydrating()) {
// SSR hydration:从树结构生成确定性 ID
const treeId = getTreeId();
id = ':' + identifierPrefix + 'R' + treeId;
} else {
// 客户端渲染:使用自增计数器
const globalClientId = globalClientIdCounter++;
id = ':' + identifierPrefix + 'r' + globalClientId.toString(32);
}
hook.memoizedState = id;
return id;
}
useId 生成的 ID 有一个特殊格式(如 :R1:、:r0:),确保不会与用户自定义的 ID 冲突。在 SSR 场景下,它基于组件在树中的位置生成确定性的 ID,保证服务端和客户端渲染出相同的值。
7.11 Hook 的调试与开发者体验
在开发模式下,React 会做额外的检查来帮助开发者发现 Hook 使用错误:
// 开发模式下的 Hook 调用检查
if (__DEV__) {
// 记录每次渲染中 Hook 的调用顺序
const hookTypesDev: Array<HookType> = [];
function mountHookTypesDev() {
// mount 阶段记录调用顺序
hookTypesDev.push('useState'); // 或 'useEffect' 等
}
function updateHookTypesDev() {
// update 阶段验证调用顺序是否一致
const hookName = hookTypesDev[hookTypesUpdateIndexDev++];
if (hookName !== currentHookNameInDev) {
console.error(
'React has detected a change in the order of Hooks called by %s. ' +
'This will lead to bugs and errors if not fixed. ' +
'For more information, read the Rules of Hooks: ...',
currentComponentName
);
}
}
}
React 还利用 Dispatcher 切换来检测组件外部的 Hook 调用:当函数组件执行完毕后,Dispatcher 会被切换为 InvalidHooksDispatcher,此后任何 Hook 调用都会抛出清晰的错误信息。
7.10.1 源码核对:mountId 的 useId 实现 + getTreeId 的 2 进制字符串
§7.10 给了 useId 的高层原理,没有展开服务端和客户端如何生成一致 ID的具体机制。真实的 mountId(ReactFiberHooks.js 附近,随不同 React 版本变化)依赖两个配对机制:
1、TreeId——每个 Fiber 在树中有一个唯一的路径 ID,基于父 Fiber 和在兄弟节点中的位置。想象成一个二进制字符串——根节点是空,子节点根据”在父节点的第几个 child”编码。两个不同的 Fiber 有不同的 TreeId;SSR 和 Client 只要树结构一致,对应位置的 Fiber 的 TreeId 也一致。
2、localIdCounter——每个组件内部调的 useId 在同一个 Fiber 里依次拿 0, 1, 2…。第 N 个 useId 拿到 counter N。
useId 的最终 ID 是 TreeId + localIdCounter 的组合。服务端和客户端只要走完全相同的 render 路径、调相同数量的 useId,就能产出完全相同的 ID。这让 <label htmlFor={id}> 和 <input id={id}> 在 SSR 与 hydration 后都指向同一个 DOM 节点,不会出现 “SSR 和 CSR ID 不一致导致 hydration 警告” 的问题。
这套机制背后的一条假设:SSR 和 CSR 的 render 输出完全一致——任何一方多调一个 useId、多 render 一层 Fiber、走不同分支——ID 都会错乱。这也是为什么 React 推荐用户”在组件里用 useId、不要自己 Math.random”——只要相信框架、框架就能保证一致性;自己生成 ID 就得自己承担 SSR/CSR 对齐的责任。
这个”合作保证一致性”的设计模式在 React 的 SSR 架构里反复出现——框架和用户代码约定好一套规则,双方都遵守就能得到免费的跨端一致性。useId 只是一个具体例子。
7.11.0 源码定位索引
为便于读者按图索骥核实本章说法,列出引用的源码锚点:
| 小节 | 源文件 | 函数 / 行号 |
|---|---|---|
| §7.1-2 | ReactFiberHooks.js | 整体 |
| §7.3-7.3.1 | 同上 | mountWorkInProgressHook:940-959 |
| §7.3.2-3 | 同上 | updateReducer / dispatchSetState:2748 |
| §7.3.4 | 同上 | 环形链表 enqueueUpdate |
| §7.3.4.1 | 同上 | dispatchSetState 完整:2748-2827 / InvalidNestedHooksDispatcherOnUpdateInDEV |
| §7.3.5 | 同上 | updateWorkInProgressHook:961-1030 |
| §7.3.6 | 同上 | mountStateImpl:1721-1737 |
| §7.4 | 同上 | mountReducer:1165 / updateReducerImpl:1203 |
| §7.5-5.3 | 同上 | mountEffectImpl / updateEffectImpl |
| §7.5.4 | 同上 | pushEffect:1838-1870 |
| §7.6-6.2 | 同上 | mountRef:1893-1959 / updateRef:1962-1965 |
| §7.7-7.2 | 同上 | mountMemo:2259 / updateMemo:2273 / areHookInputsEqual:439-486 / shouldDoubleInvokeUserFnsInHooksDEV |
| §7.8 | 同上 | useContext(readContext 转发) |
| §7.8.1 | 同上 | renderWithHooks:488-548 Dispatcher 选择 |
| §7.8.2 | 同上 | ignorePreviousDependencies:506 |
| §7.8.3 | 同上 | didScheduleRenderPhaseUpdateDuringThisPass:586 |
| §7.9 | 同上 | Hook 规则(综合性讨论) |
| §7.10 | 同上 | useId 实现 |
源码版本:opensource/react commit 546fe4681。行号在更新版本后可能偏移,但函数名和数据结构应稳定。
7.11.1 本章与全书体系的呼应
Hooks 是 React 最小但最核心的 API 面——理解了本章,相当于理解了 React 函数组件时代的整套范式。但这一章不是孤立的知识点——它和本书其他章节的接合极其密集,梳理一下能让整个 React 内核的逻辑链变得透明:
与第 3 章(Fiber 架构)的绑定:Hook 链表挂在 Fiber.memoizedState 上——Hook 的生命周期跟 Fiber 一一对应。Fiber 复用时(§5.5)Hook 链表跟着复用;Fiber 重建时(type 变化)Hook 链表整条丢弃。本章反复出现的 “mountWorkInProgressHook / updateWorkInProgressHook” 实际就是”给当前正在构建的 WIP Fiber 添加/克隆 Hook”——WIP Fiber 是 Hook 链表的容器,Hook 链表是 WIP Fiber 的 payload。
与第 5 章(Reconciliation)的延伸:§5.5.3 讲过”Fiber 复用条件”——elementType 相同、HMR 兼容、Lazy resolve 匹配。本章 §7.8.2 补了第四条:HMR 时 ignorePreviousDependencies 让 memo 重跑——这条让 state 保留、memo 重算的行为组合是 Fast Refresh 能工作的完整拼图。
与第 6 章(Commit)的交接:本章 §7.5.4 讲过 effect 挂载到 Fiber.updateQueue;第 6 章讲 commit 阶段如何遍历这条 updateQueue 调 effect.destroy → effect.create。Effect 的完整生命周期是 renderWithHooks 阶段 push 到队列 + commit 阶段按优先级遍历执行——两章合起来才是完整故事。
与第 8 章(React 19 新 Hooks)的过渡:本章讲的 useState/useReducer/useMemo/useCallback/useRef/useContext 是 React 16.8 的基石;第 8 章讲的 use/useActionState/useFormStatus/useOptimistic 是 React 19 的新成员。后者的实现都复用了本章的基础设施——useOptimistic 复用 updateReducerImpl 的 revertLane 分支、use 走独立的 thenableState 但绕不开 Hook 链表索引机制——新 Hook 是旧 Hook 基础设施的重新组合,不是另起炉灶。
与第 9 章(并发模式)的互补:本章讲 Hook 的数据结构和 dispatch 路径;第 9 章讲 Hook 的优先级调度(Lane 模型)和中断恢复。Eager State 优化(§7.3.3)之所以能 bail out、render phase update(§7.8.3)之所以最多 25 次重跑、dispatchSetState 里 entangleTransitionUpdate 的调用——这些都在 Lane 的优先级框架里被精确定义。
这套”Hook 基础设施 + Fiber 容器 + Commit 执行 + Lane 调度”四层架构是 React 函数组件能运行的最小可行组合——砍掉任何一层整个系统就崩。读完本章再读第 8/9 章时,你会发现React 19 的所有 “新” 特性实际上是这四层的重新组合——没有新层、只有新组合。这种架构上的连续性是 React 作为一个演进了 8 年的大型前端框架最可贵的财富。
7.11.3 几个常见误区的源码级厘清
本章接近尾声时,再澄清几个业界流传但源码层面并不准确的说法:
误区一:useMemo 总是免费的缓存。真相是 useMemo 本身有开销——调 updateWorkInProgressHook 克隆 Hook、areHookInputsEqual 遍历 deps 数组做 Object.is 比较、返回缓存值。小型计算(x * 2)加 useMemo 反而比直接计算慢,因为 Hook 开销远大于运算。React 官方 docs 也明确说过:“Do not try to preemptively add useMemo or memoization.”——只对昂贵计算用 useMemo,默认不加。
误区二:useCallback 让组件更快。真相是 useCallback 只在子组件用 React.memo 比较 props 时才有意义——否则 callback 引用的稳定性谁都不看。如果你的 callback 传给一个普通子组件(没 memo)或用在 useEffect 依赖里(而 effect 本来就应该跑),useCallback 什么都没优化、只增加了开销。
误区三:useRef 可以做 state 用。真相是 ref 的修改不触发 re-render——用 ref 存”当前 count”然后在 render 里读 countRef.current,UI 永远显示初始值。ref 适合存”render 之外访问的可变数据”——定时器 ID、socket 实例、上次的 props——不适合替代 state。
误区四:useContext 是性能杀手。真相是context 本身不慢——它的实现(readContext)只是一次 O(1) 查值。慢的是 context value 的引用变化导致所有消费者 re-render——和 context 机制无关,是引用稳定性问题。正确的解决方案是 useMemo 稳定 value、或把 state 和 dispatch 分成两个 context(消费 dispatch 的组件不会因 state 变而 re-render)。
误区五:函数组件比 class 组件快。真相是两者 render 性能相当——Fiber 架构下都是走同一个 reconciler 路径。函数组件的优势在代码组织(hooks 复用逻辑)而非性能。如果你听过”function 组件更快”,那是 React 15 时代的老数据——16.8 之后两者等价。
这五条误区在业界被反复复述、写进博客、甚至进了面试题库——但从本章讲的源码角度看,它们都站不住脚。读过源码的程序员在面试时能反驳这些误区,这是”会用”和”精通”的一条可见分界。
7.11.2 读完本章能回答的具体问题清单
作为本章的自测表,下列 13 个问题的答案都在本章的源码锚点里——如果你能不回源码说出原理,你对 Hooks 内核的掌握已经是”能教别人”级别:
- 为什么 Hook 不能在 if 里调?(§7.9——Hook 链表按顺序索引,多调少调都会让 updateWorkInProgressHook 抛 “Rendered more hooks”)
useState(expensive())和useState(() => expensive())差在哪?(§7.3.6——前者每次 render 都算、后者只算一次)setCount(1); setCount(1);会触发几次 render?(§7.3.3——一次,Eager State 识别新旧值相同并 bail out)- reducer 里能调 Hook 吗?(§7.3.4.1——不能,React 在 dispatch 时切 Dispatcher 到 Invalid,调 Hook 会立刻报错)
- 为什么 StrictMode 下 useMemo 的 create 函数执行两次?(§7.7.2——shouldDoubleInvokeUserFnsInHooksDEV 故意调用两次测试幂等)
- HMR 保 state 但 useMemo 重算是为什么?(§7.8.2——ignorePreviousDependencies 让 areHookInputsEqual 永远返回 false)
useRef(newValue)第二次传会把 current 改成 newValue 吗?(§7.6.2——不会,updateRef 忽略参数)- 什么是 “Rendered more hooks than during the previous render.” 错误的根因?(§7.3.5——nextCurrentHook === null 但 WIP 需要继续走)
- setState(newValue, callback) 为什么没效果?(§7.3.4.1——React 函数式 setState 不支持 callback,DEV 下会有明确警告)
- render 里 setState 会无限循环吗?(§7.8.3——React 最多重跑 25 次,超过抛 “Too many re-renders.”)
useEffect和useLayoutEffect源码层差异是什么?(§7.5.2——只差 flags:PassiveEffect vs UpdateEffect)- useReducer 和 useState 源码层的本质差异?(§7.4 + §7.4.1——useReducer 不走 Eager State 优化)
- useId 如何保证 SSR 和 CSR 一致?(§7.10.1——TreeId + localIdCounter 的组合,依赖两端 render 路径一致)
如果这 13 个问题你都能回答——你对 React Hooks 内核的理解已经超越 99% 的 React 使用者。Hooks 不是一组孤立 API,而是一套由链表、队列、Dispatcher、lane 和 effect flags 共同维持的状态机器;真正掌握它,意味着你能从一次业务异常倒推出底层状态迁移。
实操建议:把 ReactFiberHooks.js 这 4000+ 行源码在 VSCode 里打开、跟着本章提到的每一个函数跳转一遍,会让这些源码锚点在你脑海里形成立体的地图——以后写业务代码时,看到 useState、useEffect、useMemo,你会下意识地知道”这行背后 React 在做什么、开销是什么、有没有可能优化”。这种预判力就是从”会用 Hook”跃迁到”精通 Hook”的分水岭。
最后提一条读 React 源码的抓手:Hook 文件里很多代码看似重复(mount/update 两版、sync/concurrent 两版、DEV/prod 两版)——不要被表面吓退。抓两件事:数据结构演进(memoizedState / queue / baseQueue 在每个函数里的状态)和 Dispatcher 切换时机(§7.8.1 的 5 个分支)。理解这两条线,整个 Hook 系统的逻辑就串起来了。其余细节是层层叠加的边界保护——读的时候知道它们在干什么、不需要死记。
7.12 本章小结
Hooks 的设计是 React 中最精巧的工程之一。它用一条简单的单向链表和一个基于调用顺序的索引机制,为函数组件带来了完整的状态管理和副作用处理能力。
关键要点:
- Hook 链表是 Hooks 系统的基础:每个 Hook 对应一个 Hook 对象,通过
next指针串成链表,挂在 Fiber 的memoizedState上 - Dispatcher 切换实现了同一 API 的不同行为:mount 阶段创建链表,update 阶段遍历链表
- useState 就是 useReducer:内部使用
basicStateReducer,两者共享更新队列和处理逻辑 - Eager State 是重要的性能优化:在没有其他待处理更新时,可以直接计算新状态并跳过调度
- useEffect 和 useLayoutEffect 的差异只在 flags:不同的 flags 决定了在 Commit 阶段的不同子阶段执行
- Hook 规则不是约定,是硬限制:调用顺序必须一致,否则链表会错位
- useRef 是最纯粹的”容器”:不参与任何更新机制,修改
current完全是普通的 JS 操作
在下一章中,我们将探索 React 19 引入的新 Hooks 和 API——use、useFormStatus、useOptimistic 等,看看它们如何在这个 Hook 体系之上构建出更强大的能力。
思考题
-
为什么 React 选择单向链表而不是数组来存储 Hook? 考虑在并发模式下,同一个组件可能需要同时维护多个”进行中”的 Hook 状态。链表和数组在这种场景下的表现有何差异?
-
useState的 Eager State 优化有一个限制条件:当前 Fiber 没有其他待处理的更新。 为什么需要这个条件?构造一个反例,说明在有待处理更新时进行 Eager State 计算可能得到错误的结果。 -
useRef不会触发重渲染,那forwardRef+useImperativeHandle是如何将子组件的方法暴露给父组件的? 追踪useImperativeHandle的源码实现,它本质上使用了哪个 Hook? -
在一个使用了 3 个
useContext的组件中,如果其中一个 Context 的值发生了变化,另外两个 Context 的值是否会被重新读取? 从propagateContextChange和组件重渲染的流程来分析。