考点 2.1:Hooks 链表结构(memoizedState)
第 0 段:直觉锚定
想象你在组装一条手链。每个珠子就是一个 Hook 调用(useState、useEffect、useMemo......),珠子之间用线串起来,形成一条单向链表 。手链的头 挂在组件的 Fiber 节点上(fiber.memoizedState),尾 的 next 指向 null。
关键规则是:串珠子的顺序永远不能变 。第一次渲染串了 5 颗珠子(state → effect → ref → memo → callback),那之后每次重新渲染,必须按完全相同的顺序 再串一遍。React 不记"第 N 个珠子叫什么名字",它只认位置------"第 2 颗珠子就是上次第 2 颗珠子的接班人"。如果你某次渲染把第 2 颗珠子跳过了,后面所有珠子都会对错位,这就是 React 报错 "Rendered more hooks than during the previous render" 的根因。
第 1 段:问题背景
函数组件没有 this,状态存在哪?
Class 组件有 this.state、this.props,天然可以存储状态。函数组件就是个纯函数,每次调用都是全新的局部变量,没有任何地方可以跨渲染保持数据。
React 需要解决两个问题:
- 存储:函数组件的状态存放在哪里?
- 寻址:每次渲染时,第 N 次 Hook 调用怎么找到上次对应的那个状态?
答案:存在 Fiber 上,用链表顺序寻址
React 把每个 Hook 的状态封装成一个 Hook 对象 ,挂在对应 Fiber 节点的 memoizedState 字段上,通过 next 指针串成链表。
为什么要用链表而不是数组? 因为链表天然表达"一个接一个"的顺序语义,next 指针的遍历过程和 Hook 调用顺序完全一致。React 不需要额外维护索引计数器------每次调用 Hook 函数时,内部就顺着 next 往前走一格。用数组当然也能实现,但链表在克隆/复用时更方便(更新时逐节点从 current 树克隆,无需整体拷贝)。
第 2 段:核心数据结构
Hook 对象的完整结构
源码位于 ReactFiberHooks.js:194:
typescript
Hook {
memoizedState: any // 当前 Hook 的"记忆值"(不同 Hook 类型存不同东西)
baseState: any // 更新计算的基础状态(用于 useState/useReducer 的计算)
baseQueue: Update | null // 尚未处理完的更新链表(优先级被跳过的更新暂存处)
queue: UpdateQueue | null // 更新队列(dispatch 触发的 action 都排在这里)
next: Hook | null // 指向下一个 Hook
}
memoizedState 在不同 Hook 中存什么
| Hook 类型 | memoizedState 存储内容 | 源码位置 |
|---|---|---|
useState |
当前 state 值(如 0、"hello") |
ReactFiberHooks.js:1910 |
useReducer |
当前 state 值 | 同上(共用 mountStateImpl 逻辑) |
useEffect |
Effect 对象 {tag, inst, create, deps, next} |
ReactFiberHooks.js:2623 |
useMemo |
[cachedValue, depsArray] |
ReactFiberHooks.js:2931 |
useCallback |
[callbackFn, depsArray] |
ReactFiberHooks.js:2898 |
useRef |
{current: initialValue} |
ReactFiberHooks.js:2604 |
注意: memoizedState 这个名字在 Fiber 节点层面和 Hook 层面含义不同:
fiber.memoizedState→ 对于函数组件,指向 Hook 链表的头节点hook.memoizedState→ 这个 Hook 自己存储的值
链表在 Fiber 上的位置
scss
FiberNode (函数组件)
├── memoizedState ──→ [Hook#1: useState] ──→ [Hook#2: useEffect] ──→ [Hook#3: useRef] ──→ null
│ ↑ 链表头 ↑ 链表尾
├── updateQueue ──→ FunctionComponentUpdateQueue { lastEffect, events, stores, ... }
│ ↑ 注意:effect 链表的头尾由 updateQueue.lastEffect 管理,
│ 不在 Hook 链表本身上
└── ...
一个容易混淆的点: useEffect 的 Hook 对象中 memoizedState 存的是单个 Effect 对象。但组件可能有多个 effect,它们通过 Effect.next 串成环形链表 ,由 fiber.updateQueue.lastEffect 指向尾部(即尾部.next 回到头部)。这和 Hook 链表是两条不同的链表------Hook 链表管理"第几个 Hook",Effect 链表管理"哪些 Effect 需要执行"。
第 3 段:运行流程
首次渲染(mount)------链表的构建
入口: renderWithHooks()(ReactFiberHooks.js:502)
ini
// 每次渲染前,先把工作区的 Hook 链表清空
workInProgress.memoizedState = null; // ← 第 526 行
workInProgress.updateQueue = null;
// 根据 current 树的状态,选择 mount 或 update 的 dispatcher
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 首次渲染,用 mount 版 Hook
: HooksDispatcherOnUpdate; // 更新渲染,用 update 版 Hook
然后组件函数开始执行。假设组件是:
scss
function App() {
const [count, setCount] = useState(0); // Hook#1
const [name, setName] = useState("React"); // Hook#2
useEffect(() => { /* ... */ }, [count]); // Hook#3
return <div>{count}</div>;
}
每次调用 Hook 函数时 ,内部都会调用 mountWorkInProgressHook()(第 979 行):
csharp
function mountWorkInProgressHook(): Hook {
const hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 第一个 Hook:链表头挂在 fiber.memoizedState 上
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 后续 Hook:追加到链表尾部
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
构建过程:
ini
调用 useState(0):
workInProgressHook === null
→ fiber.memoizedState = hook1
→ hook1.memoizedState = 0, hook1.next = null
调用 useState("React"):
workInProgressHook === hook1
→ hook1.next = hook2
→ hook2.memoizedState = "React", hook2.next = null
调用 useEffect(...):
workInProgressHook === hook2
→ hook2.next = hook3
→ hook3.memoizedState = Effect{...}, hook3.next = null
最终结果:
fiber.memoizedState → hook1 → hook2 → hook3 → null
更新渲染(update)------链表的克隆
更新时调用的是 updateWorkInProgressHook()(第 1000 行)。它的逻辑是从 current 树的 Hook 链表逐个克隆到 workInProgress 树:
ini
function updateWorkInProgressHook(): Hook {
// 1. 从 current 树找到对应的 Hook
if (currentHook === null) {
nextCurrentHook = currentlyRenderingFiber.alternate.memoizedState; // current 树链表头
} else {
nextCurrentHook = currentHook.next; // 往前走一格
}
// 2. 克隆 current Hook 的数据,创建新的 Hook 节点
const newHook = {
memoizedState: currentHook.memoizedState, // 复用上次的值
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
// 3. 挂到 workInProgress 的链表上
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
currentHook = nextCurrentHook;
return workInProgressHook;
}
关键洞察: 更新时 React 不是原地修改 Hook 链表,而是逐个克隆。这样 workInProgress 树拥有自己的 Hook 链表副本,渲染中途即使被中断,current 树的链表也不会被破坏。
错误检测:Hook 数量不匹配
源码第 1044 行:
javascript
if (nextCurrentHook === null) {
throw new Error('Rendered more hooks than during the previous render.');
}
如果这次渲染调用的 Hook 数量比上次多,currentHook.next 已经是 null 了,再调用 Hook 就会走进这个分支------这就是 React 检测 Hook 规则违规的机制。
第 4 段:设计动机与权衡
为什么不用数组而用链表
| 维度 | 数组 | 链表(React 选择的) |
|---|---|---|
| 随机访问 | O(1) | O(n) |
| 顺序遍历 | O(n),需要索引计数器 | O(n),next 指针自然推进 |
| 克隆单个节点 | 需要拷贝整个数组或用 index 映射 | 只克隆当前节点,O(1) |
| 内存布局 | 连续内存,缓存友好 | 分散在堆上,缓存不友好 |
React 选择链表的原因:
- 克隆更高效:更新时逐个克隆 current Hook,不需要一次性拷贝整个数组
- 不需要索引 :遍历就是
hook = hook.next,比维护hooks[index]的心智模型更简单 - Hook 数量不固定:不同组件的 Hook 数量不同,链表天然适应任意长度
为什么"不能在条件语句里用 Hook"
这直接源于链表顺序寻址的设计。假设:
scss
function App({ show }) {
const [a, setA] = useState(0); // Hook#1
if (show) {
const [b, setB] = useState(1); // Hook#2(条件性)
}
const [c, setC] = useState(2); // Hook#3 或 Hook#2
}
第一次渲染 show=true:链表是 a → b → c → null(3 个节点) 第二次渲染 show=false:链表变成 a → c → null(2 个节点)
但 updateWorkInProgressHook 从 current 树取 currentHook.next 时,期望拿到的是 b 的数据,实际拿到的却是 c------链表对位错乱,React 会报错或者用错数据。
Hook 链表与 Fiber 双缓存的关系
回顾考点 1.3 的双缓冲机制:React 维护两棵 Fiber 树(current 和 workInProgress)。Hook 链表也是双份的:
current.memoizedState→ 上一次渲染的 Hook 链表workInProgress.memoizedState→ 本次渲染正在构建的 Hook 链表
更新时,updateWorkInProgressHook 从 current 链表逐个克隆到 workInProgress 链表。如果渲染中途被中断,workInProgress 的半成品链表直接丢弃,current 的链表完好无损。下次恢复时从头重新克隆。
第 5 段:次级误解和边界
误解 1:"fiber.memoizedState 和 hook.memoizedState 是同一个东西"
不是。 它们虽然同名,但作用层级不同:
fiber.memoizedState→ 对函数组件来说,是 Hook 链表的头指针hook.memoizedState→ 单个 Hook 存储的具体值
对 class 组件来说,fiber.memoizedState 存的是 this.state,跟 Hook 没有任何关系。
误解 2:"每次渲染的 Hook 对象都是新建的"
mount 时是新建的,update 时是克隆的。 看 updateWorkInProgressHook 第 1050 行:
yaml
const newHook = {
memoizedState: currentHook.memoizedState, // 浅拷贝
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue, // ← queue 是引用共享,不是拷贝!
next: null,
};
注意 queue 是引用共享 的------current 和 workInProgress 的同一个 Hook 共用同一个更新队列。这是因为 dispatch 函数(setState)在 bind 时绑定了这个 queue,如果 clone 了 queue,dispatch 就找不到新 action 了。
边界:组件没有任何 Hook 会怎样?
源码第 543-544 行的注释已经解释了:
arduino
// 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
如果一个函数组件只用了 useContext(不会创建 Hook 节点)而没用任何 stateful Hook,那 fiber.memoizedState 始终为 null。React 通过检查 current.memoizedState === null 来判断 mount/update 就会失效------它会误以为每次都是首次渲染。这是 React 的一个已知边界情况。
边界:useEffect 的 Effect 链表和 Hook 链表是两条独立的链表
这是一个高频混淆点:
less
Hook 链表(管理"第几个 Hook"):
fiber.memoizedState → [useState hook] → [useEffect hook] → null
Effect 链表(管理"哪些 effect 需要执行"):
fiber.updateQueue.lastEffect → Effect#3 ⇄ Effect#1 ⇄ Effect#2 ⇄ (回到 Effect#3)
↑ 环形链表,通过 lastEffect.next 找到头节点
每个 useEffect Hook 的 memoizedState 指向一个 Effect 对象,这些 Effect 对象通过自己的 next 指针形成环形链表,挂在 fiber.updateQueue.lastEffect 上。在 commit 阶段,React 遍历的是 Effect 环形链表,不是 Hook 链表。
现在我们知道了函数组件的所有 Hook 状态以链表形式挂在 fiber.memoizedState 上,通过调用顺序寻址 。但 useState 返回的 setState(也就是 dispatch)是怎么做到"调用后触发更新"的?它和 Fiber 节点之间是怎么绑定的?这就是下一个考点「dispatch 的 bind 机制」要拆解的事情。
考点 2.2:dispatch 的 bind 机制
第 0 段:直觉锚定
想象你给一个快递员发了一张预填好寄件人和收件地址的面单。面单上已经写死了"从哪寄、寄到哪个仓库",快递员只需要在"物品"一栏填上你给他的东西就行。
dispatchSetState.bind(null, fiber, queue) 就是这张预填面单。React 把 fiber(哪个组件)和 queue(哪个 Hook 的更新队列)提前绑死,返回的 dispatch 函数对外只暴露一个参数------action(你要设置的新值)。组件拿到的 setCount(1) 背后,fiber 和 queue 早已被闭包封印进去了。
第 1 段:问题背景
useState 返回的 setState 到底是什么
scss
const [count, setCount] = useState(0);
setCount 看起来就是一个普通函数,调用它就能触发组件更新。但问题是:一个普通函数怎么知道要更新哪个组件的哪个 state?
React 的解法是:用 bind 提前把"身份信息"绑进去。
为什么不用闭包而用 bind
技术上闭包也能实现同样效果:
arduino
// 闭包方案(假设)
const setCount = (action) => dispatchSetState(fiber, queue, action);
// bind 方案(React 实际使用的)
const setCount = dispatchSetState.bind(null, fiber, queue);
两者效果等价。React 选择 bind 可能是因为:
bind是引擎原生实现,比手动闭包包装少一层函数调用- 语义更清晰------"把前两个参数固定住,只留 action 给调用者"
第 2 段:核心数据结构
dispatch 的创建过程
源码位于 ReactFiberHooks.js:1922-1933:
ini
function mountState<S>(initialState): [S, Dispatch] {
const hook = mountStateImpl(initialState); // 创建 Hook 节点,初始化 state
const queue = hook.queue;
// 关键行:bind 绑定
const dispatch = dispatchSetState.bind(
null,
currentlyRenderingFiber, // 第1个参数:当前 Fiber 节点
queue, // 第2个参数:这个 Hook 的更新队列
);
queue.dispatch = dispatch; // 把 dispatch 也存到 queue 上
return [hook.memoizedState, dispatch];
}
bind 做了什么:
scss
dispatchSetState 的原始签名:
dispatchSetState(fiber, queue, action)
↑ ↑ ↑
组件身份 Hook身份 用户传入的新值
bind(null, fiber, queue) 之后:
dispatch(action) ← 对外只剩这一个参数
更新队列(UpdateQueue)的结构
csharp
// ReactFiberHooks.js:1911-1917
const queue: UpdateQueue = {
pending: null, // 指向最新的 Update(环形链表)
lanes: NoLanes, // 优先级
dispatch: null, // 创建后立即赋值为 bind 后的 dispatch
lastRenderedReducer: basicStateReducer, // 上次渲染用的 reducer
lastRenderedState: initialState, // 上次渲染的 state(用于比较)
};
Update 对象的结构
当调用 setCount(1) 时,React 创建一个 Update 对象(ReactFiberHooks.js:3634):
csharp
const update = {
lane, // 优先级
revertLane: NoLane,
gesture: null,
action, // ← 你传入的 1(或函数 (prev) => prev + 1)
hasEagerState: false, // 是否已急切计算过新 state
eagerState: null, // 急切计算的结果
next: null, // 指向下一个 Update(环形链表)
};
第 3 段:运行流程
dispatchSetState 的完整调用链
当你调用 setCount(1) 时:
scss
setCount(1)
│
▼
dispatchSetState(fiber, queue, 1) ← bind 解开,三个参数齐全
│
├─ ① requestUpdateLane(fiber) ← 获取一个优先级 lane
│
├─ ② dispatchSetStateInternal() ← 创建 Update 对象并入队
│ │
│ ├─ 创建 Update { action: 1, lane, ... }
│ │
│ ├─ 判断:是否在渲染阶段(isRenderPhaseUpdate)?
│ │ ├─ Yes → enqueueRenderPhaseUpdate(渲染阶段的更新,特殊处理)
│ │ └─ No → 进入下面的流程
│ │
│ ├─ 【急切计算优化】检查是否可以提前算出新 state
│ │ 条件:fiber.lanes === NoLanes(当前无待处理更新)
│ │ ├─ 用 lastRenderedReducer(currentState, action) 算出 eagerState
│ │ ├─ 如果 is(eagerState, currentState) === true
│ │ │ → 新旧 state 一样,直接 bailout(不调度更新!)
│ │ └─ 否则,正常入队
│ │
│ └─ enqueueConcurrentHookUpdate(fiber, queue, update, lane)
│ → 把 Update 入队到 queue.pending(环形链表)
│ → 返回 root
│
└─ ③ scheduleUpdateOnFiber(root, fiber, lane) ← 调度更新
→ 进入 Fiber 的调度流程(workLoop → beginWork → ...)
关键细节:急切计算(Eager Evaluation)
这是 React 的一个性能优化。源码 ReactFiberHooks.js:3648-3677:
ini
// 如果当前 fiber 没有任何待处理的更新(lanes === NoLanes)
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
const lastRenderedReducer = queue.lastRenderedReducer;
const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 新旧 state 相同 → 不触发重新渲染!
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 没有调度更新
}
}
含义: 如果 setCount(currentCount)(新值和旧值相同),React 在 dispatch 阶段就能提前算出来,直接跳过整个渲染流程。不需要等到 beginWork 的 bailout 检查。
useReducer 的 dispatch 对比
useReducer 的 dispatch 也是 bind 机制,但绑定的是 dispatchReducerAction 而不是 dispatchSetState:
arduino
// useState 的 dispatch
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
// useReducer 的 dispatch
const dispatch = dispatchReducerAction.bind(null, currentlyRenderingFiber, queue);
两者区别:
dispatchSetState:action 直接就是新值(或更新函数),内部用basicStateReducer处理dispatchReducerAction:action 传给用户提供的 reducer 函数处理
但绑定模式完全一样。
第 4 段:设计动机与权衡
为什么 bind(null, fiber, queue) 而不是 bind(this, ...)
第一个参数传 null 是因为 dispatchSetState 内部不使用 this。React 不用面向对象的方式组织这段代码,所有上下文通过参数传递。bind 的第一个参数是 thisArg,传 null 表示忽略。
为什么 dispatch 的引用是稳定的
看这段代码:
ini
// mountState 中
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
dispatch 在 mountState 时创建一次,然后存在 queue.dispatch 上。更新时不会再重新 bind:
arduino
// updateState 中(简化)
function updateState<S>(): [S, Dispatch] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
// ...
return [hook.memoizedState, queue.dispatch]; // ← 直接复用 queue.dispatch
}
这意味着 setCount 的引用在组件的整个生命周期内始终不变 (同一个函数对象)。这就是为什么你不需要把 setState 包在 useCallback 里------它天然就是稳定引用。
为什么 queue 是引用共享的
回顾考点 2.1:update 时克隆 Hook 对象,但 queue 是引用共享的:
arduino
const newHook = {
memoizedState: currentHook.memoizedState,
queue: currentHook.queue, // ← 引用,不是拷贝
};
原因就在这里:queue.dispatch 是 bind 时绑定了这个 queue 对象。如果克隆了 queue(新建一个对象),dispatch 上的闭包还指向旧的 queue,新的 action 就入不了队了。所以 queue 必须是同一个对象引用,跨渲染共享。
第 5 段:次级误解和边界
误解 1:"setState 是异步的"
不准确。 setState(即 dispatch)本身是同步执行的------它同步创建 Update 对象、入队、调用 scheduleUpdateOnFiber。但 scheduleUpdateOnFiber 不一定立即触发渲染。在 Concurrent Mode 下,更新可能被调度到稍后执行;在 Legacy Mode 的生命周期内调用时,React 会批量处理(batching)。
所谓"setState 是异步的",指的是渲染(re-render)是延迟的,不是 setState 本身是异步的。
误解 2:"每次渲染都会创建新的 dispatch"
不会。 dispatch 只在 mount 时通过 bind 创建一次,之后存在 queue.dispatch 上。update 时直接复用。这就是 useState 返回的 setter 引用稳定的原因。
边界:在渲染阶段调用 setState 会怎样?
源码 isRenderPhaseUpdate(fiber) 检测到这种情况后,走特殊路径 enqueueRenderPhaseUpdate,不会调用 scheduleUpdateOnFiber。而是标记一个标志位,等当前渲染结束后再处理。这是 React 防止渲染阶段无限循环的机制------但如果你在渲染阶段无条件调用 setState(没有终止条件),仍然会死循环。
边界:bind 绑定的 fiber 是 current 还是 workInProgress?
ini
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
currentlyRenderingFiber 是 workInProgress------当前正在构建的 Fiber 节点。但 dispatchSetState 内部通过 fiber.alternate 可以访问到 current 树。所以无论用户在什么时候调用 dispatch,React 都能找到正确的更新入口。
现在我们知道了 dispatch 通过 bind 把 fiber 和 queue 封闭在内,对外只暴露 action 参数,并且 queue 引用在组件生命周期内保持稳定。但 mount 时创建 Hook 的逻辑和 update 时克隆 Hook 的逻辑是两套完全不同的代码路径------React 是怎么切换这两条路径的?这就是下一个考点「mount vs update 两条路径」要拆解的内容。
考点 2.3:mount vs update 两条路径
第 0 段:直觉锚定
想象你开一家奶茶店。开业第一天 (mount),你要做全套:选地段、装修、买设备、招人、定菜单------一切从零开始。之后每天开门营业(update),你只需要看看昨天的数据、调整今天的备料------大部分基础设施已经在了,做增量更新就行。
React 的 Hook 也是这个逻辑。useState 在首次渲染时要创建 Hook 对象、初始化 state、创建 queue、bind dispatch。但更新渲染时,这些都已经存在了------只需要从 current 树克隆 Hook、处理队列里的 update、算出新 state。
React 怎么知道该走哪条路?答案是一个策略模式(Dispatcher) :在渲染开始时切换一个全局的"调度器对象",同一个 useState 调用,背后指向的是完全不同的函数。
第 1 段:问题背景
为什么需要两条路径
回顾考点 2.1:mount 时 mountWorkInProgressHook() 创建新 Hook 节点,update 时 updateWorkInProgressHook() 从 current 树克隆。这两个函数的行为完全不同。
如果只用一个函数处理两种情况,代码会充满 if (isMount) ... else ... 分支。React 的做法更优雅:在组件函数执行之前,就把 useState 这个"函数名"指向不同的实现 。组件代码写 useState(0) 完全不知道自己走的是 mount 还是 update 路径。
Dispatcher 是什么
Dispatcher 就是一个纯对象,每个属性对应一个 Hook API:
yaml
// ReactFiberHooks.js:3898
const HooksDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect,
useMemo: mountMemo,
useRef: mountRef,
// ...
};
// ReactFiberHooks.js:3926
const HooksDispatcherOnUpdate = {
useState: updateState,
useEffect: updateEffect,
useMemo: updateMemo,
useRef: updateRef,
// ...
};
组件代码里的 useState,实际上调用的是 ReactSharedInternals.H.useState。React 在渲染前把 H 切换成 mount 或 update dispatcher,就完成了路径切换。
第 2 段:核心数据结构
Dispatcher 的切换机制
源码 ReactFiberHooks.js:559-563:
sql
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // current 树不存在 或 current 无 Hook → mount
: HooksDispatcherOnUpdate; // 否则 → update
判断条件有两个:
current === null:这个 Fiber 从未渲染过(首次挂载)current.memoizedState === null:Fiber 存在,但从未使用过任何 stateful Hook(如只用了useContext)
React 包如何桥接到 Dispatcher
useState 等函数在 packages/react/src/ReactHooks.js 中定义:
csharp
export function useState<S>(initialState) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
resolveDispatcher() 返回的就是 ReactSharedInternals.H------当前激活的 Dispatcher。所以用户代码调用的 useState(0) 本质上是:
css
用户代码: useState(0)
→ resolveDispatcher().useState(0)
→ HooksDispatcherOnMount.useState(0) // mount 时
= mountState(0)
或
→ HooksDispatcherOnUpdate.useState(0) // update 时
= updateState(0)
三条路径,不是两条
实际上除了 mount 和 update,还有第三条路径------rerender:
ini
// ReactFiberHooks.js 中搜索 rerender
HooksDispatcherOnRerender = {
useState: rerenderState,
useEffect: rerenderEffect,
// ...
};
rerender 发生在渲染阶段的更新 (render phase update)------组件在执行过程中调用了 setState,导致当前渲染作废、需要立即重新执行。这种情况不回到 schedule 流程,而是在当前渲染中直接重跑组件。
updateState 和 rerenderState 的区别:
csharp
// ReactFiberHooks.js:1936
function updateState<S>(initialState): [S, Dispatch] {
return updateReducer(basicStateReducer, initialState);
}
// ReactFiberHooks.js:1942
function rerenderState<S>(initialState): [S, Dispatch] {
return rerenderReducer(basicStateReducer, initialState);
}
底层分别走 updateReducer 和 rerenderReducer,区别在于如何处理 Update 队列(rerender 时不重新入队,直接消费已有的更新)。
第 3 段:运行流程
完整的 Dispatcher 切换时序
ini
beginWork 遇到函数组件 Fiber
│
▼
renderWithHooks(current, workInProgress, Component, ...)
│
├─ ① 清空 workInProgress 的 Hook 状态
│ workInProgress.memoizedState = null
│ workInProgress.updateQueue = null
│
├─ ② 选择 Dispatcher
│ current === null || current.memoizedState === null?
│ → Yes: ReactSharedInternals.H = HooksDispatcherOnMount
│ → No: ReactSharedInternals.H = HooksDispatcherOnUpdate
│
├─ ③ 执行组件函数
│ Component(props)
│ → 调用 useState(0) → dispatcher.useState(0)
│ → 调用 useEffect(fn) → dispatcher.useEffect(fn)
│ → ...
│ 每次调用都通过 dispatcher 间接路由到 mount* 或 update* 实现
│
├─ ④ 检查是否有渲染阶段更新
│ if (didScheduleRenderPhaseUpdateDuringThisPass)
│ → renderWithHooksAgain(...) // 重跑组件
│
└─ ⑤ finishRenderingHooks()
清理全局变量(currentHook、workInProgressHook 等)
mount 路径:useState 的完整流程
scss
mountState(0)
│
├─ mountStateImpl(0)
│ ├─ mountWorkInProgressHook() → 创建 Hook 节点,挂到链表尾
│ ├─ hook.memoizedState = hook.baseState = 0
│ └─ 创建 UpdateQueue { pending: null, ... }
│
├─ dispatchSetState.bind(null, fiber, queue) → 创建 dispatch
├─ queue.dispatch = dispatch
│
└─ return [0, dispatch]
update 路径:useState 的完整流程
scss
updateState(0)
│
└─ updateReducer(basicStateReducer, 0)
│
├─ updateWorkInProgressHook()
│ 从 current 树克隆 Hook 到 workInProgress 树
│
└─ updateReducerImpl(hook, currentHook, reducer)
│
├─ 取出 pending 队列,合并到 baseQueue
│
├─ 遍历 baseQueue,逐个处理 Update:
│ ├─ 优先级不够 → skip(保留在 baseQueue 中)
│ ├─ 优先级足够 + hasEagerState → 直接用 eagerState
│ └─ 优先级足够 + 无 eagerState → reducer(state, action)
│
├─ 计算出 newBaseState 和 newState
│
├─ hook.memoizedState = newState
│ hook.baseState = newBaseState
│
└─ return [newState, queue.dispatch] ← dispatch 复用,不重建
updateReducerImpl 的核心:Update 队列处理
这是 update 路径最复杂的部分。源码 ReactFiberHooks.js:1302-1540。
当多个 setState 连续调用时,Update 对象排成一个环形链表 (通过 queue.pending 指向最新节点,pending.next 指向最老节点)。updateReducerImpl 遍历这个环形链表:
ini
// 简化后的核心逻辑
const first = baseQueue.next;
let newState = baseState;
let update = first;
do {
const updateLane = update.lane;
if (优先级不够) {
// 跳过,保留在新的 baseQueue 中
// 这样下次更高优先级的渲染时可以重新处理
} else {
// 有足够优先级,处理这个 update
if (update.hasEagerState) {
newState = update.eagerState; // 用急切计算的结果
} else {
newState = reducer(newState, update.action); // 正常计算
}
}
update = update.next;
} while (update !== null && update !== first); // 环形链表,回到起点就停
关键设计:被跳过的 Update 不会丢失。 它们被保存到新的 baseQueue 中,下次更高优先级渲染时会重新处理。这就是为什么低优先级更新不会覆盖高优先级更新。
第 4 段:设计动机与权衡
为什么用 Dispatcher 模式而不是 if-else
| 方案 | 优点 | 缺点 |
|---|---|---|
每个函数里 if (isMount) |
直观 | 每个 Hook 函数都要分支,代码重复 |
| Dispatcher 对象切换 | 关注点分离,mount/update 实现完全独立 | 多一层间接调用 |
| 策略类 / 继承 | 面向对象风格 | JS 中过于重量级 |
Dispatcher 模式的核心优势:mount 和 update 的实现可以完全独立演进 。比如 mountRef 创建 {current: value},updateRef 直接返回 hook.memoizedState------两者逻辑差异极大,用 if-else 会非常臃肿。
为什么 rerender 是第三条路径
渲染阶段更新(组件函数执行中调用 setState)需要特殊处理:
- 不能再调
scheduleUpdateOnFiber(会导致无限循环) - 需要在当前渲染中立即消费新的 Update
rerenderReducer的实现跳过了scheduleUpdateOnFiber,直接处理 pending queue
useState 和 useReducer 共享 updateReducer
matlab
function updateState<S>(initialState): [S, Dispatch] {
return updateReducer(basicStateReducer, initialState);
}
useState 的 update 路径实际上调用的是 updateReducer,只不过用了内置的 basicStateReducer:
csharp
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
这就是为什么 setState 既接受值(setState(1))也接受函数(setState(prev => prev + 1))------basicStateReducer 帮你做了分发。useReducer 则用你自定义的 reducer。
第 5 段:次级误解和边界
误解 1:"mount 和 update 的判断依据是组件是否第一次渲染"
不完全对。 判断条件是 current === null || current.memoizedState === null。即使组件已经渲染过,如果上次渲染没有使用任何 stateful Hook(只用了 useContext),current.memoizedState 仍然是 null,React 会走 mount 路径。
源码第 543 行的注释明确说了这一点:
arduino
// 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.
误解 2:"Dispatcher 切换是 React 独有的发明"
Dispatcher 本质上是策略模式(Strategy Pattern) + 全局状态切换。在 JS 生态中并不罕见------Express 的中间件、Redux 的 store enhancer 都用了类似思路。React 的独特之处在于用它来区分同一 API 的不同实现,且切换时机由框架内部精确控制。
边界:Strict Mode 下的双重调用
源码第 613-626 行:
scss
if (shouldDoubleRenderDEV) {
setIsStrictModeForDevtools(true);
try {
children = renderWithHooksAgain(workInProgress, Component, props, secondArg);
} finally {
setIsStrictModeForDevtools(false);
}
}
Strict Mode 下组件会执行两次,但不是切换两次 Dispatcher 。第二次执行走的是 renderWithHooksAgain,它复用第一次执行建立的 Hook 链表,不会重新创建。这是考点 3.6 会详细展开的内容。
边界:Suspense 恢复后的 Dispatcher
当组件 throw Promise 触发 Suspense,稍后恢复渲染时,React 可能需要切换 Dispatcher。因为 Suspense 恢复后组件可能处于 mount 状态(第一次成功渲染),需要确保用正确的 Dispatcher。这涉及 renderWithHooksAgain 和 rerender 路径的交互,是边界情况。
现在我们知道了 React 通过 Dispatcher 对象切换来路由同一 Hook 调用到 mount/update/rerender 三条完全不同的实现路径,mount 创建一切从零,update 克隆并处理 Update 队列 。但 useEffect 在 mount 和 update 中的行为有何不同?它的回调到底在什么时机执行?这就是下一个考点「useEffect 的执行时机」要拆解的内容。
考点 2.4:useEffect 的执行时机
第 0 段:直觉锚定
想象你在装修房子。有三种装修工:
- 水电工 (useInsertionEffect):在你搬进来之前,趁墙还露着,赶紧把线管埋好。必须在刷漆、铺地板之前干完。
- 精装修工 (useLayoutEffect):你刚把家具搬走、新家具还没进门这一瞬间。他在空房子里搞装修------你看不见这个过程(浏览器还没绘制),但你回来时一切已经就绪。
- 保洁阿姨 (useEffect):你已经搬进来了,正在正常生活。阿姨趁你不在家的时候来打扫------不影响你的日常生活(浏览器已经完成绘制),但如果阿姨干活很慢,下次你约的朋友来访可能会迟到(阻塞下一次渲染)。
useEffect 就是那位保洁阿姨------在浏览器绘制完成后,以低优先级异步执行。
第 1 段:问题背景
useEffect 的回调到底什么时候执行
这是面试高频题,也是最容易答错的地方。先给结论:
php
setState → render 阶段(计算新 Fiber 树)→ commit 阶段
│
├─ ① Mutation:操作 DOM(同步)
├─ ② Layout Effects:执行 useLayoutEffect(同步)
│ ← 此时 DOM 已更新,但浏览器尚未绘制
├─ 浏览器绘制(Paint) ← 不受 React 控制
└─ ③ Passive Effects:执行 useEffect(异步/延迟)
← 此时浏览器已经绘制完毕
关键点:useEffect 的 create 回调不在 commit 阶段同步执行,而是被调度到一个单独的微任务/宏任务中。
为什么这么设计
如果 useEffect 和 useLayoutEffect 一样同步执行,那所有 effect 都会阻塞浏览器绘制。但大部分 effect(数据获取、日志上报、事件订阅)并不需要阻塞绘制。延迟执行可以让浏览器更快地把新 UI 呈现给用户。
第 2 段:核心数据结构
Effect 对象的 tag 标记
Effect 对象有一个 tag 字段,用位标记(bitmask)标识类型和状态:
ini
tag 的可能值(来自 ReactHookEffectTags.js):
HookPassive = 0b100 (4) → useEffect
HookLayout = 0b010 (2) → useLayoutEffect
HookInsertion = 0b001 (1) → useInsertionEffect
HookHasEffect = 0b1000 (8) → "需要执行"标记
mount 时如何标记
scss
// ReactFiberHooks.js:2622-2628
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook();
currentlyRenderingFiber.flags |= fiberFlags; // ← 标记 Fiber:我有 passive effect
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags, // ← tag 同时包含"类型"和"需要执行"
createEffectInstance(),
create,
nextDeps,
);
}
fiberFlags=Passive(对于 useEffect)→ 标记到fiber.flags上HookHasEffect | HookPassive→ 标记到effect.tag上
为什么需要两个标记? fiber.flags 告诉 React "这个组件有某种 effect 要处理",effect.tag 告诉 React "具体是哪种 effect、是否真的需要执行"。
update 时 deps 不变的短路逻辑
ini
// ReactFiberHooks.js:2644-2657
if (currentHook !== null) {
if (nextDeps !== null) {
const prevDeps = currentHook.memoizedState.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
// deps 没变 → 不需要执行这个 effect
hook.memoizedState = pushSimpleEffect(
hookFlags, // ← 注意:没有 HookHasEffect!
inst,
create,
nextDeps,
);
return; // ← 直接返回,不标记 fiber.flags
}
}
}
// deps 变了,才走下面的逻辑,打上 HookHasEffect 标记
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(HookHasEffect | hookFlags, inst, create, nextDeps);
核心机制:deps 不变时,effect.tag 只有 HookPassive(4),没有 HookHasEffect(8)。commit 阶段检查的是 HookHasEffect,所以不会执行。
Effect 环形链表
pushEffectImpl(ReactFiberHooks.js:2579)把 Effect 串成环形链表:
ini
function pushEffectImpl(effect) {
const componentUpdateQueue = currentlyRenderingFiber.updateQueue;
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
// 第一个 effect:自己指向自己
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// 追加到尾部
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
return effect;
}
less
updateQueue.lastEffect → Effect#3
│
Effect#3.next → Effect#1 → Effect#2 → Effect#3(回到头)
↑ 头 ↑ 尾
第 3 段:运行流程
commit 阶段 effect 执行的完整时序
scss
commitRootImpl()
│
├─ ─ ─ ─ ─ ─ commit 阶段开始 ─ ─ ─ ─ ─ ─
│
├─ ① BeforeMutation 阶段
│ └─ getSnapshotBeforeUpdate(class 组件)
│
├─ ② Mutation 阶段
│ ├─ 遍历 effectList,执行 DOM 操作(插入/更新/删除)
│ ├─ 执行 useInsertionEffect 的 unmount + mount
│ │ commitHookEffectListUnmount(HookInsertion | HookHasEffect)
│ │ commitHookEffectListMount(HookInsertion | HookHasEffect)
│ └─ 执行 useLayoutEffect 的 unmount
│ commitHookLayoutUnmountEffects(HookLayout | HookHasEffect)
│
├─ ③ 切换 Fiber 树:root.current = finishedWork
│
├─ ④ Layout 阶段
│ ├─ 执行 useLayoutEffect 的 mount
│ │ commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect)
│ ├─ 执行 componentDidMount / componentDidUpdate
│ └─ 执行 ref 更新(safelyAttachRef)
│
├─ ─ ─ ─ ─ ─ 浏览器绘制(Paint) ─ ─ ─ ─ ─
│
├─ ⑤ 调度 Passive Effects
│ scheduleCallback(NormalPriority, () => {
│ flushPassiveEffects() ← 这是一个独立的调度任务
│ })
│
└─ commit 阶段结束,主线程释放
│
▼(稍后,在独立的调度任务中)
flushPassiveEffects()
├─ 先执行所有 useEffect 的 unmount(destroy)
│ commitHookPassiveUnmountEffects(finishedWork, HookPassive | HookHasEffect)
│
└─ 再执行所有 useEffect 的 mount(create)
commitHookPassiveMountEffects(finishedWork, HookPassive | HookHasEffect)
commitHookEffectListMount 的核心逻辑
源码 ReactFiberCommitEffects.js:141:
ini
function commitHookEffectListMount(flags, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) { // ← 位运算检查:tag 是否包含所有要求的标记
const create = effect.create;
const inst = effect.inst;
destroy = create(); // 执行 create 回调
inst.destroy = destroy; // 把 destroy 函数存起来,下次 unmount 用
}
effect = effect.next;
} while (effect !== firstEffect); // 环形链表,回到起点就停
}
}
(effect.tag & flags) === flags 是关键判断:
-
flags = HookPassive | HookHasEffect=0b1100 -
如果
effect.tag = HookPassive=0b0100(deps 没变,没有 HookHasEffect)0b0100 & 0b1100 = 0b0100 ≠ 0b1100→ 跳过
-
如果
effect.tag = HookHasEffect | HookPassive=0b1100(deps 变了)0b1100 & 0b1100 = 0b1100→ 执行
useEffect 的 unmount(destroy)何时执行
lua
场景1:组件卸载
→ 下次渲染时该 Fiber 被删除
→ Mutation 阶段:commitHookPassiveUnmountEffects
→ 先执行 destroy,再移除 DOM
场景2:deps 变化
→ flushPassiveEffects 中:
先 unmount(执行旧 destroy) → 再 mount(执行新 create)
场景3:deps 不变
→ effect.tag 没有 HookHasEffect → 跳过,不执行 destroy 也不执行 create
第 4 段:设计动机与权衡
三种 Effect 的时序对比
| Hook | 时机 | 阻塞绘制 | 典型用途 |
|---|---|---|---|
useInsertionEffect |
Mutation 阶段,DOM 操作前 | 是(但极快) | CSS-in-JS 注入样式规则 |
useLayoutEffect |
Layout 阶段,DOM 更新后、绘制前 | 是 | 读取 DOM 布局、同步修改 DOM |
useEffect |
绘制后,异步调度 | 否 | 数据获取、事件订阅、日志 |
为什么 useEffect 不阻塞绘制
因为 React 通过 scheduleCallback(Scheduler 包)把 passive effects 调度到一个独立的回调中。这个回调在浏览器完成当前帧的绘制后、下一个空闲时段执行。如果 effect 执行时间过长,不会阻塞当前帧的绘制,但可能影响下一帧。
为什么 unmount 在 mount 之前
markdown
// flushPassiveEffects 的执行顺序
1. commitHookPassiveUnmountEffects → 先执行旧 destroy
2. commitHookPassiveMountEffects → 再执行新 create
这保证了 cleanup 逻辑先于新的 setup 逻辑运行。如果反过来(先 create 再 destroy),新 effect 可能依赖的 DOM 状态已经被 destroy 破坏了。
为什么用环形链表而不用数组
环形链表的好处是 lastEffect.next 就是头节点,O(1) 找到遍历起点。追加新 effect 也是 O(1)(修改 lastEffect 指针和两个 next 引用)。遍历时 effect !== firstEffect 自然终止。
第 5 段:次级误解和边界
误解 1:"useEffect 是异步的"
不够精确。 useEffect 的调度 是异步的(通过 scheduleCallback),但回调执行 本身是同步的------只是被放在一个稍后的任务中。不是说回调内部有 await 或者用了 Web Worker。
误解 2:"useEffect 在每次渲染后都会执行"
不一定。 只有当 effect.tag 包含 HookHasEffect 时才会执行。deps 不变的情况下,mount 路径打了 HookHasEffect,但 update 路径通过 areHookInputsEqual 检测后不打 HookHasEffect,commit 阶段就会跳过。
误解 3:"useEffect 的 destroy 在 DOM 移除之前执行"
passive effect 的 destroy 不是在 DOM 移除前执行的。 它在下一轮 passive effect flush 时执行------也就是在下一次渲染的 commit 之后。此时 DOM 可能已经改变了。
真正在 DOM 移除前执行的是 useLayoutEffect 的 destroy (Mutation 阶段)。这就是为什么如果你需要在 DOM 节点被移除前做清理(比如断开 MutationObserver),应该用 useLayoutEffect 而不是 useEffect。
边界:strict mode 下的双重执行
开发模式 + StrictMode 下,React 会对每个 effect mount → unmount → mount 执行一次,帮助你发现缺少 cleanup 的问题。这不是 bug,是故意的设计(考点 3.6 会展开)。
边界:flushSync 内的 useEffect
如果在 flushSync 回调中触发更新,React 会在同步 commit 后立即同步刷新所有 passive effects ,而不是延迟到下一个任务。这会打破"不阻塞绘制"的保证,是 flushSync 需要谨慎使用的原因之一。
现在我们知道了 useEffect 的 create 回调在浏览器绘制后异步执行,通过 HookHasEffect 位标记控制是否执行,deps 不变时跳过 。但 useLayoutEffect 和 useEffect 共享了几乎全部基础设施,唯一的区别就是执行时机------这个区别到底有多大影响?这就是下一个考点「useLayoutEffect vs useEffect」要拆解的内容。
考点 2.5:useLayoutEffect vs useEffect
第 0 段:直觉锚定
继续用装修的比喻。你刚换了一幅画(DOM 更新完毕)。
useLayoutEffect 就像你站在画前面,趁朋友还没看到(浏览器还没拍照),赶紧把画扶正------你朋友走进来时,看到的就是完美状态。但代价是你扶正的时候,朋友被挡在门外等着(阻塞绘制)。
useEffect 就像你朋友已经看到画挂歪了(浏览器拍了一张歪的照片给他看了),你等朋友走了再把画扶正------朋友下次再来时画才是正的。好处是朋友不需要在门口等你。
核心区别就一个:在浏览器绘制前还是绘制后执行。 其他所有行为(deps 比较、destroy/create 顺序、Effect 链表结构)完全一样。
第 1 段:问题背景
为什么 React 要提供两种 Effect
useLayoutEffect 是为了解决一个实际问题:读取或同步修改 DOM 布局。
典型场景:tooltip 定位。你需要先渲染 DOM 元素,读取它的 getBoundingClientRect(),然后根据位置设置 tooltip 的 top/left。如果用 useEffect:
渲染 DOM → 浏览器绘制(用户看到 tooltip 闪到错误位置)→ useEffect 执行修正位置 → 再次绘制
用户会看到一次"闪烁"(flash)。用 useLayoutEffect:
渲染 DOM → useLayoutEffect 读取位置并修正 → 浏览器绘制(用户直接看到正确位置)
没有任何闪烁。
三种 Effect 的完整时序回顾
上一节已经给出了时序图,这里聚焦 layout 和 passive 的执行位置差异:
sql
Mutation 阶段:
├─ 操作 DOM(插入/更新/删除)
├─ useInsertionEffect unmount + mount
└─ useLayoutEffect unmount(destroy) ← DOM 操作之后
Layout 阶段:
├─ useLayoutEffect mount(create) ← 浏览器绘制之前
├─ componentDidMount / componentDidUpdate
└─ ref 更新
═══════════════ 浏览器绘制 ═══════════════
Passive 阶段(异步):
├─ useEffect unmount(destroy)
└─ useEffect mount(create)
第 2 段:核心数据结构
源码层面的差异------只有两个参数不同
useLayoutEffect 和 useEffect 的 mount/update 实现调用的是同一个 mountEffectImpl / updateEffectImpl,唯一区别是传入的参数:
arduino
// useEffect --- ReactFiberHooks.js:2686
mountEffectImpl(
PassiveEffect | PassiveStaticEffect, // fiberFlags
HookPassive, // hookFlags
create,
deps,
);
// useLayoutEffect --- ReactFiberHooks.js:2781
mountEffectImpl(
UpdateEffect | LayoutStaticEffect, // fiberFlags ← 不同!
HookLayout, // hookFlags ← 不同!
create,
deps,
);
两个参数的含义:
- fiberFlags :打在
fiber.flags上的标记,告诉 commit 阶段"这个 Fiber 有哪种副作用" - hookFlags :打在
effect.tag上的标记,用来区分 effect 类型
fiberFlags 对 commit 阶段的影响
sql
useEffect:
fiber.flags |= PassiveEffect
→ commit 阶段看到 Passive 标记 → 把这个 Fiber 加入 pendingPassiveEffects
→ 异步调度 flushPassiveEffects → 绘制后才执行
useLayoutEffect:
fiber.flags |= UpdateEffect
→ commit 阶段看到 Update 标记 → 在 Layout 子阶段同步执行
hookFlags 对遍历过滤的影响
commit 阶段遍历 Effect 环形链表时:
scss
// Layout 阶段
commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect);
// 遍历时:if ((effect.tag & (HookLayout | HookHasEffect)) === (HookLayout | HookHasEffect))
// Passive 阶段
commitHookPassiveMountEffects(finishedWork, HookPassive | HookHasEffect);
// 遍历时:if ((effect.tag & (HookPassive | HookHasEffect)) === (HookPassive | HookHasEffect))
位运算过滤保证了 layout 阶段只执行 HookLayout 的 effect,passive 阶段只执行 HookPassive 的 effect,互不干扰。
Effect 对象的 tag 编码
一个组件同时使用 useEffect 和 useLayoutEffect 时,Effect 链表中会有不同 tag 的节点:
less
updateQueue.lastEffect → Effect#2 (HookLayout)
│
Effect#2.next → Effect#1 (HookPassive) → Effect#2(环形)
每个 Effect 的 tag:
useEffect产生的:HookPassive | HookHasEffect=0b1100(deps 变时)或HookPassive=0b0100(deps 不变时)useLayoutEffect产生的:HookLayout | HookHasEffect=0b1010(deps 变时)或HookLayout=0b0010(deps 不变时)
第 3 段:运行流程
完整时序对比(同一组件同时使用两种 Effect)
ini
render 阶段:
组件执行,调用 useEffect(fn1, [a]) → 创建 Effect#1 {tag: HookPassive|HookHasEffect}
组件执行,调用 useLayoutEffect(fn2, [b]) → 创建 Effect#2 {tag: HookLayout|HookHasEffect}
Effect 环形链表:Effect#1 ⇄ Effect#2
commit 阶段:
│
├─ Mutation 子阶段:
│ ├─ DOM 操作(appendChild / updateProperties / removeChild)
│ │
│ ├─ 遍历 Effect 链表,查找 HookLayout | HookHasEffect
│ │ Effect#1: tag = 0b1100, (0b1100 & 0b1010) = 0b1000 ≠ 0b1010 → 跳过
│ │ Effect#2: tag = 0b1010, (0b1010 & 0b1010) = 0b1010 → 执行 destroy(fn2 上次的 cleanup)
│ │
│ └─ 同样处理 useInsertionEffect 的 unmount + mount
│
├─ 切换 Fiber 树:root.current = finishedWork
│
├─ Layout 子阶段:
│ ├─ 遍历 Effect 链表,查找 HookLayout | HookHasEffect
│ │ Effect#2: tag = 0b1010 → 执行 create(fn2)
│ │ inst.destroy = fn2 的返回值(cleanup 函数)
│ │
│ └─ ref 更新
│
├─ ─ ─ 浏览器绘制(Paint) ─ ─ ─
│
└─ 调度 Passive Effects(scheduleCallback)
│
▼(异步执行)
flushPassiveEffects:
├─ 遍历 Effect 链表,查找 HookPassive | HookHasEffect
│ Effect#1: tag = 0b1100 → 先执行 destroy(fn1 上次的 cleanup)
│ Effect#1: tag = 0b1100 → 再执行 create(fn1)
│ inst.destroy = fn1 的返回值
└─ 完成
对比:如果 deps 不变
ini
update 时 deps 未变化:
useEffect: effect.tag = HookPassive(无 HookHasEffect)→ 0b0100
useLayoutEffect: effect.tag = HookLayout(无 HookHasEffect)→ 0b0010
遍历时的判断:
Layout 阶段:(0b0010 & 0b1010) = 0b0010 ≠ 0b1010 → 跳过 ✓
Passive 阶段:(0b0100 & 0b1100) = 0b0100 ≠ 0b1100 → 跳过 ✓
两种 Effect 都不执行。
第 4 段:设计动机与权衡
为什么共享 mountEffectImpl / updateEffectImpl
看代码,三种 Effect 的 mount/update 逻辑几乎完全一样------创建 Hook、打标记、推入 Effect 链表。唯一的区别就是 fiberFlags 和 hookFlags 两个参数。共享实现避免了三份重复代码。
useLayoutEffect 的性能代价
lua
使用 useLayoutEffect 时:
Mutation → Layout Effect destroy → Layout Effect create → 浏览器绘制
↑
如果 create 很慢,阻塞绘制
使用 useEffect 时:
Mutation → 浏览器绘制 → Passive Effect destroy → Passive Effect create
↑
不被 effect 阻塞
所以 React 官方建议:99% 的情况用 useEffect,只在需要同步读取/修改 DOM 布局时才用 useLayoutEffect。
SSR 中的差异
useLayoutEffect 在服务端渲染时会触发 React 警告:
vbnet
Warning: useLayoutEffect does nothing on the server...
因为服务端没有 DOM,也没有浏览器绘制周期,layout effect 无法执行。React 官方建议 SSR 场景下用 useEffect 替代,或使用社区库 use-isomorphic-layout-effect(自动在服务端降级为 useEffect)。
第 5 段:次级误解和边界
误解 1:"useLayoutEffect 比 useEffect 快"
不是。 两者执行的是同一个 create 回调,速度取决于你写了什么代码。区别只在执行时机------useLayoutEffect 在绘制前同步执行(可能阻塞绘制),useEffect 在绘制后异步执行(不阻塞但用户可能看到中间状态)。
误解 2:"useLayoutEffect 和 componentDidMount 等价"
基本等价,但有细微差异。 componentDidMount 在 Layout 阶段同步执行,和 useLayoutEffect 的 create 回调在同一时机。但 componentDidUpdate 和 useLayoutEffect 的 destroy/create 顺序不同:
- class 组件:先
componentDidUpdate,再处理子组件的 layout effects - 函数组件:先处理完所有 layout effects 的 destroy,再执行所有 create
边界:useLayoutEffect 中触发 setState
在 useLayoutEffect 的 create 回调中调用 setState,会同步触发一次额外渲染(不是异步调度)。因为此时还在 commit 阶段,React 会立即处理这个更新,在浏览器绘制前完成。这意味着用户不会看到中间状态,但组件会多渲染一次。
如果这个额外渲染又触发了 layout effect 中的 setState,就会形成无限循环------React 会对嵌套更新次数设置上限来防止这种情况。
边界:useInsertionEffect 是什么
三种 Effect 中的第三个,执行时机最早(Mutation 阶段,DOM 操作之前)。它设计给 CSS-in-JS 库用------在 DOM 插入前注入 <style> 标签,避免无样式内容闪烁(FOUC)。普通开发者几乎不需要直接使用它。
它的 fiberFlags 是 UpdateEffect,hookFlags 是 HookInsertion。mount/update 同样调用 mountEffectImpl / updateEffectImpl,只是参数不同。
现在我们知道了 useLayoutEffect 和 useEffect 共享同一套基础设施,仅通过 fiberFlags 和 hookFlags 两个参数区分,导致 commit 阶段在不同的子阶段执行 。但 useMemo 和 useCallback 不涉及 Effect 链表,它们用的是完全不同的机制来避免重复计算------这就是下一个考点「useMemo / useCallback 的依赖比较」要拆解的内容。
考点 2.6:useMemo / useCallback 的依赖比较
第 0 段:直觉锚定
想象你有个习惯:出门前看一眼手机备忘录上的购物清单。
- useMemo:如果清单没变(牛奶、面包、鸡蛋------和上次一模一样),你直接用上次已经买好的那袋东西,不用再去超市跑一趟。只有清单变了,你才重新出门采购。
- useCallback:如果清单没变,你直接把上次写的那张"买菜路线图"递给家人,不用重新画一张一模一样的。
两者做的事情本质相同:避免重复计算/重复创建 。区别只是"缓存的是什么"------useMemo 缓存计算结果 ,useCallback 缓存函数引用。而且 useCallback 其实就是 useMemo 的语法糖。
第 1 段:问题背景
为什么需要这两个 Hook
函数组件每次渲染都会从头执行一遍。所有局部变量、函数、计算表达式都会重新创建/重新求值。
javascript
function App({ items, onClick }) {
// 每次渲染都重新创建函数
const handleClick = () => { /* ... */ };
// 每次渲染都重新计算
const sorted = items.sort((a, b) => a.id - b.id);
// 如果传给子组件,子组件每次都会重新渲染(因为引用变了)
return <List data={sorted} onClick={handleClick} />;
}
如果 List 是 React.memo 包裹的组件,即使 items 没变,sorted 是新数组(引用不同),List 还是会重新渲染。useMemo/useCallback 就是为了解决这类"无意义的新建"。
第 2 段:核心数据结构
memoizedState 的存储格式
两者在 Hook 的 memoizedState 中存的都是 [value, deps] 二元组:
less
// mountMemo --- ReactFiberHooks.js:2931
hook.memoizedState = [nextValue, nextDeps];
// ↑ ↑
// 缓存的值 deps 数组
// mountCallback --- ReactFiberHooks.js:2898
hook.memoizedState = [callback, nextDeps];
// ↑ ↑
// 缓存的函数 deps 数组
源码对比:mount 路径
ini
// mountCallback --- ReactFiberHooks.js:2895
function mountCallback<T>(callback: T, deps): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps]; // 直接存 [函数, deps]
return callback;
}
// mountMemo --- ReactFiberHooks.js:2916
function mountMemo<T>(nextCreate: () => T, deps): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate(); // 先执行计算函数
hook.memoizedState = [nextValue, nextDeps]; // 存 [结果, deps]
return nextValue;
}
关键差异在 mount 阶段: useMemo 会立即调用 nextCreate() 拿到结果,useCallback 直接存传入的函数(不调用)。
源码对比:update 路径
ini
// updateCallback --- ReactFiberHooks.js:2902
function updateCallback<T>(callback: T, 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]; // deps 没变 → 返回旧函数
}
}
hook.memoizedState = [callback, nextDeps]; // deps 变了 → 存新函数
return callback;
}
// updateMemo --- ReactFiberHooks.js:2935
function updateMemo<T>(nextCreate: () => T, 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]; // deps 没变 → 返回旧值,不调用 nextCreate
}
}
const nextValue = nextCreate(); // deps 变了 → 重新计算
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
update 阶段两者的逻辑结构完全一样 :areHookInputsEqual 比较新旧 deps → 相同就返回缓存的旧值 → 不同就更新。唯一的区别是"更新"时 useMemo 要调用 nextCreate(),useCallback 直接用传入的新函数。
第 3 段:运行流程
useMemo 的完整生命周期
scss
首次渲染(mount):
useMemo(() => computeExpensiveValue(a, b), [a, b])
│
├─ mountWorkInProgressHook() → 创建 Hook 节点
├─ nextCreate() → 执行 computeExpensiveValue(a, b),得到 nextValue
├─ hook.memoizedState = [nextValue, [a, b]]
└─ return nextValue
更新渲染(update),a 和 b 不变:
useMemo(() => computeExpensiveValue(a, b), [a, b])
│
├─ updateWorkInProgressHook() → 从 current 克隆 Hook
├─ prevState = hook.memoizedState → [oldValue, [a, b]]
├─ areHookInputsEqual([a, b], [a, b]) → true
├─ return prevState[0] → 直接返回 oldValue
└─ nextCreate 没有被调用!省掉了 computeExpensiveValue 的执行
更新渲染(update),a 或 b 变了:
useMemo(() => computeExpensiveValue(a, b), [a, b])
│
├─ updateWorkInProgressHook() → 从 current 克隆 Hook
├─ prevState = hook.memoizedState → [oldValue, [oldA, oldB]]
├─ areHookInputsEqual([a, b], [oldA, oldB]) → false
├─ nextValue = nextCreate() → 重新计算
├─ hook.memoizedState = [nextValue, [a, b]]
└─ return nextValue
useCallback 和 useMemo 的等价关系
scss
// useCallback 的实现本质上等价于:
useCallback(fn, deps)
≈ useMemo(() => fn, deps)
但 React 没有用 useMemo 实现 useCallback,而是写了独立实现。原因是 useCallback 不需要调用 nextCreate() ,直接存函数引用即可,少一层函数调用开销。
deps 为 null / undefined 的行为
ini
const nextDeps = deps === undefined ? null : deps;
useMemo(fn)→deps = undefined→nextDeps = nulluseMemo(fn, null)→deps = null→nextDeps = null
当 nextDeps === null 时:
csharp
if (nextDeps !== null) { // ← false,跳过比较
// ...
}
// 直接走"更新"分支,每次都重新计算
不传 deps 或传 null = 每次渲染都重新计算 ,和不用 useMemo 一样。React 的 ESLint 规则 react-hooks/exhaustive-deps 会警告这种情况。
第 4 段:设计动机与权衡
useMemo / useCallback 不等于 "自动性能优化"
markdown
useMemo 的开销:
1. 创建 Hook 节点(或克隆)
2. 创建 deps 数组
3. areHookInputsEqual 逐个比较 deps
4. 存储 [value, deps] 二元组
如果 computeExpensiveValue 本身很快(比如简单的属性访问),
useMemo 的开销反而 > 直接计算的开销。
何时值得用:
- 计算确实昂贵(大数组排序、复杂过滤、深度克隆)
- 值作为
React.memo组件的 props,引用稳定性影响子组件是否重渲染 - 值作为其他 Hook 的 deps,避免不必要的 effect 重执行
何时不必用:
- 简单的属性访问(
items.length、user.name) - 原始值的计算(
a + b、count * 2) - 组件本身就很轻,重渲染代价极低
useCallback 的真正价值
useCallback(fn, deps) 缓存的不是"函数的计算结果"(函数本身就是结果),而是函数引用的稳定性。它的价值几乎只体现在一个场景:
函数作为 props 传给 React.memo 子组件
→ 如果函数引用每次都变,memo 失效,子组件白重渲染
→ useCallback 保持引用稳定,memo 才能生效
如果没有 React.memo,useCallback 完全没有意义------函数重建了,传给普通子组件,子组件本来就会重渲染。
为什么 React 未来可能自动优化 useMemo
React 编译器(React Compiler)的目标就是自动判断哪些值需要缓存 ,开发者不再需要手动写 useMemo / useCallback。编译器通过静态分析,在编译阶段自动插入缓存逻辑。这也是为什么 React 官方文档现在说 useMemo 是"性能优化而非语义保证"。
第 5 段:次级误解和边界
误解 1:"useCallback 缓存了函数的闭包变量"
不是。 useCallback(fn, deps) 中的 fn 是每次渲染新创建的函数(包含最新的闭包变量)。如果 deps 变了,hook.memoizedState 被更新为新的 [fn, deps]。如果 deps 没变,返回的是第一次(或上次 deps 变化时)传入的那个函数 ,那个函数的闭包捕获的是当时的变量值。
javascript
function App({ count }) {
// count 变了但 deps 是 [],返回的永远是第一次的函数
// 那个函数的闭包中 count 永远是初始值
const fn = useCallback(() => console.log(count), []);
}
useCallback 缓存的是"那个时刻的函数",不是"最新的函数"。 这是 stale closure 问题的常见来源。
误解 2:"useMemo 保证引用不变"
不保证。 useMemo 是一个性能优化提示 ,不是语义保证。React 文档明确说明:React 可能会选择"遗忘"已缓存的值并重新计算。未来 React 编译器可能自动去掉你写的 useMemo,用更优的缓存策略替代。
如果你需要语义保证 引用不变(比如作为 useRef 的初始值、作为 Map 的 key),应该用 useRef。
边界:deps 比较用的是 Object.is
和 useEffect 一样,areHookInputsEqual 逐个位置用 Object.is 比较。这意味着:
scss
const obj = { a: 1 };
useMemo(() => obj.a, [obj]); // 每次 obj 引用变了都会重新计算
useMemo(() => obj.a, [obj.a]); // 值不变就跳过
对象/数组作为 deps 时,即使"内容一样",引用不同也会触发重新计算。解决方案是把 deps 拆成原始值,或者用 useRef 手动比较。
现在我们知道了 useMemo 和 useCallback 通过 [value, deps] 二元组缓存结果/函数引用,update 时用 areHookInputsEqual(Object.is 逐个比较)判断是否命中缓存 。但 useRef 完全不走 deps 比较逻辑------它甚至没有 mount/update 的区别,它的本质是什么?这就是下一个考点「useRef 的本质」要拆解的内容。
考点 2.7:useRef 的本质
第 0 段:直觉锚定
想象你在办公室有一个私人抽屉。抽屉上没有锁、没有标签、没有变化检测。你往里面放什么、什么时候换东西,没人管------公司不监听抽屉内容的变动。
useRef 就是函数组件的私人抽屉。{ current: 值 } 就是那个抽屉。你可以在任何地方读它写它(渲染阶段、effect 回调、事件处理),React 完全不关心你改了什么------它不会因此触发重渲染,不会比较新旧值,不会通知任何人。
第 1 段:问题背景
函数组件的"可变状态"困境
函数组件中,有两种"跨渲染持久"的数据需求:
- 驱动渲染的数据 (state)------变了就要重新渲染 →
useState - 不驱动渲染的数据 (可变引用)------变了不需要重新渲染 →
useRef
典型场景:
- 保存 DOM 引用(
ref={divRef}) - 保存定时器 ID(
setInterval返回值) - 保存上一次渲染的值(手动实现
usePrevious) - 保存一个跨渲染稳定的可变容器
如果用 useState 存定时器 ID,每次 setState 都会触发无意义的重渲染。useRef 解决的就是"我需要跨渲染持久存一个值,但我不希望它的变化触发渲染"。
第 2 段:核心数据结构
源码------可能是 React 中最短的 Hook 实现
csharp
// mountRef --- ReactFiberHooks.js:2602
function mountRef<T>(initialValue: T): {current: T} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
// updateRef --- ReactFiberHooks.js:2609
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
就这么短。 全部逻辑加起来 6 行有效代码。
和其他 Hook 的对比
| 维度 | useState | useEffect | useMemo | useRef |
|---|---|---|---|---|
| mount 做了什么 | 创建 state + queue + dispatch | 创建 Effect + 打 flags | 调用 nextCreate + 存 value, deps | 创建 {current: value} |
| update 做了什么 | 克隆 Hook + 处理 Update 队列 | 比较 deps + 打/不打 flags | 比较 deps + 返回旧值或重算 | 直接返回 memoizedState |
| 有 deps 比较吗 | 有(eager bailout) | 有(areHookInputsEqual) | 有(areHookInputsEqual) | 没有 |
| 有 queue 吗 | 有 | 无 | 无 | 无 |
| 有 Effect 链表吗 | 无 | 有 | 无 | 无 |
| 变化会触发渲染吗 | 会 | N/A | 不会(但也不检测变化) | 不会 |
initialValue update 时用到吗 |
用到(作为 fallback) | 用到(作为 create 参数) | 用到(作为 nextCreate) | 完全忽略 |
updateRef 为什么忽略 initialValue
csharp
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState; // ← initialValue 参数完全没被使用
}
initialValue 出现在函数签名中是为了和 mountRef 保持相同的调用接口 (Dispatcher 要求 useState、useRef 等的参数列表一致)。但 update 时直接返回已有的 ref 对象,初始值没有意义。
第 3 段:运行流程
useRef 的完整生命周期
sql
首次渲染(mount):
const divRef = useRef(null)
│
├─ mountWorkInProgressHook() → 创建 Hook 节点
├─ ref = {current: null}
├─ hook.memoizedState = ref
└─ return {current: null}
更新渲染(update),假设渲染之间你做了 divRef.current = divElement:
const divRef = useRef(null)
│
├─ updateWorkInProgressHook()
│ → 从 current 树克隆 Hook
│ → newHook.memoizedState = currentHook.memoizedState ← 引用共享!
│
└─ return hook.memoizedState → 返回的是 mount 时创建的同一个对象
↓
{current: divElement} ← 你之前修改的值还在
为什么 ref 的修改能跨渲染保持
关键在于 updateWorkInProgressHook 的克隆逻辑(考点 2.1):
arduino
const newHook = {
memoizedState: currentHook.memoizedState, // ← 浅拷贝:引用同一个 ref 对象
// ...
};
currentHook.memoizedState 是 {current: ...} 对象的引用。克隆时这个引用被复制到 newHook 上。所以 mount 创建的那个 {current: ...} 对象在组件的整个生命周期中始终是同一个对象 。你改它的 current 属性,下次渲染时读到的就是新值。
为什么 ref 的修改不触发重渲染
因为 React 完全不监听 ref 的变化 。没有任何代码在 ref.current 被修改时调用 scheduleUpdateOnFiber。对比 useState:
ini
setState(1):
→ dispatchSetState → 创建 Update → 入队 → scheduleUpdateOnFiber → 触发渲染
ref.current = 1:
→ 就是普通的 JavaScript 属性赋值
→ 没有任何 React 代码介入
→ 不会触发任何东西
第 4 段:设计动机与权衡
useRef 为什么不用 Proxy / getter-setter 拦截
理论上 React 可以用 Proxy 或属性描述符拦截 ref.current 的读写,在写入时自动触发渲染。React 选择不这么做的原因:
- 性能:每次属性赋值都经过 Proxy 拦截,有额外开销
- 可预测性 :如果
ref.current = x突然触发了重渲染,开发者会很困惑------"我只是给一个引用赋值,怎么页面刷新了?" - 灵活性:ref 的设计初衷就是"可变但不触发渲染的逃逸舱",如果自动触发渲染就失去了存在意义
useRef 作为"所有 Hook 问题"的逃生舱
useRef 是 React Hooks 中最灵活的底层原语。很多自定义 Hook 的底层都依赖 useRef:
scss
// usePrevious --- 保存上一次渲染的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current;
}
// useInterval --- 稳定的定时器
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; });
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// 实例变量(类似 class 组件的 this.xxx)
const instanceVar = useRef(0);
instanceVar.current++; // 不会触发渲染,但值确实变了
useRef 和 class 组件实例属性的等价关系
| class 组件 | 函数组件 | 触发渲染 |
|---|---|---|
this.state |
useState |
是 |
this.xxx(实例属性) |
useRef |
否 |
this.props |
函数参数 props |
N/A |
useRef 本质上就是函数组件的 this------一个跨渲染持久、可自由修改、不触发渲染的可变容器。
第 5 段:次级误解和边界
误解 1:"useRef 创建的对象在每次渲染都是新的"
不是。 mount 时创建一次 {current: value},之后所有渲染都返回同一个对象 。因为 updateRef 直接返回 hook.memoizedState,而 hook.memoizedState 是从 current Hook 浅拷贝过来的引用。
误解 2:"useRef 的返回值是稳定的"
对象引用是稳定的,但 current 属性的值不是。 每次 ref.current = newValue 之后,你拿到的值就变了。如果你把 ref.current 作为其他 Hook 的 deps:
ini
const countRef = useRef(0);
countRef.current++;
useEffect(() => {
console.log("effect");
}, [countRef.current]); // ← ref.current 变了但 React 不知道!
这段 effect 不会重新执行。 因为 deps 比较发生在渲染阶段 (调用 useEffect 时),此时 countRef.current 的值和上次渲染结束时的值相同(你在渲染阶段修改了它,但 React 在渲染开始时已经"记住"了旧值)。
更危险的是:在渲染阶段读写 ref.current 本身就是反模式------React 可能并发执行多次渲染,ref 的值可能不可预测。
边界:ref 在并发模式下的安全风险
React 18 的并发渲染可能多次调用组件函数(但只提交一次)。如果组件在渲染阶段修改 ref:
csharp
function App() {
const ref = useRef(0);
ref.current++; // ← 渲染阶段修改 ref
return <div>{ref.current}</div>;
}
并发模式下 React 可能调用 App() 两次或更多次,ref.current 会累加多次。但最终只有一次被提交到 DOM。这意味着 ref.current 的值可能和用户看到的不一致。
正确做法:ref 的写入应该只在 effect(commit 后)或事件回调中进行,不要在渲染阶段修改。
边界:ref callback vs ref object
useRef 返回的是 ref object({current: ...})。React 还支持 ref callback:
javascript
<div ref={(node) => { /* 用 node 做点什么 */ }} />
ref callback 在每次 DOM 节点变化时调用(传入新节点),卸载时调用一次(传入 null)。和 ref object 不同,callback 能让你在 ref 变化时立即执行逻辑,不需要 useEffect。
现在我们知道了 useRef 的本质就是一个挂载在 Hook 链表上的普通 JavaScript 对象 {current: value},React 完全不监听它的变化,这是它作为"可变逃逸舱"的核心设计 。但 useContext 走了一条完全不同的路径------它甚至不创建 Hook 节点,也不走 mount/update 的标准流程。它的机制是什么?有什么性能陷阱?这就是下一个考点「useContext 原理与性能陷阱」要拆解的内容。
考点 2.8:useContext 原理与性能陷阱
第 0 段:直觉锚定
想象你的公司有一个全员广播系统。CEO 在广播里说"今年的主题色改成蓝色了"------所有开着广播的员工立刻听到,立刻换上蓝色工服。
useContext 就是这个广播的接收端。CEO 是 Context.Provider,广播内容是 context._currentValue,"开着广播的员工"是所有调用了 useContext(MyContext) 的组件。
关键问题在于:广播没有定向功能 。CEO 只说了一句话,但所有人都会听到、都会行动------哪怕你的工位在仓库、根本不需要接触客户,你也被迫换了工服。这就是 useContext 的性能陷阱:没有选择性订阅,只有全量通知。
第 1 段:问题背景
useContext 和其他 Hook 的根本区别
到目前为止我们学的所有 Hook 都遵循同一个模式:mount 时创建 Hook 节点 → update 时克隆 → 处理数据。但 useContext 不走这套流程。
看 Dispatcher 的定义:
arduino
// HooksDispatcherOnMount --- ReactFiberHooks.js:3898
{
useContext: readContext, // ← mount 和 update 用的是同一个函数!
useState: mountState, // ← mount 和 update 不同
useEffect: mountEffect,
// ...
}
// HooksDispatcherOnUpdate --- ReactFiberHooks.js:3926
{
useContext: readContext, // ← 同一个 readContext,没有 mount/update 区分
useState: updateState,
useEffect: updateEffect,
// ...
}
useContext 没有 mount/useCallback 的两条路径。 它每次都做同样的事:读 context._currentValue,然后把当前组件注册为这个 context 的依赖。
为什么 useContext 不创建 Hook 节点
回顾考点 2.1:mountWorkInProgressHook() 创建的 Hook 节点挂在 fiber.memoizedState 上。但 useContext 不调用 mountWorkInProgressHook / updateWorkInProgressHook,它走的是完全不同的机制------fiber.dependencies。
第 2 段:核心数据结构
ReactContext 对象
csharp
// packages/react/src/ReactContext.js
const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue, // 主渲染器的当前值
_currentValue2: defaultValue, // 副渲染器的当前值(如 React Native)
Provider: { $$typeof: REACT_PROVIDER_TYPE, _context: context },
Consumer: { $$typeof: REACT_CONSUMER_TYPE, _context: context },
};
_currentValue 是 context 的"全局状态"------任何时刻只有一个值 ,所有消费者读的都是同一个引用。当 Provider 的 value prop 变化时,React 直接修改 _currentValue。
fiber.dependencies ------ context 依赖链表
readContextForConsumer(ReactFiberNewContext.js:576)在组件读取 context 时,把当前组件注册为依赖:
ini
function readContextForConsumer(consumer, context) {
const value = context._currentValue; // 直接读全局值
const contextItem = {
context: context,
memoizedValue: value,
next: null,
};
if (lastContextDependency === null) {
// 第一个 context 依赖:创建新的依赖链表
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
};
consumer.flags |= NeedsPropagation;
} else {
// 后续 context 依赖:追加到链表
lastContextDependency = lastContextDependency.next = contextItem;
}
return value;
}
关键:context 依赖存在 fiber.dependencies 上,不是 fiber.memoizedState(Hook 链表)上。 这就是为什么 useContext 不受 Hook 链表顺序约束------它根本不在 Hook 链表里。
依赖链表的结构
less
Fiber (消费组件)
├── memoizedState → [useState Hook] → [useEffect Hook] → null ← Hook 链表
│
└── dependencies → { ← Context 依赖
lanes: NoLanes,
firstContext → ContextItem#1 → ContextItem#2 → null
{context, memoizedValue, next}
{context, memoizedValue, next}
}
两套完全独立的数据结构: Hook 链表管 useState/useEffect 等,dependencies 链表管 useContext。
第 3 段:运行流程
完整的 Context 更新传播流程
scss
用户代码:<MyContext.Provider value={newValue}>
│
▼
Provider 的 value prop 变化
│
├─ ① React 修改 context._currentValue = newValue
│ (直接赋值,全局生效)
│
├─ ② propagateContextChanges()
│ 从 Provider 对应的 Fiber 开始,向下遍历子树
│ │
│ ├─ 遇到消费组件(fiber.dependencies.firstContext !== null)
│ │ ├─ 检查依赖链表中是否有匹配的 context
│ │ ├─ 匹配到 → 标记这个 Fiber 需要重新渲染
│ │ │ fiber.lanes |= renderLanes
│ │ └─ 不匹配 → 跳过
│ │
│ └─ 遇到 Provider → 检查是否"屏蔽"了该 context
│ (同名 context 的嵌套 Provider 会创建独立作用域)
│
├─ ③ scheduleUpdateOnFiber()
│ 把被标记的消费组件加入渲染调度
│
└─ ④ 消费组件重新渲染
调用 readContext() → 读到新的 context._currentValue
组件渲染时 readContext 做了什么
css
组件函数执行到 useContext(MyContext):
│
├─ dispatcher.useContext(MyContext)
│ = readContext(MyContext)
│ = readContextForConsumer(currentlyRenderingFiber, MyContext)
│
├─ ① value = MyContext._currentValue ← 读当前值
│
├─ ② 创建 ContextItem {context, memoizedValue: value, next: null}
│
├─ ③ 挂到 fiber.dependencies 链表上 ← 注册依赖
│
└─ ④ return value ← 返回当前值给组件使用
每次渲染都会重新注册依赖。 prepareToReadContext(第 535 行)在组件渲染开始时重置 lastContextDependency = null,然后 readContext 逐个重建依赖链表。
为什么 mount 和 update 用同一个函数
因为 readContext 每次做的事情完全一样:
- 读
_currentValue - 注册依赖
- 返回值
不存在"首次需要初始化,后续需要复用/比较"的区别。context 的值永远是"全局最新值",不需要像 useState 那样维护历史状态。
第 4 段:设计动机与权衡
性能陷阱:无选择性订阅
scss
<MyContext.Provider value={{ theme: 'blue', fontSize: 14 }}>
<Header /> ← 用了 useContext(MyContext),只用 theme
<Content /> ← 用了 useContext(MyContext),只用 fontSize
<Footer /> ← 没用 useContext(MyContext)
</MyContext.Provider>
当 Provider 的 value 变了(比如 { theme: 'blue', fontSize: 15 }):
Header只关心theme,没变,但被迫重新渲染Content关心fontSize,变了,需要重新渲染 ✓Footer没用 context,不重新渲染 ✓
问题根源:useContext 的粒度是整个 context 对象,不是对象中的某个字段。 只要 value 引用变了(哪怕内容只改了一个字段),所有消费者都会重新渲染。
为什么 React 不做细粒度订阅
性能代价 vs 实现复杂度的权衡:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 当前方案(全量通知) | 实现简单,API 简洁 | 消费者全部重渲染 |
| 选择性订阅(只通知用到的字段) | 精确更新 | 需要 Proxy 或 selector,实现复杂 |
| 拆分多个 Context | 精确更新 | API 复杂,Context 嵌套地狱 |
React 选择了第一种------简单但粗暴。官方建议的优化手段是拆分 Context:
xml
// 不好:一个 Context 包所有
<ThemeContext.Provider value={{ theme, fontSize, locale }}>
...
</ThemeContext.Provider>
// 好:拆成多个独立 Context
<ThemeContext.Provider value={theme}>
<FontSizeContext.Provider value={fontSize}>
<LocaleContext.Provider value={locale}>
...
</LocaleContext.Provider>
</FontSizeContext.Provider>
</ThemeContext.Provider>
这样改 fontSize 不会触发只读 theme 的组件重渲染。
另一个常见陷阱:Provider value 内联创建
javascript
// 问题代码:每次渲染都创建新对象
function App() {
return (
<MyContext.Provider value={{ theme: 'blue' }}>
<Child />
</MyContext.Provider>
);
}
每次 App 渲染,value 都是新对象({} 引用不同),React 认为 value 变了 → 所有消费者重渲染。
修复: 把 value 提到组件外或用 useMemo:
javascript
const VALUE = { theme: 'blue' }; // 组件外常量
function App() {
return (
<MyContext.Provider value={VALUE}>
<Child />
</MyContext.Provider>
);
}
第 5 段:次级误解和边界
误解 1:"useContext 会创建 Hook 节点"
不会。 readContext 不调用 mountWorkInProgressHook / updateWorkInProgressHook,所以它不在 Hook 链表上。这也是为什么源码注释(考点 2.3 提到过)说:
less
// Non-stateful hooks (e.g. context) don't get added to memoizedState,
// so memoizedState would be null during updates and mounts.
只用 useContext 的组件,fiber.memoizedState 始终为 null,React 无法通过 memoizedState 判断是 mount 还是 update。
误解 2:"React.memo 能阻止 context 导致的重渲染"
不能。 React.memo 只做 props 比较,不影响 context 的传播 。即使组件被 memo 包裹且 props 没变,只要它消费的 context value 变了,它仍然会重新渲染。
scss
React.memo(Component) 阻止的是:
父组件重渲染 → props 不变 → 子组件不重渲染 ✓
React.memo(Component) 阻止不了的是:
context value 变 → 消费该 context 的 memo 组件 → 仍然重渲染 ✗
误解 3:"useContext 是响应式的"
不是。 useContext 不是像 Vue 的 computed 或 MobX 的 autorun 那样自动追踪依赖、精确响应变化。它的工作方式是:
- 渲染时读一次全局值
- Provider 变了 → 标记消费者需要重渲染 → 下次渲染时读新值
没有 proxy、没有 getter/setter 拦截、没有细粒度依赖追踪。 本质上就是"全局变量 + 全量广播"。
边界:context 变化传播会被 Provider 屏蔽
嵌套的同名 Provider 会创建独立作用域:
xml
<ThemeContext.Provider value="blue">
<Outer /> ← 读到 "blue"
<ThemeContext.Provider value="red">
<Inner /> ← 读到 "red"
</ThemeContext.Provider>
</ThemeContext.Provider>
外层 Provider 从 "blue" 改成 "green" 时,Outer 重渲染,但 Inner 不受影响------因为它的最近 Provider 是内层的 "red"。propagateContextChanges 遍历到内层同名 Provider 时会停止向下传播。
现在我们知道了 useContext 不走 Hook 链表,而是通过 fiber.dependencies 注册依赖,Provider 变化时全量通知所有消费者,这就是它的性能陷阱根源 。但 React 还有一个和 useState 密切相关的 Hook------useReducer,它的 dispatch 机制和 useState 共享底层,但提供了更灵活的状态更新方式。这就是下一个考点「useReducer 与 useState 的关系」要拆解的内容。
考点 2.9:useReducer 与 useState 的关系
第 0 段:直觉锚定
想象你在餐厅点餐。
useState 就像你直接跟厨师喊指令:"加个蛋!"、"换成微辣!"、"再加一碗饭!"。每条指令都是独立的话,厨师记在脑子里(或者忘了)。
useReducer 就像你填了一张标准化表单 。表单上只有几个固定选项:{type: 'ADD_EGG'}、{type: 'SPICY', level: 'mild'}、{type: 'ADD_RICE'}。厨师拿到表单后,查一本操作手册 (reducer 函数),手册里写着"收到 ADD_EGG 就加蛋,收到 SPICY 就调辣度"。所有逻辑集中在手册里,厨师不需要自己判断。
本质上,useState 是 useReducer 的语法糖。React 源码里就是这样实现的。
第 1 段:问题背景
useState 和 useReducer 各自适合什么场景
| 维度 | useState | useReducer |
|---|---|---|
| 状态类型 | 独立的原始值/简单对象 | 复杂状态机、多个子值互相关联 |
| 更新逻辑 | 直接设值或用函数 | 统一的 reducer 函数集中处理 |
| 可测试性 | 逻辑分散在各处 | reducer 是纯函数,独立测试 |
| 调试 | setState 之间无关联 | 可以打日志看 action 流 |
经典例子:一个表单有 name、email、address、phone 四个字段。用 useState 需要四个独立的 state,或者一个 state 对象但每次更新都要展开。用 useReducer 可以用一个 reducer 统一管理所有字段的更新逻辑。
第 2 段:核心数据结构
useState 是 useReducer 的语法糖------源码证据
mount 路径:
ini
// useState 的 mount --- ReactFiberHooks.js:1922
function mountState<S>(initialState): [S, Dispatch] {
const hook = mountStateImpl(initialState);
const queue = hook.queue;
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
// useState 的 mountStateImpl 内部 --- ReactFiberHooks.js:1911
const queue = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer, // ← 内置 reducer
lastRenderedState: initialState,
};
对比 useReducer 的 mount:
ini
// useReducer 的 mount --- ReactFiberHooks.js:1256
function mountReducer<S, I, A>(reducer, initialArg, init?): [S, Dispatch<A>] {
const hook = mountWorkInProgressHook();
const initialState = init !== undefined ? init(initialArg) : initialArg;
hook.memoizedState = hook.baseState = initialState;
const queue = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: reducer, // ← 用户自定义的 reducer
lastRenderedState: initialState,
};
hook.queue = queue;
const dispatch = dispatchReducerAction.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
差异只有三处:
| 差异点 | useState | useReducer |
|---|---|---|
lastRenderedReducer |
basicStateReducer(内置) |
用户传入的 reducer |
| 绑定的 dispatch 函数 | dispatchSetState |
dispatchReducerAction |
| 初始值处理 | typeof initialState === 'function' 则调用 |
支持 init(initialArg) 惰性初始化 |
basicStateReducer------useState 的内置 reducer
csharp
// ReactFiberHooks.js:1251
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
这就是为什么 setState 既接受值也接受函数:
scss
setCount(1); // action = 1 → typeof 1 !== 'function' → return 1
setCount(prev => prev + 1); // action = function → return function(prevState)
basicStateReducer 帮你做了这个分发。
update 路径的共享
csharp
// ReactFiberHooks.js:1936
function updateState<S>(initialState): [S, Dispatch] {
return updateReducer(basicStateReducer, initialState);
}
// ReactFiberHooks.js:1942
function rerenderState<S>(initialState): [S, Dispatch] {
return rerenderReducer(basicStateReducer, initialState);
}
update 和 rerender 时,useState 直接调用 updateReducer/rerenderReducer,只是把 reducer 固定为 basicStateReducer。 这就是"useState 是 useReducer 的语法糖"在源码层面的直接证据。
第 3 段:运行流程
两条 dispatch 路径的对比
scss
用户调用 setState(1):
dispatchSetState(fiber, queue, 1)
├─ requestUpdateLane(fiber) ← 获取优先级
├─ 创建 Update {action: 1, ...}
├─ 【急切计算优化】
│ eagerState = queue.lastRenderedReducer(state, 1)
│ = basicStateReducer(state, 1)
│ = 1
│ if (is(eagerState, state)) → bailout
├─ 否则 enqueueConcurrentHookUpdate
└─ scheduleUpdateOnFiber
用户调用 dispatch({type: 'INCREMENT'}):
dispatchReducerAction(fiber, queue, {type: 'INCREMENT'})
├─ requestUpdateLane(fiber) ← 获取优先级
├─ 创建 Update {action: {type: 'INCREMENT'}, ...}
├─ 没有急切计算优化!← 关键区别
├─ enqueueConcurrentHookUpdate
└─ scheduleUpdateOnFiber
关键区别:dispatchSetState 有急切计算优化(eager evaluation),dispatchReducerAction 没有。
原因在于:useState 的 basicStateReducer 逻辑极简(直接返回 action 或调用 action 函数),在 dispatch 阶段急切计算几乎零成本。而 useReducer 的 reducer 是用户自定义的,可能很重,React 不敢在 dispatch 阶段就调用它------万一 reducer 有副作用(虽然不该有),在 dispatch 阶段执行就出问题了。
updateReducerImpl------两者共享的状态计算核心
render 阶段处理 Update 队列时,updateState 和 updateReducer 最终都走到 updateReducerImpl:
arduino
// ReactFiberHooks.js:1302
function updateReducerImpl(hook, current, reducer) {
const queue = hook.queue;
queue.lastRenderedReducer = reducer; // 更新为最新的 reducer
// 合并 pending queue 和 baseQueue
// 遍历 Update 环形链表
// 逐个处理:优先级够的执行 reducer(state, action)
// 优先级不够的保留在 baseQueue
// 最终:
hook.memoizedState = newState;
hook.baseState = newBaseState;
return [newState, queue.dispatch];
}
无论是 useState 还是 useReducer,状态计算都是 (newState = reducer(newState, action))。 useState 传入的 reducer 是 basicStateReducer,useReducer 传入的是用户自定义 reducer。
第 4 段:设计动机与权衡
为什么 React 要同时提供 useState 和 useReducer
-
API 简洁性 :
const [count, setCount] = useState(0)比const [count, dispatch] = useReducer((s, a) => a, 0)简洁得多。简单状态用useState,复杂状态用useReducer,各得其所。 -
useState 的急切优化 :因为
basicStateReducer是 React 内部函数,React 确保它没有副作用,可以在 dispatch 阶段安全地提前计算。这个优化让setState(sameValue)可以在 dispatch 阶段直接 bailout,连 render 阶段都不进。 -
dispatch 函数签名不同:
useState的setState:接受新值或 updater 函数useReducer的dispatch:接受 action 对象
两种签名服务于不同的心智模型。
useReducer 的隐藏优势:稳定的 dispatch 引用
useState 的 setState 引用是稳定的(考点 2.2 讲过)。useReducer 的 dispatch 同样稳定------它也是 bind 一次后存在 queue.dispatch 上复用。
但 useReducer 有一个额外好处:reducer 函数本身在组件外部定义 ,不需要作为 deps 传入 useEffect/useCallback。对比:
scss
// useState + useEffect:updater 函数需要作为 deps
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // ← setCount 是稳定的,不传也行,但 ESLint 会警告
}
// useReducer + useEffect:dispatch 是稳定的,不需要 deps
function Counter() {
const [count, dispatch] = useReducer((c, a) => c + 1, 0);
useEffect(() => {
const id = setInterval(() => dispatch({type: 'inc'}), 1000);
return () => clearInterval(id);
}, []); // ← dispatch 稳定,reducer 在外部定义,不需要任何 deps
}
惰性初始化------useReducer 独有的特性
scss
// useState 的惰性初始化
const [state, setState] = useState(() => computeExpensiveValue());
// useReducer 的惰性初始化(第三个参数)
const [state, dispatch] = useReducer(reducer, initialArg, init);
// init(initialArg) 只在 mount 时调用,update 时跳过
useReducer 的惰性初始化是三个参数的设计,比 useState 的单函数参数更灵活------可以传入一个初始参数 initialArg 和一个初始化函数 init,两者分离。
第 5 段:次级误解和边界
误解 1:"useReducer 比 useState 性能更好"
不一定。 两者共享同一套 updateReducerImpl 状态计算逻辑,核心循环完全一样。唯一的性能差异是 useState 有急切计算优化,而 useReducer 没有。所以对于"设了相同的值"的场景,useState 反而更快。
误解 2:"useReducer 的 dispatch 和 useState 的 setState 是同一个函数"
不是。 useState 绑定 dispatchSetState,useReducer 绑定 dispatchReducerAction。虽然两者做类似的事(创建 Update、入队、调度),但 dispatchSetState 多了急切计算优化的逻辑。
边界:useState 的 setState 接受函数时不是 reducer
ini
setCount(prev => prev + 1);
这个函数叫 updater function,不是 reducer。区别:
- reducer:
(state, action) => newState,接收 state 和 action 两个参数 - updater:
(prevState) => newState,只接收 prevState 一个参数
basicStateReducer 内部帮你做了分发:如果是函数就当 updater 调用,如果是值就直接用。
边界:useReducer 可以在 render 阶段 dispatch
如果 reducer 返回和当前 state 相同的值(Object.is 判断),React 会 bailout 这个组件------和 useState 的 bailout 行为一致。但如果 reducer 返回新值,会触发渲染阶段更新(rerender),走 rerenderReducer 路径。
问题考核
题目 1: 假设组件内有两个 useState 和一个 useEffect。请描述 fiber.memoizedState 指向的数据结构长什么样。如果此时 useEffect 的 deps 没变,commit 阶段遍历 Effect 链表时,React 是怎么跳过这个 effect 的?
题目 2: useState 的 setState 为什么不需要用 useCallback 包裹?请从源码层面解释 dispatch 引用稳定的原理,以及为什么 update 路径中 queue 必须是引用共享而不是克隆。
题目 3: useContext 不走 Hook 链表(fiber.memoizedState),那它把依赖信息存在哪里?当 Provider 的 value 变化时,React 怎么找到所有消费了这个 context 的组件并触发它们重渲染?这个机制有什么性能陷阱?