在第一篇中,我们理解了 Fiber 是为了解决”卡顿”而生的。那么,React 拿到 Fiber 链表后,到底是如何一步步把它变成网页上的真实像素的呢?

本篇我们进入 React 更新流程的核心地带:Render 阶段(打草稿)和 Commit 阶段(去发布),以及连接它们的双缓存机制


1. 核心模型:Render 与 Commit 的职责划分

React 将每一次 UI 更新严格分成了两个阶段,这不是设计上的”洁癖”,而是为了实现可中断更新的前提条件。

特性 Render 阶段(协调) Commit 阶段(提交)
工作内容 在内存中计算 Fiber 树的差异,标记增删改 将计算结果一次性应用到真实 DOM
是否可中断 (配合 Scheduler 时间切片) (必须同步执行完)
对用户可见 不可见(全在内存中进行) 可见(用户看到界面变化)
内部核心函数 beginWork / completeWork commitMutationEffects / commitLayoutEffects
有无副作用 无(纯计算) 有(DOM 操作、ref 更新、effect 触发)

这种分离的直接好处是:

Render 阶段可以随时中断、丢弃、重来,因为它不会产生任何用户可见的变化。只有当整棵”草稿树”计算完毕,React 才会进入 Commit 阶段一次性提交。


2. 双缓存技术:为什么需要两棵树

在理解 Render 阶段之前,需要先搞清一个基础设施:React 在内存中同时维护着两棵 Fiber 树

两棵树的角色

  • Current Tree:当前屏幕上正在显示的那棵树,每个 Fiber 节点对应着真实 DOM
  • WorkInProgress Tree:正在内存中构建的”草稿树”,是下一次 UI 的预演

两棵树上的对应节点通过 alternate 属性互相引用:

1
2
3
4
5
// current 树上的节点
currentFiber.alternate === workInProgressFiber; // true

// workInProgress 树上的节点
workInProgressFiber.alternate === currentFiber; // true

为什么不在原树上直接改

如果 React 直接修改 Current Tree,会遇到两个严重问题:

  1. UI 撕裂:由于 Render 阶段是可中断的,如果直接改 Current Tree,用户可能看到”改了一半”的界面——比如列表前 5 项是新样式,后 5 项还是旧样式
  2. 无法回滚:如果一个高优先级更新打断了当前的低优先级渲染,React 需要丢弃未完成的工作。如果改的是原树,就无法恢复

双缓存解决了这两个问题:

  • 所有修改都发生在 WorkInProgress Tree 上,Current Tree 保持不变
  • 如果需要丢弃,直接扔掉 WorkInProgress Tree 即可
  • 只有在所有计算都完成后,才通过切换指针让 WorkInProgress Tree “上位”成为新的 Current Tree

双缓存的生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
首次渲染(Mount):

rootFiber.current ──→ 空的 Current Tree

│ 创建 workInProgress

WorkInProgress Tree
(从头构建每个节点)

│ Commit 阶段:切换指针

rootFiber.current ──→ 新的 Current Tree (就是刚才的 WIP)


后续更新(Update):

rootFiber.current ──→ Current Tree (上次的结果)

│ 基于 Current 创建 WIP(尽量复用节点)

WorkInProgress Tree
(只处理有变化的节点)

│ Commit 阶段:切换指针

rootFiber.current ──→ 新的 Current Tree
旧的 Current ──→ 闲置(等待下次复用为 WIP)

注意最后一步:旧的 Current Tree 不会被销毁,它会在下次更新时被复用为新的 WorkInProgress Tree。这就是为什么叫”双缓存”——两棵树交替充当”正式版”和”草稿版”。


3. Render 阶段:精细的”打草稿”过程

Render 阶段的目标是:在 WorkInProgress Tree 上标记出所有需要变更的节点

React 通过上一篇讲过的深度优先遍历(workLoop),对每个 Fiber 节点执行两个核心函数:

3.1 beginWork:自顶向下的”递”

beginWork 负责处理当前节点,并生成它的子 Fiber。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function beginWork(current, workInProgress, renderLanes) {
// current 是 Current Tree 上的对应节点
// workInProgress 是正在构建的节点

// 第一步:判断是否可以跳过
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;

if (oldProps === newProps && !hasContextChanged()) {
// props 没变、context 没变 → 直接复用,跳过这棵子树
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}
}

// 第二步:根据节点类型分流处理
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, renderLanes);
case ClassComponent:
return updateClassComponent(current, workInProgress, renderLanes);
case HostComponent: // 原生 DOM 元素,如 <div>
return updateHostComponent(current, workInProgress);
// ... 还有十几种类型
}
}

拿函数组件举例,updateFunctionComponent 会做这些事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function updateFunctionComponent(current, workInProgress, renderLanes) {
const Component = workInProgress.type; // 组件函数,比如 App
const newProps = workInProgress.pendingProps;

// 执行组件函数,拿到 JSX(也就是 React Element)
// 在这里,Hooks 会被依次调用
const nextChildren = renderWithHooks(
current,
workInProgress,
Component,
newProps,
renderLanes
);

// 如果上一次渲染和这次结果一样,可以提前退出
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}

