全部专栏
考点 3.1:render 阶段 vs commit 阶段
第 0 段:直觉锚定
想象你在装修房子:
- render 阶段就像你在图纸上画设计方案——哪面墙要拆、哪里要加柜子、哪里刷什么颜色。你在纸上改来改去,真正的房子完全没动。如果突然接到更紧急的任务(比如水管爆了),你可以放下图纸先去处理,回来继续画。这个过程是可中断、可恢复的。
- commit 阶段就像施工队按图纸真正动工——砸墙、刷漆、装柜子。一旦开工就得一口气干完,不能停。因为用户(住户)正在看着房子,如果干一半停下来,房子就处于半成品状态。
React 的 render/commit 就是这个关系:render 是纯计算(画图纸),commit 是真实 DOM 变更(施工) 。
第 1 段:问题背景
在 Fiber 之前(React 15 的 Stack Reconciler),渲染是一锅端的——从根节点开始递归 diff,一口气更新完所有 DOM,中间不可中断。这导致长列表或复杂组件树会阻塞主线程,动画掉帧、输入卡顿。
Fiber 架构的核心目标就是把"计算要做什么"和"真正做"拆开:
- render 阶段(也叫 reconcile 阶段):遍历 Fiber 树,对比新旧 props/state,决定每个节点要做什么操作(增/删/改),打上 flags 标记。这个阶段纯计算、无副作用、可中断可恢复。
- commit 阶段:遍历带 flags 的 Fiber 节点,把 render 阶段规划好的变更一次性应用到真实 DOM。这个阶段同步执行、不可中断。
⚠️ 常见误解: 很多人以为 "render 阶段" 就是调用组件的 render 函数(或函数组件本身)。确实 render 阶段包含了调用函数组件,但 render 阶段的范围更大——它是整棵 Fiber 树的遍历过程(beginWork + completeWork),调用函数组件只是其中一环。
第 2 段:核心数据结构
render 阶段和 commit 阶段共享同一棵 Fiber 树,但各自关注不同字段:
FiberNode {
// render 阶段重点关注 ↓
tag: WorkTag, // 节点类型(FunctionComponent / HostComponent / ...)
type: any, // 组件函数或 DOM 标签名
return: Fiber | null, // 父节点
child: Fiber | null, // 第一个子节点
sibling: Fiber | null, // 右兄弟节点
index: number, // 在兄弟中的位置
pendingProps: any, // 本次要处理的新 props
memoizedProps: any, // 上次渲染的 props(用于对比)
memoizedState: any, // Hooks 链表 或 state
alternate: Fiber | null, // 双缓冲的对侧节点
// render → commit 的桥梁 ↓
flags: Flags, // 本节点的操作标记(Placement / Update / Deletion ...)
subtreeFlags: Flags, // 子树中所有 flags 的聚合(优化:跳过无变更子树)
// commit 阶段重点关注 ↓
stateNode: any, // 真实 DOM 节点(HostComponent)或组件实例
}
flags 是两个阶段的桥梁:
render 阶段 → 标记 flags → commit 阶段
"这个节点要插入" Placement 真的调用 appendChild
"这个节点要更新属性" Update 真的调用 setAttribute
"这个节点要删除" Deletion 真的调用 removeChild
旧版 React 用的是
effectList(一条单独的副作用链表),React 18 改为用subtreeFlags做子树聚合。核心思路不变:render 阶段打标,commit 阶段按标施工。
第 3 段:运行流程
整体流程:
触发更新(setState / dispatch)
↓
调度(Scheduler)→ 安排一个 render 任务
↓
┌──── render 阶段(可中断)─────────────┐
│ │
│ workLoopConcurrent() │
│ ↓ │
│ performUnitOfWork(workInProgress) │
│ ├── beginWork() ← 处理"向下"阶段 │
│ │ 对比 props,创建/复用子 Fiber │
│ │ 打上 flags │
│ ↓ │
│ completeWork() ← 处理"向上"阶段 │
│ │ 创建 DOM 节点(内存中) │
│ │ 冒泡子树 flags │
│ ↓ │
│ 检查 shouldYield() → 是否让出主线程? │
│ 是 → 暂停,等下次恢复 │
│ 否 → 继续下一个 unit │
│ │
└────────────────────────────────────────┘
↓ render 完成,得到完整的 wip 树
┌──── commit 阶段(不可中断)───────────┐
│ │
│ commitRoot(root) │
│ ├── BeforeMutation 阶段 │
│ ├── Mutation 阶段(操作 DOM) │
│ ├── 切换 current 指针 │
│ └── Layout 阶段 │
│ │
└────────────────────────────────────────┘
↓
浏览器绘制
源码定位(入口):
- render 阶段入口:
react@18.3.1·packages/react-reconciler/src/ReactFiberWorkLoop.js·renderRootConcurrent(root, lanes) - commit 阶段入口:同文件 ·
commitRootImpl(root, renderPriorityLevel)
render 阶段的核心循环:
// 简化伪代码
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
// 向下:处理当前节点,返回子节点
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 没有子节点了,向上 complete
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next; // 继续向下
}
}
commit 阶段在下一节(3.2)会详细展开,这里先理解它的大结构:三个子阶段 + current 指针切换。
第 4 段:设计动机与权衡
为什么要拆成两个阶段?
核心约束是主线程不可长时间阻塞。浏览器需要在约 16ms 内完成一帧(JS 执行 → 样式计算 → 布局 → 绘制),如果 JS 占着主线程不放,用户就会看到掉帧。
拆成两个阶段的设计:
- render 阶段可以被
shouldYield()打断,让出主线程给浏览器渲染 - commit 阶段虽然不可中断,但它只做 DOM 操作,通常很快(毫秒级)
牺牲了什么?
- 复杂度大增:因为 render 可中断,需要一套完整的"暂停-恢复"机制(workInProgress 树、current 树交替),还要处理中断期间的优先级变化(高优先级任务插队导致低优先级作废)
- 内存开销:维护两棵 Fiber 树(双缓冲)
如果 render 不可中断会怎样? 回到 React 15 的 Stack Reconciler——无法实现 Concurrent Mode,无法做时间切片,复杂页面卡顿。
第 5 段:次级误解和边界
误解 1:"render 阶段不会执行任何副作用"
严格来说,render 阶段不应该有副作用,React 在 StrictMode 下会双调用组件函数来帮你发现意外副作用。但实际上函数组件本体就是在 render 阶段执行的,如果你的组件里有 console.log、修改外部变量等,它确实会执行——只是 React 不保证执行次数和时机。
误解 2:"commit 阶段完全同步,所以所有 hook 回调都在 commit 阶段同步执行"
不完全对。commit 阶段内部有分层:
useLayoutEffect的回调和清理函数在 Layout 子阶段同步执行useEffect的回调和清理函数被调度为异步任务(Passive 阶段),不在 commit 主流程中同步执行
这也是为什么 useLayoutEffect 会阻塞绘制,而 useEffect 不会。
边界条件:如果 render 阶段被中断后,有一个更高优先级的更新进来,React 可能会丢弃当前未完成的 wip 树,从头开始。这就是"饥饿"场景——低优先级任务可能反复被抢走而永远完不成。React 有饥饿救援机制(starvedLanes),在特定条件下自动提升低优先级任务的优先级。
交接:
现在我们知道了 React 把一次更新分为 render(纯计算、可中断)和 commit(DOM 变更、不可中断)两个大阶段,render 阶段通过 flags 标记告诉 commit 阶段该做什么。但 commit 阶段内部的三个子阶段具体各自做什么、为什么要分三步——这是考点 3.2 要处理的事情。
考点 3.2:commit 的三个子阶段
第 0 段:直觉锚定
延续装修的类比。施工队拿到图纸后(flags 标记),不是一窝蜂上去乱干,而是严格按工序来:
- 拆旧(BeforeMutation)— 先拍照记录旧房间样子,通知住户"要开始动了"
- 施工(Mutation)— 砸墙、刷漆、装柜子,真正的物理变更
- 验收(Layout)— 住户走进来看效果,量尺寸确认没问题
这三步的顺序不可颠倒:必须先拆旧、再施工、再验收。而且住户从"旧房子"搬到"新房子"这个动作,精确地发生在施工和验收之间(current 指针切换)。
第 1 段:问题背景
commit 阶段要处理三类性质完全不同的工作:
- DOM 操作:插入、更新、删除节点 — 这是物理变更
- 生命周期 / hooks 回调:
componentDidMount、componentDidUpdate、useLayoutEffect— 组件需要知道"DOM 已经变了" - useEffect 调度:不紧急的副作用可以延后执行
如果把这些全部混在一起顺序执行,会有问题:
- 组件的
useLayoutEffect回调里可能读取 DOM 布局信息(getBoundingClientRect),所以它必须在 DOM 变更之后执行 - 但
useEffect不需要同步执行,应该尽量晚地异步调度,避免阻塞浏览器绘制 componentWillUnmount这种清理工作,需要在 DOM 删除之前执行,否则访问不到要清理的 DOM 节点
所以 React 把 commit 阶段精确地分成了三个子阶段,每个阶段处理特定类型的副作用。
⚠️ 常见误解: 很多人以为
useEffect的回调在 commit 阶段同步执行。实际上 commit 阶段只是把useEffect的回调收集起来丢给调度器,真正的执行是在 commit 结束后的微任务/宏任务中。只有useLayoutEffect才是同步执行的。
第 2 段:核心数据结构
每个子阶段遍历的都是同一批带 flags 的 Fiber 节点,但各自关注不同的 hook 标记:
FiberNode {
flags: Flags, // 节点级操作标记
subtreeFlags: Flags, // 子树聚合标记
// Hooks 链表中每个 hook 也有自己的标记
hook.flags: HookFlags, // HookHasEffect = 这个 effect 需要执行
}
// 三类 flags 举例
Placement // 要插入(appendChild / insertBefore)
Update // 要更新属性
Deletion // 要删除(removeChild)
ChildDeletion // 子节点有删除
Ref // ref 变更
Layout // 有 useLayoutEffect 需要执行
Passive // 有 useEffect 需要调度
关键 HookFlags:
HookHasEffect // 二进制标记,表示这个 effect 的 deps 变了,需要执行/重新执行
render 阶段在处理 useEffect/useLayoutEffect 时,如果 deps 变了,就会给对应的 hook 打上 HookHasEffect。commit 阶段遍历时,只执行带 HookHasEffect 标记的 effect,跳过没变的。
第 3 段:运行流程
commitRootImpl(root, renderPriorityLevel)
│
├── ① BeforeMutation 阶段
│ commitBeforeMutationEffects(root, finishedWork)
│ ├── 遍历带 flags 的 Fiber 树(DFS)
│ ├── Deletion 节点:调用 getSnapshotBeforeUpdate(类组件)
│ └── 所有节点:调度 useEffect(Passive effects)
│ → 调用 scheduleCallback() 把 passive effects 丢进调度器
│ → 注意:这里只是调度,不执行!
│
├── ② Mutation 阶段
│ commitMutationEffects(root, finishedWork, lanes)
│ ├── Placement:执行 appendChild / insertBefore
│ ├── Update:执行 DOM 属性更新(如 style、className、src 等)
│ ├── Deletion:执行 removeChild,递归卸载子树
│ │ └── 同时调用 componentWillUnmount / 清理 ref
│ └── Ref:分离旧 ref
│
├── ★ current 指针切换
│ root.current = finishedWork // wip 树正式成为 current 树
│ // 这个切换精确地在 mutation 和 layout 之间
│
├── ③ Layout 阶段
│ commitLayoutEffects(root, finishedWork, lanes)
│ ├── 同步调用 componentDidMount(类组件首次挂载)
│ ├── 同步调用 componentDidUpdate(类组件更新)
│ ├── 同步执行 useLayoutEffect 回调(函数组件)
│ │ └── 只执行带 HookHasEffect 标记的
│ └── 更新 ref(设置新 ref.current)
│
└── 调度 Passive effects 执行
→ 在独立任务中执行 useEffect 回调和清理函数
→ 通过 flushPassiveEffects() 实现
源码定位:
- 总入口:
react@18.3.1·packages/react-reconciler/src/ReactFiberWorkLoop.js·commitRootImpl(root, renderPriorityLevel) - BeforeMutation:同文件 ·
commitBeforeMutationEffects(root, firstChild)→ 委托到ReactFiberCommitWork.js - Mutation:同文件 ·
commitMutationEffects(root, firstChild, lanes)→ 委托到ReactFiberCommitWork.js·commitMutationEffectsOnFiber(fiber, root, lanes) - Layout:同文件 ·
commitLayoutEffects(root, firstChild, lanes)→ 委托到ReactFiberCommitWork.js·commitLayoutEffectOnFiber(fiber, root, lanes)
打开
ReactFiberWorkLoop.js,找到commitRootImpl函数(大约在文件中部),你可以看到这三个阶段严格按顺序排列,中间有一行root.current = finishedWork就是 current 指针切换。
Mutation 阶段的 DOM 操作细节(简化):
function commitReconciliationEffects(finishedWork) {
const flags = finishedWork.flags;
if (flags & Placement) {
// 找到父 DOM 节点和兄弟 DOM 节点
const parentFiber = getHostParentFiber(finishedWork);
const parentDOM = parentFiber.stateNode;
// 执行插入
insertOrAppendPlacementNode(finishedWork, parentDOM);
}
}
function commitMutationEffectsOnFiber(finishedWork, root, lanes) {
const flags = finishedWork.flags;
if (flags & Update) {
// 更新 DOM 属性
switch (finishedWork.tag) {
case HostComponent:
updateDOMProperties(finishedWork, finishedWork.memoizedProps, ...);
break;
// ...
}
}
if (flags & ChildDeletion) {
// 删除子节点
for (const childDeletion of finishedWork.deletions) {
recursivelyUnmountNode(childDeletion);
}
}
}
第 4 段:设计动机与权衡
为什么是三个子阶段,不是两个或四个?
核心约束是 DOM 操作和回调的时序必须精确:
| 阶段 | DOM 状态 | 能做什么 | 不能做什么 |
|---|---|---|---|
| BeforeMutation | 旧 DOM | 读取旧 DOM 快照、调度 passive effects | 修改 DOM |
| Mutation | 正在变更 | 执行所有 DOM 操作 | 读取"最终"布局(还没完) |
| Layout | 新 DOM | 读取新布局、同步回调 | 修改 Fiber 树(已提交) |
这种三段设计保证:
getSnapshotBeforeUpdate在 DOM 变更前能拿到旧布局useLayoutEffect在 DOM 变更后能读到新布局useEffect被尽早调度但不阻塞 commit
current 指针切换的时机为什么在 mutation 和 layout 之间?
这是精心设计的。mutation 之后,DOM 已经是新的了。此时如果组件在 useLayoutEffect 里触发 setState,它需要基于"当前已经生效的状态"来创建更新——所以 current 指针必须已经指向新树。但如果在 mutation 之前切换,mutation 阶段的 DOM 操作就无法通过 current 找到旧 DOM 节点来执行 removeChild。
第 5 段:次级误解和边界
误解 1:"三个子阶段各自独立遍历 Fiber 树,性能开销是 3 倍"
实际上 React 18 做了 subtreeFlags 优化。render 阶段在 completeWork 中,会把子节点的 flags 冒泡聚合到父节点的 subtreeFlags 上。commit 阶段遍历时,如果一个节点的 subtreeFlags 为空,说明整棵子树都没有变更,直接跳过。这避免了 React 15 的 effectList 方案需要维护一条额外链表的开销。
误解 2:"useEffect 的清理函数和回调都是异步的,所以它们之间没有时序保证"
实际上 React 保证:对于同一个 effect,先执行清理函数,再执行新的回调。并且同一轮渲染中的所有 effect 清理函数会批量执行完毕后,才开始执行 effect 回调。顺序严格按照 Fiber 树的 DFS 遍历顺序。
边界条件:unmount 场景
当组件被卸载(父节点标记了 ChildDeletion)时:
componentWillUnmount/ ref 清理在 Mutation 阶段同步执行(DOM 还没被删除时)useEffect的清理函数在 Passive 阶段异步执行(DOM 已经被删除了)- 这也是为什么
useEffect清理函数里不应该访问 DOM——节点可能已经被移除了
交接:
现在我们知道了 commit 阶段内部被分为 BeforeMutation / Mutation / Layout 三个子阶段,每个阶段处理不同类型的副作用,current 指针切换精确地发生在 Mutation 和 Layout 之间。render 阶段通过 flags 标记告诉 commit 要做什么——但 flags 到底有哪些类型、怎么被打上的、subtreeFlags 的冒泡机制是什么——这是考点 3.3 要处理的事情。
考点 3.3:flags / subtreeFlags 是什么
第 0 段:直觉锚定
回到装修类比。施工队拿到的不是一张写满文字的说明书,而是一套彩色标签系统:
- 🔴 红标 = 要新建(Placement)
- 🟡 黄标 = 要翻新(Update)
- ⚫ 黑标 = 要拆除(Deletion)
每个房间(Fiber 节点)上贴着自己的标签,每个楼层(父节点)的门上还贴了一张汇总贴:"本楼层有 3 个红标、1 个黄标"。施工队走到一楼层,先看汇总贴——如果一张标都没有,直接跳过整层,不用挨个房间检查。
这个"汇总贴"就是 subtreeFlags。
第 1 段:问题背景
React 需要在 render 阶段结束后,高效地找出"哪些节点有变更"。有三种方案:
| 方案 | 思路 | 缺点 |
|---|---|---|
| 遍历整棵树 | commit 时 DFS 全树,检查每个节点 | O(n) 全量遍历,即使只有 1 个节点变了 |
| effectList 链表(React ≤17) | render 阶段维护一条"有变更节点"的链表 | 需要额外维护链表指针,删节点时链表操作复杂 |
| subtreeFlags 聚合(React ≥18) | flags 冒泡到父节点,commit 时跳过无变更子树 | 需要 completeWork 中做冒泡,但查询 O(1) |
React 18 选了第三种方案,核心思路:用位运算实现 O(1) 判断"这棵子树有没有活要干" 。
⚠️ 常见误解: 很多人以为
flags是一个数组或字符串。实际上 flags 是一个二进制位掩码(bitmask) ,一个数字就能同时表示多种标记。比如0b1010同时表示"有 Update"和"有 Ref 变更"。
第 2 段:核心数据结构
flags 的定义(位掩码,每个标记占一个二进制位):
// packages/react-reconciler/src/ReactFiberFlags.js
export const NoFlags = 0b00000000000000000000;
export const Placement = 0b00000000000000000010; // 2 — 需要插入
export const ChildDeletion = 0b00000000000000010000; // 16 — 子节点有删除
export const Deletion = 0b00000000000000100000; // 32 — 自己要被删除
export const Update = 0b00000000000010000000; // 128 — 属性/状态更新
export const Ref = 0b00000001000000000000; // 512 — ref 变更
export const Layout = 0b00000010000000000000; // 1024 — 有 useLayoutEffect
export const Passive = 0b00000100000000000000; // 2048 — 有 useEffect
export const ChildInsertion = 0b... // 子节点有插入
位运算操作:
// 打标(添加标记)
fiber.flags |= Placement; // 按位或:把 Placement 位设为 1
// 检查(是否有某个标记)
if (fiber.flags & Placement) // 按位与:对应位是否为 1
// 组合检查
if (fiber.flags & (Placement | Update)) // 是否有插入或更新
// 清除
fiber.flags &= ~Placement; // 按位与取反:把 Placement 位清零
subtreeFlags 的含义:
FiberNode {
flags: number, // 本节点的标记
subtreeFlags: number, // 整棵子树所有节点 flags 的按位或(OR)聚合
deletions: Fiber[]|null, // 被删除的子节点列表(只有标记了 ChildDeletion 时才有)
}
一个具体例子(3 个节点的子树):
div flags=Update subtreeFlags=Update|Placement
/ (子树中有 Update 和 Placement)
p flags=Placement subtreeFlags=0
(叶子节点,没有子树)
span flags=0 subtreeFlags=0
父节点 div 的 subtreeFlags = div.flags | p.flags | span.flags = Update | Placement。commit 阶段检查 div.subtreeFlags 时,发现非零,就知道子树中有活要干,需要继续深入遍历。
第 3 段:运行流程
flags 的打标时机(render 阶段):
flags 在 render 阶段的 beginWork 和 completeWork 中被打上:
beginWork(current, workInProgress, renderLanes)
│
├── reconcileChildFibers() // 对比子节点
│ ├── 新增子节点 → child.flags |= Placement
│ ├── 删除子节点 → parent.flags |= ChildDeletion
│ │ parent.deletions = [deletedChild, ...]
│ └── 复用但 key/type 变 → 旧节点.flags |= Deletion
│ 新节点.flags |= Placement
│
└── updateHostComponent() // 对比 HostComponent props
└── props 变了 → workInProgress.flags |= Update
subtreeFlags 的冒泡(completeWork 中):
completeWork(current, workInProgress, renderLanes)
│
├── 创建/复用 DOM 节点(HostComponent / HostText)
├── 对比 props,打 Update flag
│
└── 冒泡子树 flags ← 这是关键步骤
bubbleProperties(workInProgress);
function bubbleProperties(workInProgress) {
let subtreeFlags = NoFlags;
// 遍历所有子节点,聚合 flags
let child = workInProgress.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags; // 子节点的子树聚合
subtreeFlags |= child.flags; // 子节点自身的 flags
child = child.sibling;
}
workInProgress.subtreeFlags = subtreeFlags;
}
源码定位:
- flags 定义:
react@18.3.1·packages/react-reconciler/src/ReactFiberFlags.js— 所有 flag 常量 - 打标(beginWork 中子节点对比) :
packages/react-reconciler/src/ReactChildFiber.js·reconcileChildFibers()— 在placeChild、deleteChild等辅助函数中打标 - 冒泡(completeWork) :
packages/react-reconciler/src/ReactFiberCompleteWork.js·bubbleProperties(fiber)— 在文件底部
你可以打开
ReactChildFiber.js,搜索Placement或ChildDeletion,能看到在哪些场景下被打上标记。
commit 阶段如何利用 subtreeFlags 跳过子树:
function commitMutationEffectsOnFiber(finishedWork, root, lanes) {
const flags = finishedWork.flags;
// 先处理本节点的直接操作
if (flags & Placement) { /* 插入 DOM */ }
if (flags & Update) { /* 更新属性 */ }
if (flags & Deletion) { /* 删除 */ }
// 再递归处理子树
if (finishedWork.subtreeFlags & MutationMask) {
// 子树中有需要 mutation 的操作,继续深入
let child = finishedWork.child;
while (child !== null) {
commitMutationEffectsOnFiber(child, root, lanes);
child = child.sibling;
}
}
// 否则 subtreeFlags 为 0,整棵子树直接跳过!
}
MutationMask 是一个组合掩码 = Placement | Update | Deletion | ChildDeletion | ...,只要 subtreeFlags 与它按位与为 0,说明子树中没有任何 mutation 相关的操作。
第 4 段:设计动机与权衡
为什么 React 18 从 effectList 切换到 subtreeFlags?
旧方案(effectList):
- render 阶段每处理一个有变更的节点,就把它链入一条全局链表
- commit 阶段遍历这条链表即可,不需要遍历整棵树
- 问题:删除节点时,需要从链表中摘除节点(链表操作);而且链表是全局的,无法做子树级别的优化
新方案(subtreeFlags):
- 不需要维护额外的数据结构,flags 已经存在于每个 Fiber 节点上
- 子树级别的剪枝:如果一棵子树没变更,O(1) 跳过
- 删除场景简化:
deletions数组直接挂在父节点上
牺牲了什么?
- completeWork 中每次都要遍历子节点做冒泡,多了一层 O(子节点数) 的遍历。但这个开销很小(只是位运算 OR),而且只遍历直接子节点,不递归。
第 5 段:次级误解和边界
误解 1:"flags 只在 render 阶段被打上"
不完全准确。Deletion flag 的处理比较特殊——被删除的节点不会进入正常的 beginWork/completeWork 流程(它不在新的 wip 树中)。React 在 reconcileChildFibers 时发现旧子节点被删除,会给父节点打上 ChildDeletion,并把被删子节点收集到 parent.deletions 数组中。commit 阶段遍历 deletions 数组来处理删除。
误解 2:"flags 和 HookFlags 是同一个东西"
它们是不同层级的标记:
fiber.flags— Fiber 节点级别的操作标记(Placement / Update / Deletion 等),由 reconciler 使用hook.flags(即 HookFlags 中的HasEffect)— Hook 级别的标记,表示某个 effect hook 是否需要执行/重新执行
它们的关系:beginWork 在处理函数组件时,如果发现某个 useEffect 的 deps 变了,给 hook 打上 HookHasEffect,同时给 fiber 打上 Passive flag。commit 阶段看到 fiber 有 Passive flag 就知道这个组件有 useEffect 要处理,再通过 hook 链表找到带 HookHasEffect 标记的具体 hook。
边界条件:Ref flag 的特殊性
Ref flag 不走 mutation 阶段的常规处理。它的清理(置 null)在 mutation 阶段,设置新值在 layout 阶段。这是因为 ref 可能在 useLayoutEffect 回调中被使用,所以必须在 layout 阶段之前清理旧 ref、在 layout 阶段设置新 ref。
交接:
现在我们知道了 flags 是位掩码标记,subtreeFlags 通过 completeWork 中的冒泡实现子树级别的剪枝优化,commit 阶段用位运算 O(1) 判断是否需要深入子树。整个流程是 render 打标 → completeWork 冒泡 → commit 按标施工。但在 render 阶段开始之前,组件的 JSX 是怎么变成 Fiber 节点的——React.createElement 和 JSX 编译器做了什么——这是考点 3.4 要处理的事情。
考点 3.4:React.createElement 与 JSX transform
第 0 段:直觉锚定
你写了一封中文信(JSX),但浏览器只认英文电报格式(React.createElement 调用)。中间需要一个翻译官——这就是 JSX 编译器(Babel / TypeScript Compiler)。
翻译官做的事很简单:看到 <div className="box"><span>hello</span></div>,就把它翻成 React.createElement('div', {className: 'box'}, React.createElement('span', null, 'hello'))。浏览器收到的永远是一串 createElement 调用,从不直接看到 JSX。
但 React 17 之后,翻译官换了一个更高效的新版本(Automatic JSX Transform),它不再翻译成 React.createElement,而是翻译成两个更底层的运行时函数——就像翻译官升级了,从"先翻译成中间语言再解释"变成了"直接翻译成目标机器码" 。
第 1 段:问题背景
JSX 不是 JavaScript 标准语法,浏览器无法直接运行。所以 JSX 必须经过编译:
旧方案(Classic Transform,React ≤16) :
// 你写的
<div id="app"><Hello name="world" /></div>
// 编译后
React.createElement('div', {id: 'app'}, React.createElement(Hello, {name: 'world'}))
这带来几个问题:
- 每个使用 JSX 的文件都必须手动
import React from 'react',因为编译产物引用了React.createElement React.createElement每次调用都创建一个对象,即使这些对象可能被丢弃(比如组件 return null)- 运行时做了太多工作:key 提取、ref 提取、defaultProps 合并、children 收集
新方案(Automatic Transform,React 17+) :
// 你写的
<div id="app"><Hello name="world" /></div>
// 编译后(不再引用 React.createElement!)
import { jsx as _jsx } from 'react/jsx-runtime';
_jsx('div', {id: 'app', children: _jsx(Hello, {name: 'world'})});
好处:
- 不需要手动 import React
- 编译器直接调用更轻量的
jsx(),跳过 createElement 的中间层 - 运行时工作量更小
⚠️ 常见误解: "JSX 就是模板语言,像 Vue 的 template"。JSX 本质是
createElement调用的语法糖,它是 JavaScript 表达式,可以任意嵌套逻辑(三元运算、map、变量等)。Vue template 有独立的模板编译器,受模板语法限制;JSX 没有。
第 2 段:核心数据结构
React.createElement 返回的是一个普通 JavaScript 对象——React Element(不是 Fiber):
// createElement 返回的 Element 对象
{
$$typeof: Symbol(react.element), // 类型标识,防 XSS 注入
type: string | Function, // 'div' / Hello 函数 / ClassComponent
key: string | null, // 从 props.key 提取出来
ref: any, // 从 props.ref 提取出来
props: { // 原始 props(不含 key 和 ref)
id: 'app',
children: [ // 子元素,可能是 Element / string / number
{ $$typeof: Symbol(react.element), type: Hello, ... }
]
},
_owner: Fiber | null, // 创建该 Element 的组件(用于 ref 追踪)
}
Element vs Fiber 的关系(这是关键):
你写的 JSX
↓ Babel 编译
React.createElement() 调用
↓ 运行时执行
React Element 对象(纯数据描述)
↓ render 阶段 beginWork
Fiber 节点(带指针、状态、flags 的工作单元)
Element 是静态的描述——"我要一个 div,props 是这些,子元素是这些"。它不关心怎么渲染、怎么更新、挂在树上的什么位置。
Fiber 是动态的工作单元——"这个 div 对应哪个真实 DOM、它上次渲染的 props 是什么、它有没有被打上 flags"。
一个 Element 可能对应多个 Fiber(在不同的渲染周期中),也可能不对应任何 Fiber(如果它被丢弃了)。
第 3 段:运行流程
createElement 的源码逻辑(简化):
// packages/react/src/ReactElement.js
function createElement(type, config, children) {
const props = {};
let key = null;
let ref = null;
// 1. 从 config 中提取 key 和 ref(它们不属于 props)
if (config != null) {
if (config.key !== undefined) key = '' + config.key;
if (config.ref !== undefined) ref = config.ref;
// 2. 剩余属性拷贝到 props
for (const propName in config) {
if (propName !== 'key' && propName !== 'ref'
&& Object.prototype.hasOwnProperty.call(config, propName)) {
props[propName] = config[propName];
}
}
}
// 3. 处理 children(第三个及之后的参数)
const childrenLength = arguments.length - 3;
if (childrenLength === 1) {
props.children = children[0]; // 单子节点:直接赋值
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 3]; // 多子节点:数组
}
props.children = childArray;
}
// 4. 合并 defaultProps(类组件场景)
if (type && type.defaultProps) {
for (const propName in type.defaultProps) {
if (props[propName] === undefined) {
props[propName] = type.defaultProps[propName];
}
}
}
// 5. 返回 Element 对象
return ReactElement(type, key, ref, self, source, owner, props);
}
新 JSX Runtime(jsx())的逻辑:
// packages/react/src/jsx.js
function jsx(type, config, maybeKey) {
// 比 createElement 更简单:
// - 不需要处理 arguments(编译器直接传 config)
// - key 直接作为第三个参数传入,不需要从 config 提取
// - 不需要处理 defaultProps(已废弃)
const props = {};
let key = null;
if (maybeKey !== undefined) key = '' + maybeKey;
for (const prop in config) {
if (Object.prototype.hasOwnProperty.call(config, prop)
&& prop !== 'key') { // 只跳过 key,ref 现在也放进 props 了!
props[prop] = config[prop];
}
}
return ReactElement(type, key, undefined, undefined, null, owner, props);
}
注意一个关键差异:在旧方案中,ref 从 props 中被提取出来单独存放;在新方案(jsx runtime)中,ref 保留在 props 中,由后续的 reconciler 处理提取。这简化了 createElement 的逻辑。
源码定位:
- createElement:
react@18.3.1·packages/react/src/ReactElement.js·createElement(type, config, children)— 在文件前半部分 - jsx runtime:
react@18.3.1·packages/react/src/jsx.js·jsx(type, config, maybeKey)和jsxs(type, config, maybeKey)(jsxs用于有多个 children 的场景) - ReactElement 工厂函数:同文件 ·
ReactElement(type, key, ref, self, source, owner, props)— 只是一个对象字面量构造
第 4 段:设计动机与权衡
为什么 $$typeof 用 Symbol?
这是一个安全设计。React Element 最终可能被序列化后存入 JSON(比如 Server Components 场景)。如果没有 $$typeof 标识,攻击者可以构造一个看起来像 Element 的恶意对象注入到 React 渲染流程中。用 Symbol 做 $$typeof 的好处是:Symbol 无法从 JSON 中构造,JSON.parse 解析后的对象不会有 Symbol 属性,所以恶意伪造的 Element 不会通过 React 的类型检查。
为什么 ref 在新旧方案中处理方式不同?
旧方案(createElement)把 ref 从 props 中剥离,单独存在 Element 上。这导致一个历史问题:如果用 {...props} 展开传递,ref 会被意外透传给子组件。React 19 彻底将 ref 作为普通 prop 处理,由 reconciler 在需要时提取——这就是新 jsx runtime 把 ref 保留在 props 中的原因。
权衡:
- createElement 是运行时函数,每次组件渲染都会被调用, createElement 的开销是可测量的(虽然小)
- 新 jsx runtime 让编译器承担了更多工作(key 提取、children 收集),运行时只做最小化处理
第 5 段:次级误解和边界
误解 1:"React Element 就是虚拟 DOM"
严格来说,Element 是虚拟 DOM 的描述单元,但"虚拟 DOM"更准确地对应 Fiber 树。Element 只描述"要渲染什么",Fiber 才描述"怎么渲染、和真实 DOM 的映射关系、上一次的状态"。Element 是一次性数据(每次渲染都创建新的),Fiber 是持久的(跨渲染复用)。
误解 2:"JSX 编译后一定是 React.createElement"
从 React 17 开始不一定是了。取决于编译器配置:
- Classic Transform →
React.createElement - Automatic Transform →
jsx()/jsxs()fromreact/jsx-runtime
Next.js、Vite、现代 CRA 都默认使用 Automatic Transform。只有老旧项目或手动配置 Babel 的项目可能还在用 Classic。
边界条件:React.cloneElement
cloneElement(element, [config], [...children]) 接受一个已有的 Element,合并新的 props/key/ref,返回新 Element。它内部也是调用 ReactElement 工厂函数,但会从原始 Element 上继承 type、未覆盖的 props、_owner 等。它不复制 Fiber——因为 Element 和 Fiber 是一对多关系,clone 一个 Element 只是创建了新的描述数据。
交接:
现在我们知道了 JSX 经过编译变成 createElement 或 jsx() 调用,运行时生成 React Element(纯描述对象),Element 在 render 阶段被 beginWork 消费,转化为 Fiber 节点。整个链路是 JSX → 编译 → Element → Fiber → DOM。但这里有一个开发中经常遇到的困惑:setState 之后立即 console.log(state),为什么读到的还是旧值——这是考点 3.5 要处理的事情。
考点 3.5:为什么 setState 之后不能立即读到新 state
第 0 段:直觉锚定
想象你在餐厅点菜:
你:服务员,加一份红烧肉!(setState)
你:那我现在桌上有什么菜?(console.log(state))
服务员:桌上还是原来的菜。红烧肉在厨房排队呢,还没做出来。
// ... 过了一会儿 ...
服务员:红烧肉好了,端上来了。(组件重新渲染,新 state 可用)
setState 本质上是一张订单——你把"我要什么变更"提交到队列,然后控制权立刻返回给你。此时订单还没被处理(render 阶段还没发生),所以你读到的 state 仍然是旧的。只有当 React 真正处理了这张订单(重新执行组件函数),新 state 才会出现。
第 1 段:问题背景
这是 React 初学者最常遇到的"反直觉"行为:
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count); // 输出 0,不是 1!
}
很多人期望 setCount(1) 之后 count 立刻变成 1,但它还是 0。
根本原因:React 的更新是异步调度的,不是同步赋值。
setState 做的事情仅仅是:
- 创建一个 update 对象
- 把它放入 fiber 的更新队列
- 调度一次渲染(安排 render 任务)
它不修改当前变量。 新 state 要等到下次组件渲染时,updateReducer 遍历队列、计算新值后才能拿到。
⚠️ 常见误解: "setState 是异步的"。严格来说,setState 的执行是同步的(它立刻把 update 入队了),但状态更新和渲染是被调度延后的。在 Concurrent Mode 下,render 可能被延迟甚至被打断;在 Legacy Mode 的生命周期中,有时会出现同步批量(同步执行但批量合并)。
第 2 段:核心数据结构
回顾 Hooks 考点中讲过的 update 队列结构:
// dispatch (即 setState) 做了什么
dispatchSetState(fiber, queue, action)
│
├── 创建 update 对象
│ {
│ lane: requestUpdateLane(fiber), // 优先级
│ action: action, // 你传的值(1)或函数(prev => prev + 1)
│ hasEagerState: false,
│ eagerState: null,
│ next: null, // 环形链表指针
│ }
│
├── 入队到 fiber.memoizedState.queue
│ // queue 是一个环形链表:
│ // queue.pending → 最近的 update → ... → 最早的 update → 回到最近的
│
└── 调度渲染
scheduleUpdateOnFiber(fiber, lane, eventTime)
queue 的结构(环形链表):
Hook {
memoizedState: 0, // 当前 state 值(还是 0!)
queue: {
pending: update2 → update1 → update2 (环形)
dispatch: dispatchSetState.bind(null, fiber, queue),
lastRenderedState: 0, // 上次渲染时计算的 state
}
}
dispatch 之后,queue.pending 指向新 update,但 memoizedState 没有变。它要等到下次 render 时 updateReducer 遍历 pending 链表才会被更新。
第 3 段:运行流程
完整时序(从 setState 到新 state 可用):
① 用户点击 → handleClick() 执行
│
├── setCount(1) // dispatch
│ ├── 创建 update { action: 1, lane: ... }
│ ├── 入队到 hook.queue.pending
│ ├── scheduleUpdateOnFiber()
│ │ └── 安排一个 render 任务(可能异步)
│ └── 返回(同步完成,但 state 没变)
│
├── console.log(count) // 输出 0
│ // 此时 count 仍然是本次渲染闭包中的值
│ // 组件函数还没重新执行,新 state 还没算出来
│
│ ... 调度器安排渲染 ...
│
② render 阶段开始
│
├── beginWork 处理该 Fiber 节点
│ └── renderWithHooks() 重新执行组件函数
│ └── useState() → 调用 updateReducer
│ ├── 取出 hook.queue.pending 链表
│ ├── 遍历所有 update,计算新 state:
│ │ let newState = hook.memoizedState; // 0
│ │ for each update in pending:
│ │ newState = basicStateReducer(newState, update.action);
│ │ // newState = 1
│ │
│ ├── hook.memoizedState = newState; // 终于更新为 1!
│ ├── 清空 queue.pending
│ └── 返回 [newState, dispatch]
│
│ // 此时组件函数体内 count = 1(新闭包)
│
③ commit 阶段完成 → 浏览器绘制新 UI
源码定位:
- dispatch 入口:
react@18.3.1·packages/react-reconciler/src/ReactFiberHooks.js·dispatchSetState(fiber, queue, action)— 入队 update + 调度渲染 - state 计算:同文件 ·
updateReducer(reducer, initialArg, init)— 遍历 pending 链表,计算新 state - 调度入口:
packages/react-reconciler/src/ReactFiberWorkLoop.js·scheduleUpdateOnFiber(root, fiber, lane, eventTime)
Eager State 优化(特殊情况):
React 在 dispatchSetState 中有一个优化路径——如果当前没有正在进行的渲染,且 fiber 的 lanes 为空,React 会立即同步计算新 state:
// dispatchSetState 中的 eager 计算逻辑(简化)
if (fiber.lanes === NoLanes && (!alternate || alternate.lanes === NoLanes)) {
// 没有进行中的渲染,可以立即试算
const lastRenderedState = queue.lastRenderedState;
const eagerState = basicStateReducer(lastRenderedState, action);
if (Object.is(eagerState, lastRenderedState)) {
// 新值和旧值一样,跳过调度!
return;
}
queue.hasEagerState = true;
queue.eagerState = eagerState;
}
// 无论是否 eager,都需要 scheduleUpdateOnFiber
但即使走了 eager 计算,它也只是提前算出了新值存在 queue 上,并没有修改组件闭包中的变量。你当前事件处理函数中的 count 仍然是旧闭包的 0。
第 4 段:设计动机与权衡
为什么不设计成同步更新?
同步更新意味着每次 setState 立刻触发一次完整渲染:
handleClick() {
setState(1); // 渲染一次整棵树
setState(2); // 又渲染一次整棵树
setState(3); // 又渲染一次整棵树
}
// 总共渲染 3 次!
React 的做法是批量更新(batching) :收集所有 setState,统一处理,只渲染一次:
handleClick() {
setState(1); // 入队
setState(2); // 入队
setState(3); // 入队
}
// 调度器触发一次渲染,遍历队列 [1,2,3],最终 state = 3
// 总共只渲染 1 次
权衡:
- 优点:性能好,避免无意义的中间渲染
- 代价:开发者不能在 setState 后立刻读到新值,心智模型更复杂
- React 提供了
flushSync可以强制同步刷新(下一节会讲到),但这会放弃批量更新的性能优势
第 5 段:次级误解和边界
误解 1:"setState 永远是异步的"
不完全是。在以下场景中,React 会同步完成渲染(但 state 仍然不能在当前闭包中读到):
flushSync(() => setState(...))包裹时- 在 React 18 之前的
setTimeout/Promise/ 原生事件回调中的 setState(React 18 统一为自动批量,不再有此差异)
即使同步渲染完成,当前函数闭包中的 count 也不会变——因为闭包中的值是快照,不会因为外部状态变更而改变。只有组件函数重新执行时,新闭包才会捕获新值。
误解 2:"用函数式 setState 可以拿到最新值"
setCount(prev => {
console.log(prev); // 这里确实是最新的!
return prev + 1;
});
是的,函数式 updater 的参数 prev 是 React 在 updateReducer 遍历队列时传入的最新计算值。但这不是"在 setState 之后立即拿到",而是你提前注册了一个计算函数,React 在真正计算 state 时会调用它。此时你在外部仍然读不到新值。
边界条件:多次 setState 的合并
const [state, setState] = useState({ count: 0 });
setState({ count: 1 });
setState({ count: 2 });
// 最终 state = { count: 2 },不是 { count: 3 }
如果你传的是对象(替换式),后面的会覆盖前面的。如果想要累加:
setState(prev => ({ count: prev.count + 1 }));
setState(prev => ({ count: prev.count + 1 }));
// 最终 state = { count: 2 }
函数式 updater 会按顺序执行,每次拿到的 prev 都是前面所有 update 累积的结果。
交接:
现在我们知道了 setState 只是把 update 入队并调度渲染,当前闭包中的 state 是渲染时的快照,不会因为入队操作而改变。新 state 要等到下次 render 阶段 updateReducer 遍历队列时才能计算出来。但还有一个开发中常见的困惑:StrictMode 下组件为什么会被执行两次——这是考点 3.6 要处理的事情。
考点 3.6:StrictMode 为何双调用组件
第 0 段:直觉锚定
你给新员工布置任务时,让他做两遍——不是因为他做错了,而是为了检查他是不是一个"纯"的执行者。如果第二遍做出来的结果和第一遍不同,说明他偷偷记了笔记、改了外部状态、或者依赖了随机数——这些都不可靠。
React 的 StrictMode 就是这个"要求做两遍"的检查员。它在开发模式下故意把组件函数、effect 回调、state 初始化函数都执行两次,帮你提前发现不纯的代码。
第 1 段:问题背景
React 的并发模式(Concurrent Mode)对组件行为有一个硬性要求:render 阶段的代码必须是纯的。
"纯"意味着:
- 相同输入 → 相同输出(没有随机数、Date.now()、外部变量依赖)
- 没有副作用(不修改 DOM、不发起请求、不修改外部状态)
为什么这个要求这么重要?因为在 Concurrent Mode 下,React 可能会:
- 渲染一次后丢弃结果:高优先级更新插队,当前渲染作废
- 多次渲染同一个组件:重试、恢复、切换分支
如果组件有副作用,执行两次就会出问题:
function BadComponent() {
// 每次渲染都发请求!StrictMode 下会发两次
fetch('/api/log', { method: 'POST', body: 'rendered' });
return <div>hello</div>;
}
StrictMode 的目的:在开发阶段帮你提前发现这类问题,而不是等到线上并发场景才暴露。
⚠️ 常见误解: "StrictMode 会影响性能,生产环境应该关掉"。StrictMode 的双调用只在开发模式生效,生产构建中完全不执行任何额外调用,零性能开销。
第 2 段:核心数据结构
StrictMode 的工作不通过 Fiber 节点上的字段,而是通过 ReactFiberHooks 中的全局标志位控制:
// packages/react-reconciler/src/ReactFiberHooks.js
let isInStrictMode: boolean = false;
// 双调用的具体范围由 ReactStrictModeWarnings.js 控制
// 涉及的行为列表:
//
// 双调用的函数:
// 1. 函数组件本体(render)
// 2. useState / useReducer 的初始化函数(initializer)
// 3. useMemo 的计算函数
// 4. useReducer 的 reducer 函数
//
// 双调用的 effect 相关:
// 5. useEffect 的 setup 和 cleanup
// 6. useLayoutEffect 的 setup 和 cleanup
StrictMode 本身在 Fiber 树中是一个特殊节点:
<React.StrictMode>
<App />
</React.StrictMode>
// 编译后
React.createElement(React.StrictMode, null, React.createElement(App))
// Fiber 树中会创建一个 tag = OffscreenComponent 或 Mode 类型的节点
// 它的子树中所有组件都会受到 strict mode 检查
在 renderWithHooks 执行时,React 检查当前 Fiber 是否在 StrictMode 子树中:
function renderWithHooks(current, workInProgress, Component, props, secondArg, lanes) {
// ...
// 检查是否在 StrictMode 中
if (isStrictMode) {
isInStrictMode = true;
// 组件函数会被调用两次
}
// 执行组件函数
let children = Component(props, secondArg);
if (isStrictMode) {
// 再执行一次,丢弃结果(仅用于检测副作用)
isInStrictMode = false;
Component(props, secondArg);
}
// ...
}
第 3 段:运行流程
双调用的完整流程:
renderWithHooks(current, workInProgress, Component, ...)
│
├── 检查 fiber 是否在 StrictMode 子树中
│ isStrictMode = checkIsStrictMode(fiber)
│
├── 设置 Dispatcher(mount / update)
│
├── ① 第一次调用组件函数
│ children = Component(props, secondArg)
│ // 这次调用是"真的"——结果 children 被用于构建 Fiber 树
│ // Hooks 链表在这次调用中被正确建立
│
├── if (isStrictMode) {
│ ② 第二次调用组件函数
│ // 关键:重置 hook 链表索引到起始位置
│ // 重新执行一遍,但结果被丢弃
│ // 目的:触发 console.log 两次、fetch 两次等,让你发现不纯代码
│
│ // 同时:React 会临时拦截 console.log 等方法
│ // 把第二次调用的日志标记为 "React has detected a change in the order of Hooks"
│ // 或直接以灰色/折叠方式展示,避免混淆
│ }
│
└── 返回第一次调用的 children(真正的渲染结果)
Effect 的双调用(更复杂):
Effect 的双调用不是在 render 阶段,而是在 commit 阶段:
// StrictMode 下 effect 的调用顺序:
// mount 时:
setup1() // 第一次 mount effect
cleanup1() // 立刻清理
setup2() // 第二次 mount effect(最终生效的)
// update 时(deps 变了):
cleanup1() // 清理上一次
setup1() // 新的 effect
cleanup2() // 立刻清理
setup2() // 再次执行(最终生效的)
这种"mount → cleanup → mount"的模式模拟了 StrictMode 卸载再重新挂载的场景。
源码定位:
- 双调用组件:
react@18.3.1·packages/react-reconciler/src/ReactFiberHooks.js·renderWithHooks()函数中间部分 - 双调用 effect:
packages/react-reconciler/src/ReactFiberCommitWork.js·commitHookEffectListMount()和commitHookEffectListUnmount()— 在 StrictMode 下会被调用两次 - StrictMode 检测:
packages/react-reconciler/src/ReactStrictModeWarnings.js— 各种警告信息的生成
第 4 段:设计动机与权衡
为什么不直接在文档里说"别写副作用",而要搞双调用?
因为人类的记忆力和纪律性不可靠。React 团队做过大量用户研究发现:
- 很多开发者在 render 中写副作用而不自知(
setInterval、修改全局变量) - 文档写的"render 必须是纯的"被大量开发者忽视
- 只有实际运行时看到问题才能有效改变行为
双调用是一个开发时检测工具,它的哲学是:让错误在开发阶段尽可能明显地暴露。
React 18 加强了 StrictMode:
React 18 之前,StrictMode 只双重调用 render 函数。React 18 扩展了范围,加入了 effect 的双重调用(mount → unmount → mount),目的是为未来的 Offscreen API 做准备——Offscreen 允许 React 隐藏/显示组件(类似 Android 的 Activity 生命周期),组件可能会被卸载又重新挂载,effect 需要能正确处理这种情况。
权衡:
- 开发体验受影响:console.log 看到两次输出、请求发两次、断点命中间隔奇怪
- React 的对策:开发模式下会折叠第二次调用的 console 输出(Chrome 中显示为灰色),减轻干扰
- 收益:在开发阶段捕获大量潜在的并发 bug
第 5 段:次级误解和边界
误解 1:"StrictMode 会让 Hooks 的调用顺序不一致"
不会。StrictMode 双调用时,React 会重置 hook 索引到起始位置,然后从头重新调用所有 hook。两次调用的 hook 顺序完全相同——如果顺序不一致,那是你自己的 bug(条件分支中调用 hook),StrictMode 反而帮你提前发现了。
误解 2:"双调用会导致 state 被重置"
不会。组件函数被调用两次,但只有第一次的结果被用于构建 Hooks 链表。第二次调用时,React 不使用产生的 hook 结果。第二次调用纯粹是"跑一遍看看有没有副作用",不影响任何内部状态。
边界条件:哪些不会被双调用?
以下内容不受 StrictMode 双调用影响:
- 事件处理函数:
onClick、onChange等回调——它们不在 render 阶段执行,不是纯函数约束的范围 - createRef / useRef 的初始化:ref 对象只创建一次
- DOM 操作:只有 commit 阶段的 mutation 操作,不会被重复
边界条件:第三方库的兼容问题
某些第三方库在 effect 中做一次性初始化(如 WebSocket 连接、监听器注册),StrictMode 下会执行两次。这通常需要库作者做适配(确保 cleanup 能正确清理)。如果第三方库没处理好,开发环境下可能出现意外行为。
交接:
现在我们知道了 StrictMode 通过双调用组件和 effect,在开发阶段帮我们发现不纯的代码,为 Concurrent Mode 的安全运行提供保障。这是 React 对开发者行为的主动约束。但还有一个问题没有回答:当我们用 React.memo 包裹组件时,React 到底是怎么比较 props 来决定跳过渲染的——这是考点 3.7 要处理的事情,也是主题块 3 的最后一个考点。
考点 3.7:React.memo 的 props 比较原理
第 0 段:直觉锚定
想象一个严格的门卫:
每个员工(子组件)每次进出都要出示证件(props)。门卫会逐项对比证件上的信息——姓名、部门、工号,全部一样才放行。哪怕只有一项变了,门卫就说"证件变了,重新登记"。
但门卫有一个盲区:他用的是 === 严格相等来检查。如果你把证件上的"入职日期"从 2024-01-01 改成了 2024-01-01(内容没变,但换了张新纸),门卫会说"这不是同一张纸,证件变了"——因为 === 比较的是引用地址,不是内容。
这就是 React.memo 默认行为的核心:用 Object.is() 逐个比较 props 的每个属性。对象类型的 prop 如果引用变了,即使内容一样,也会触发重新渲染。
第 1 段:问题背景
React 的默认行为是:父组件重新渲染时,所有子组件都会重新渲染,不管传给子组件的 props 有没有变。
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<Child name="hello" /> {/* count 变了,Child 的 props 没变,但 Child 也会重新渲染 */}
</div>
);
}
这是因为 React 的渲染是自上而下的——beginWork 从父节点开始 DFS 遍历,遇到子节点就处理,不检查 props 是否变化(默认行为)。
React.memo 的作用是给组件加一层浅比较保护:如果 props 没变,跳过这个组件的 render,直接复用上次的子树。
⚠️ 常见误解: "
React.memo做深比较"。默认情况下React.memo做的是浅比较(shallow compare),即用Object.is()逐个比较顶层属性。嵌套对象的内部变化检测不到,需要自定义areEqual函数。
第 2 段:核心数据结构
React.memo 在 Fiber 层面的表示:
// React.memo(Comp, areEqual?) 返回一个特殊对象
{
$$typeof: Symbol(react.memo), // 标识这是 memo 组件
type: Comp, // 被包裹的原始组件
compare: areEqual | shallowEqual, // 比较函数(默认浅比较)
}
// Fiber 树中,memo 组件的 tag 是 SimpleMemoComponent 或 MemoComponent
FiberNode {
tag: MemoComponent,
type: { $$typeof: REACT_MEMO_TYPE, type: Comp, compare: areEqual },
memoizedProps: { name: "hello" }, // 上次渲染的 props
pendingProps: { name: "hello" }, // 本次新的 props
// ...
}
MemoComponent vs SimpleMemoComponent 的区别:
// 如果被包裹的组件是函数组件 → SimpleMemoComponent(简化路径,跳过一些检查)
// 如果被包裹的组件有其他特殊情况 → MemoComponent(完整路径)
SimpleMemoComponent 是 React 内部的优化——如果 memo 包裹的是普通函数组件,不需要处理 defaultProps、propTypes 等,走一条更快的代码路径。
第 3 段:运行流程
memo 的比较时机:beginWork 中。
beginWork(current, workInProgress, renderLanes)
│
├── switch (workInProgress.tag)
│ ...
│ case MemoComponent:
│ updateMemoComponent(current, workInProgress, type, ...);
│ ...
│ case SimpleMemoComponent:
│ updateSimpleMemoComponent(current, workInProgress, ...);
updateMemoComponent 的核心逻辑(简化):
function updateMemoComponent(current, workInProgress, type, nextProps, renderLanes) {
// 1. 如果没有 current(首次渲染),直接渲染
if (current === null) {
// mount,必须渲染
return mountChildFibers(workInProgress, ...);
}
// 2. 有 current,检查 props 是否变化
const prevProps = current.memoizedProps;
const compare = type.compare; // 自定义比较函数或默认 shallowEqual
// 3. 调用比较函数
if (compare(prevProps, nextProps)) {
// props 没变 → 跳过渲染!
// 复用 current 的子树
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// 4. props 变了 → 正常渲染
return updateFunctionComponent(current, workInProgress, type, nextProps, renderLanes);
}
默认浅比较函数 shallowEqual 的实现:
// packages/shared/shallowEqual.js
function shallowEqual(objA, objB) {
// 1. 引用相同 → 直接相等
if (Object.is(objA, objB)) return true;
// 2. 任一不是对象 → 不相等
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false;
}
// 3. 逐个比较顶层属性
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])) {
return false; // 某个属性的值不同(引用比较!)
}
}
return true;
}
bailout 机制(跳过渲染):
function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
// 复用 current 的子树
cloneChildFibers(current, workInProgress);
// 检查子树是否有高优先级更新需要处理
if (workInProgress.childLanes & renderLanes) {
// 子树有更新 → 继续遍历子节点
return workInProgress.child;
}
// 子树也没有更新 → 整棵子树跳过
return null;
}
这里有一个重要的连锁效应:memo 跳过渲染后,所有子孙组件也会被跳过(只要它们没有自己的更新)。这就是 memo 能大幅减少渲染次数的原因。
源码定位:
- memo 创建:
react@18.3.1·packages/react/src/ReactMemo.js·memo(type, compare)— 只是把{ $$typeof, type, compare }包装成一个对象 - beginWork 中的 memo 处理:
packages/react-reconciler/src/ReactFiberBeginWork.js·updateMemoComponent()和updateSimpleMemoComponent() - 浅比较函数:
packages/shared/shallowEqual.js·shallowEqual(objA, objB) - bailout 逻辑:
packages/react-reconciler/src/ReactFiberBeginWork.js·bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
第 4 段:设计动机与权衡
为什么默认不做 memo?
React 团队的哲学是:默认行为应该简单正确,优化应该是手动可选的。
如果默认就 memo 所有组件:
- 每个组件每次渲染都要跑一次浅比较,对比所有 props —— 这个比较本身就有开销
- 很多场景下 props 确实变了,比较完还是要渲染,那比较就是浪费
- 开发者会失去对渲染行为的直观理解("为什么我的组件没更新?")
所以 React 让你按需手动添加 memo,只包裹那些:
- 渲染成本高的组件
- 经常被父组件连带渲染但 props 很少变的组件
- 作为
useMemo/useCallback优化的配合方
权衡:
- memo 的浅比较本身有 O(n) 开销(n = props 属性数量)
- 如果 props 每次都变,memo 反而增加了无用的比较开销
- 自定义
areEqual如果写得不好(比如做了深比较),可能比重新渲染还慢
第 5 段:次级误解和边界
误解 1:"memo 包裹后,组件永远不会重新渲染"
不对。memo 只在 props 不变时跳过渲染。如果 props 变了,组件照常渲染。另外,即使 props 没变,以下情况组件仍会重新渲染:
- 组件内部用了
useContext,context 值变了 - 组件自身调用了
useState/useReducer的 dispatch forceUpdate(类组件)
memo 只挡住来自父组件的 props 驱动渲染,不挡自驱渲染和 context 驱动渲染。
误解 2:"memo 比较的是 children prop"
一个经典坑:
// 每次渲染都是新对象 → memo 失效!
<MemoChild style={{ color: 'red' }} />
// 每次渲染都是新函数 → memo 失效!
<MemoChild onClick={() => setCount(c => c + 1)} />
// 每次渲染都是新的 JSX → memo 失效!(children 是隐式 prop)
<MemoChild>hello</MemoChild>
{ color: 'red' } 每次渲染都是新对象,Object.is({ color: 'red' }, { color: 'red' }) 是 false。解决方法:
// 用 useMemo 缓存对象
const style = useMemo(() => ({ color: 'red' }), []);
<MemoChild style={style} />
// 用 useCallback 缓存函数
const handleClick = useCallback(() => setCount(c => c + 1), []);
<MemoChild onClick={handleClick} />
// children 也是 prop,也需要稳定引用
const children = useMemo(() => <div>hello</div>, []);
<MemoChild children={children} />
边界条件:自定义 areEqual 的坑
const areEqual = (prev, next) => {
// ❌ 错误:只比较了一个字段,忽略了其他 props
return prev.id === next.id;
};
// ✅ 正确:应该比较所有可能影响渲染的 props
const areEqual = (prev, next) => {
return prev.id === next.id && prev.name === next.name && prev.onClick === next.onClick;
};
自定义比较函数的签名是 (prevProps, nextProps) => boolean,返回 true 表示 props 相等(跳过渲染),返回 false 表示 props 不同(需要渲染)。注意这和 shouldComponentUpdate 的语义一致,但和 React.memo 默认的 shallowEqual 调用方式不同——shallowEqual 返回 true 表示相等。
考核题目
题目 1(源码追踪题):
当 React 执行 commitRootImpl 时,请描述三个子阶段各自的职责,并说明 root.current = finishedWork(current 指针切换)精确发生在哪个子阶段之间?为什么必须在那个位置?
题目 2(机制推理题):
假设你写了一个组件,用 React.memo 包裹,但发现它仍然在父组件每次渲染时都重新渲染。你发现父组件传了 style={{ color: 'red' }}。请解释为什么 memo 失效,以及如果要修复,有哪些方案?
题目 3(设计理解题):
React 18 把 React 17 的 effectList(副作用链表)替换成了 subtreeFlags(子树标记聚合)方案。请说明这两种方案各自的原理,以及 React 18 做这个替换的设计动机是什么?