Dive into React——渲染流程

20 阅读25分钟

全部专栏

1.Fiber架构

2.Hooks原理

3.渲染流程

4.diff算法

5.调度与并发

6.性能优化

7.事件系统

8.高级特性

实验:为了弥补原理和实践的鸿沟,可以参与实验,也可在线体验


考点 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 阶段                     │
│                                        │
└────────────────────────────────────────┘
        ↓
浏览器绘制

源码定位(入口):

  1. render 阶段入口react@18.3.1 · packages/react-reconciler/src/ReactFiberWorkLoop.js · renderRootConcurrent(root, lanes)
  2. 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 标记),不是一窝蜂上去乱干,而是严格按工序来

  1. 拆旧(BeforeMutation)— 先拍照记录旧房间样子,通知住户"要开始动了"
  2. 施工(Mutation)— 砸墙、刷漆、装柜子,真正的物理变更
  3. 验收(Layout)— 住户走进来看效果,量尺寸确认没问题

这三步的顺序不可颠倒:必须先拆旧、再施工、再验收。而且住户从"旧房子"搬到"新房子"这个动作,精确地发生在施工和验收之间(current 指针切换)。


第 1 段:问题背景

commit 阶段要处理三类性质完全不同的工作:

  • DOM 操作:插入、更新、删除节点 — 这是物理变更
  • 生命周期 / hooks 回调componentDidMountcomponentDidUpdateuseLayoutEffect — 组件需要知道"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() 实现

源码定位:

  1. 总入口react@18.3.1 · packages/react-reconciler/src/ReactFiberWorkLoop.js · commitRootImpl(root, renderPriorityLevel)
  2. BeforeMutation:同文件 · commitBeforeMutationEffects(root, firstChild) → 委托到 ReactFiberCommitWork.js
  3. Mutation:同文件 · commitMutationEffects(root, firstChild, lanes) → 委托到 ReactFiberCommitWork.js · commitMutationEffectsOnFiber(fiber, root, lanes)
  4. 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

父节点 divsubtreeFlags = div.flags | p.flags | span.flags = Update | Placement。commit 阶段检查 div.subtreeFlags 时,发现非零,就知道子树中有活要干,需要继续深入遍历。


第 3 段:运行流程

flags 的打标时机(render 阶段):

flags 在 render 阶段的 beginWorkcompleteWork 中被打上:

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;
}

源码定位:

  1. flags 定义react@18.3.1 · packages/react-reconciler/src/ReactFiberFlags.js — 所有 flag 常量
  2. 打标(beginWork 中子节点对比)packages/react-reconciler/src/ReactChildFiber.js · reconcileChildFibers() — 在 placeChilddeleteChild 等辅助函数中打标
  3. 冒泡(completeWork)packages/react-reconciler/src/ReactFiberCompleteWork.js · bubbleProperties(fiber) — 在文件底部

你可以打开 ReactChildFiber.js,搜索 PlacementChildDeletion,能看到在哪些场景下被打上标记。

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.flagsFiber 节点级别的操作标记(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 的逻辑。

源码定位:

  1. createElementreact@18.3.1 · packages/react/src/ReactElement.js · createElement(type, config, children) — 在文件前半部分
  2. jsx runtimereact@18.3.1 · packages/react/src/jsx.js · jsx(type, config, maybeKey)jsxs(type, config, maybeKey)jsxs 用于有多个 children 的场景)
  3. 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() from react/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 经过编译变成 createElementjsx() 调用,运行时生成 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 做的事情仅仅是:

  1. 创建一个 update 对象
  2. 把它放入 fiber 的更新队列
  3. 调度一次渲染(安排 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

源码定位:

  1. dispatch 入口react@18.3.1 · packages/react-reconciler/src/ReactFiberHooks.js · dispatchSetState(fiber, queue, action) — 入队 update + 调度渲染
  2. state 计算:同文件 · updateReducer(reducer, initialArg, init) — 遍历 pending 链表,计算新 state
  3. 调度入口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 卸载再重新挂载的场景。

源码定位:

  1. 双调用组件react@18.3.1 · packages/react-reconciler/src/ReactFiberHooks.js · renderWithHooks() 函数中间部分
  2. 双调用 effectpackages/react-reconciler/src/ReactFiberCommitWork.js · commitHookEffectListMount()commitHookEffectListUnmount() — 在 StrictMode 下会被调用两次
  3. 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 双调用影响:

  • 事件处理函数onClickonChange 等回调——它们不在 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 能大幅减少渲染次数的原因。

源码定位:

  1. memo 创建react@18.3.1 · packages/react/src/ReactMemo.js · memo(type, compare) — 只是把 { $$typeof, type, compare } 包装成一个对象
  2. beginWork 中的 memo 处理packages/react-reconciler/src/ReactFiberBeginWork.js · updateMemoComponent()updateSimpleMemoComponent()
  3. 浅比较函数packages/shared/shallowEqual.js · shallowEqual(objA, objB)
  4. 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 做这个替换的设计动机是什么?