// Diff:对比新旧 children,创建/复用/标记子 Fiber
reconcileChildren(current, workInProgress, nextChildren, renderLanes);

return workInProgress.child; // 返回第一个子 Fiber,继续向下
}

Diff 算法的核心策略

reconcileChildren 是 Diff 算法的入口,React 对它做了三个重要的降级假设来把 O(n³) 的通用树 Diff 降低到 O(n):

  1. 跨层级的节点移动极少发生:只比较同一层级的节点,不做跨层级复用
  2. 不同类型的组件产生不同的树:如果一个 <div> 变成了 <span>,直接销毁整棵子树重建
  3. 通过 key 标识同一元素:列表中同 key 的元素才认为是”同一个”

对于列表的 Diff(多节点),React 分两轮遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 简化版列表 Diff 思路
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
// === 第一轮:从头开始,逐个比对 ===
// 如果 key 和 type 都匹配 → 复用旧 Fiber,标记 Update
// 如果 key 不匹配 → 跳出第一轮

// === 第二轮:处理剩余节点 ===
// 把旧的剩余节点放进一个 Map<key, Fiber>
// 遍历新的剩余节点,从 Map 里找可复用的
// 找到了 → 复用,标记 Placement(移动)
// 没找到 → 创建新 Fiber,标记 Placement(新增)
// Map 里剩下的 → 标记 Deletion(删除)
}

每个需要变更的 Fiber 会被打上 Flags(旧版叫 effectTag):

1
2
3
4
5
// 常见的 Flags
const Placement = 0b0000000000010; // 新增或移动
const Update = 0b0000000000100; // 属性更新
const Deletion = 0b0000000001000; // 删除
const ChildDeletion = 0b0000000010000; // 子节点删除

3.2 completeWork:自底向上的”归”

当一个节点的所有子节点都处理完后(没有 child,或 child 的子树已经完成),React 会调用 completeWork 进行”收尾”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function completeWork(current, workInProgress) {
switch (workInProgress.tag) {
case HostComponent: {
// 原生 DOM 元素的处理

if (current !== null && workInProgress.stateNode !== null) {
// === 更新阶段 ===
// DOM 实例已存在,比较新旧 props
// 找出变化的属性(className、style、事件等)
// 生成 updateQueue:[propKey1, propValue1, propKey2, propValue2, ...]
updateHostComponent(current, workInProgress);
} else {
// === 首次渲染 ===
// 创建真实 DOM 实例
const instance = createInstance(workInProgress.type, newProps);

// 把已经创建好的子 DOM 挂到自己身上
// 注意:此时还在内存里,不在页面上
appendAllChildren(instance, workInProgress);

// 设置 DOM 属性
finalizeInitialChildren(instance, workInProgress.type, newProps);

workInProgress.stateNode = instance;
}

// === 副作用冒泡 ===
// 把子树中的 Flags 汇总到自己的 subtreeFlags
bubbleProperties(workInProgress);
return null;
}
// ... 其他类型
}
}

副作用冒泡(bubbleProperties)

这是 React 18 引入的一个性能优化。每个节点在 completeWork 时,会把子树所有的 Flags 合并到自己的 subtreeFlags 上:

1
2
3
4
5
6
7
8
9
10
11
12
function bubbleProperties(completedWork) {
let subtreeFlags = 0;
let child = completedWork.child;

while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
child = child.sibling;
}

completedWork.subtreeFlags = subtreeFlags;
}

这样在 Commit 阶段,React 只需要检查 subtreeFlags 就知道某棵子树下面有没有需要处理的副作用——如果 subtreeFlags === 0,整棵子树都可以跳过,省去了大量无用遍历。


4. Commit 阶段:最终的”发布时刻”

一旦 Render 阶段走完,整棵 WorkInProgress Tree 上已经标记好了所有变更。React 进入 Commit 阶段,这个阶段是同步、不可中断的——因为它涉及到真实 DOM 操作,半途而废会让用户看到不一致的界面。

Commit 阶段在 React 内部被细分为三个子阶段:

4.1 Before Mutation(DOM 变更前)

这个阶段读取 DOM 的”旧状态”,为后续变更做准备:

  • 调用 Class 组件的 getSnapshotBeforeUpdate 生命周期
  • 此时 DOM 还是旧的,可以安全地读取当前的 scrollTop、尺寸等信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function commitBeforeMutationEffects(firstChild) {
let fiber = firstChild;
while (fiber !== null) {
if (fiber.flags & Snapshot) {
const current = fiber.alternate;
// 调用 getSnapshotBeforeUpdate
commitBeforeMutationLifeCycles(current, fiber);
}

// 如果子树有副作用,继续向下
if (fiber.subtreeFlags & BeforeMutationMask) {
commitBeforeMutationEffects(fiber.child);
}

fiber = fiber.sibling;
}
}

