在第一篇中,我们理解了 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 currentFiber.alternate === workInProgressFiber; workInProgressFiber.alternate === currentFiber;
为什么不在原树上直接改 如果 React 直接修改 Current Tree,会遇到两个严重问题:
UI 撕裂 :由于 Render 阶段是可中断的,如果直接改 Current Tree,用户可能看到”改了一半”的界面——比如列表前 5 项是新样式,后 5 项还是旧样式
无法回滚 :如果一个高优先级更新打断了当前的低优先级渲染,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 ) { if (current !== null ) { const oldProps = current.memoizedProps ; const newProps = workInProgress.pendingProps ; if (oldProps === newProps && !hasContextChanged ()) { return bailoutOnAlreadyFinishedWork (current, workInProgress); } } switch (workInProgress.tag ) { case FunctionComponent : return updateFunctionComponent (current, workInProgress, renderLanes); case ClassComponent : return updateClassComponent (current, workInProgress, renderLanes); case HostComponent : 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 ; const newProps = workInProgress.pendingProps ; const nextChildren = renderWithHooks ( current, workInProgress, Component , newProps, renderLanes ); if (current !== null && !didReceiveUpdate) { bailoutHooks (current, workInProgress, renderLanes); return bailoutOnAlreadyFinishedWork (current, workInProgress); } reconcileChildren (current, workInProgress, nextChildren, renderLanes); return workInProgress.child ; }
Diff 算法的核心策略 reconcileChildren 是 Diff 算法的入口,React 对它做了三个重要的降级假设 来把 O(n³) 的通用树 Diff 降低到 O(n):
跨层级的节点移动极少发生 :只比较同一层级的节点,不做跨层级复用
不同类型的组件产生不同的树 :如果一个 <div> 变成了 <span>,直接销毁整棵子树重建
通过 key 标识同一元素 :列表中同 key 的元素才认为是”同一个”
对于列表的 Diff(多节点),React 分两轮遍历:
1 2 3 4 5 6 7 8 9 10 11 12 13 function reconcileChildrenArray (returnFiber, currentFirstChild, newChildren ) { }
每个需要变更的 Fiber 会被打上 Flags (旧版叫 effectTag):
1 2 3 4 5 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 : { if (current !== null && workInProgress.stateNode !== null ) { updateHostComponent (current, workInProgress); } else { const instance = createInstance (workInProgress.type , newProps); appendAllChildren (instance, workInProgress); finalizeInitialChildren (instance, workInProgress.type , newProps); workInProgress.stateNode = instance; } 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 ; 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); } if (flags & Update ) { commitWork (fiber); } 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 ) { commitLayoutEffectOnFiber (fiber); } if (fiber.flags & Ref ) { commitAttachRef (fiber); } if (fiber.subtreeFlags & LayoutMask ) { commitLayoutEffects (fiber.child ); } fiber = fiber.sibling ; } }
需要注意 useLayoutEffect 和 useEffect 的区别:
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!