4.2 Mutation(DOM 变更)

这是真正修改 DOM 的阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function commitMutationEffects(firstChild) {
let fiber = firstChild;
while (fiber !== null) {
const flags = fiber.flags;

// 处理子节点的删除
if (flags & ChildDeletion) {
commitDeletions(fiber.deletions);
}

// 处理当前节点的变更
if (flags & Placement) {
commitPlacement(fiber); // 插入 / 移动 DOM
}
if (flags & Update) {
commitWork(fiber); // 更新 DOM 属性
}

// 递归处理子树
if (fiber.subtreeFlags & MutationMask) {
commitMutationEffects(fiber.child);
}

fiber = fiber.sibling;
}
}

完成 Mutation 后,用户的屏幕发生了变化。 紧接着,React 执行关键的一步——指针切换:

1
2
// 一行代码完成"双缓存切换"
root.current = finishedWork;

此时,原本的 WorkInProgress Tree 变成了新的 Current Tree,旧的 Current Tree 退居二线,等待下次更新复用。

4.3 Layout(DOM 变更后)

这个阶段 DOM 已经更新完毕,可以安全地读取新的布局信息:

  • 调用 Class 组件的 componentDidMount / componentDidUpdate
  • 调用 useLayoutEffect 的回调(同步执行,在浏览器绘制之前)
  • 更新 ref
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function commitLayoutEffects(firstChild) {
let fiber = firstChild;
while (fiber !== null) {
if (fiber.flags & LayoutMask) {
// 函数组件:执行 useLayoutEffect 的回调
// Class 组件:执行 componentDidMount / componentDidUpdate
commitLayoutEffectOnFiber(fiber);
}

// 更新 ref
if (fiber.flags & Ref) {
commitAttachRef(fiber);
}

if (fiber.subtreeFlags & LayoutMask) {
commitLayoutEffects(fiber.child);
}

fiber = fiber.sibling;
}
}

需要注意 useLayoutEffectuseEffect 的区别:

  • useLayoutEffect:在 Layout 阶段同步执行,此时 DOM 已更新但浏览器还没绘制
  • useEffect:在 Commit 阶段结束后异步调度执行,不阻塞浏览器绘制
1
2
3
4
5
6
7
Commit 阶段时间线:

├── Before Mutation ──┤──── Mutation ────┤──── Layout ────┤── 浏览器绘制 ──┤
│ getSnapshot... │ DOM 增删改 │ useLayoutEffect │ │
│ │ 切换 current 指针 │ componentDidMount│ │
│ │ │ 更新 ref │ │
│ │ │ │ useEffect (异步)│

5. 完整更新流程串联

把前两篇的内容综合起来,一次由 setState 触发的更新从头到尾的完整流程是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
setState() 调用


创建 Update 对象,挂到 Fiber 的 updateQueue 上


调用 scheduleUpdateOnFiber(),向上标记优先级


Scheduler 根据优先级安排任务


╔═══════════════════════════════════════╗
║ Render 阶段(可中断) ║
║ ║
║ workLoop: ║
║ while (wip !== null && !yield) { ║
║ ┌─ beginWork(wip) ║
║ │ • 执行组件函数 / render() ║
║ │ • Diff 子节点,标记 Flags ║
║ │ • 返回 child ║
║ │ ║
║ └─ completeWork(wip) ║
║ • 创建 / 更新 DOM 实例 ║
║ • 副作用冒泡 ║
║ • 返回 sibling 或 return ║
║ } ║
╚═══════════════════════════════════════╝


╔═══════════════════════════════════════╗
║ Commit 阶段(同步不可中断) ║
║ ║
║ 1. Before Mutation ║
║ • getSnapshotBeforeUpdate ║
║ ║
║ 2. Mutation ║
║ • 真实 DOM 增删改 ║
║ • root.current = finishedWork ║
║ ║
║ 3. Layout ║
║ • useLayoutEffect ║
║ • componentDidMount/Update ║
║ • 更新 ref ║
╚═══════════════════════════════════════╝


浏览器绘制(Paint)


异步调度 useEffect

6. 本章总结

通过将”计算”与”变更”分离,React 实现了渲染的原子性

  • Render 阶段纯计算、可中断、可丢弃——保证了并发渲染的可能性
  • Commit 阶段同步执行、不可中断——保证了 UI 的一致性
  • 双缓存机制让两个阶段之间有了安全的”缓冲区”

React 的更新策略可以用一句话概括:要么不更新,要更新就一次性把完美的成品呈现给用户。

下一篇,我们要探索一个更有趣的问题:既然函数组件每次渲染都会重新执行,那组件里的状态(State)是如何逃过”被重置”的命运,稳稳地留在内存里的呢?

Happy Coding!