工作循环 — React 的渲染原理
前置知识
- ›第 1 篇:架构概览(monorepo 结构与 fork 机制)
- ›第 2 篇:Fiber 数据结构(Fiber 字段、WorkTags、flags、lanes 概念)
- ›熟悉位运算,用于 lane 的操作处理
工作循环 — React 的渲染原理
工作循环是 React 的核心引擎,负责将一次 setState 调用最终变为屏幕上可见的像素。ReactFiberWorkLoop.js 约有 5600 行代码,是整个代码库中最重要的文件,统筹协调了三大核心流程:渲染阶段(构建 workInProgress 树)、lane 模型(决定优先处理哪些工作),以及提交阶段(将副作用应用到宿主环境)。
本文将完整追踪一次状态更新的生命周期——从调用 setState 的那一刻起,经过 lane 分配与调度,进入逐个处理 fiber 的工作循环,最终完成三个提交子阶段的 DOM 变更。
Lane 模型 — 用位掩码表示优先级
要理解调度机制,首先需要理解 lanes。Lanes 定义于 ReactFiberLane.js,是一套 31 位的位掩码系统,用于编码工作的优先级。
每个比特位对应一个不同的优先级级别:
| Lane | 位位置 | 用途 |
|---|---|---|
SyncHydrationLane |
0 | 最高优先级的 hydration |
SyncLane |
1 | 同步更新(如受控输入框) |
InputContinuousLane |
3 | 连续事件(如拖拽、滚动) |
DefaultLane |
5 | 普通更新(如 useEffect 中的 setState) |
GestureLane |
6 | 视图过渡手势 |
TransitionLane1-14 |
8-21 | startTransition 更新(14 条 lane 用于批处理) |
RetryLane1-4 |
22-25 | Suspense 重试 |
IdleLane |
28 | 最低优先级工作 |
OffscreenLane |
29 | 预渲染隐藏内容 |
位掩码的优势在于批处理:相同优先级的多次更新共享同一个 lane 位,React 会在一次渲染过程中统一处理它们。TransitionLanes 区间包含 14 条 lane,使 React 可以为不同的 startTransition 调用分配独立的 lane,从而实现对各个过渡进度的独立跟踪。
graph LR
subgraph "31-bit Lane Bitmask"
SH["0: SyncHydration"]
SY["1: Sync"]
ICH["2: InputContinuousHydration"]
IC["3: InputContinuous"]
DH["4: DefaultHydration"]
D["5: Default"]
G["6: Gesture"]
TH["7: TransitionHydration"]
T["8-21: Transition × 14"]
R["22-25: Retry × 4"]
SEL["26: SelectiveHydration"]
IH["27: IdleHydration"]
ID["28: Idle"]
OFF["29: Offscreen"]
DEF["30: Deferred"]
end
getNextLanes 函数负责决定下一步处理哪些 lane。它会选取优先级最高的非挂起 lane,同时考虑 lane 之间的关联关系(必须一起处理的 lane)以及过期情况(长期被饿死的低优先级工作会被提升)。
调度更新 — 从 setState 到 scheduleUpdateOnFiber
调用 setState 后,更新走向屏幕的旅程从 scheduleUpdateOnFiber 开始:
sequenceDiagram
participant User as User Code
participant Dispatch as dispatchSetState
participant Schedule as scheduleUpdateOnFiber
participant Root as ensureRootIsScheduled
participant Sched as Scheduler
User->>Dispatch: setState(newValue)
Dispatch->>Dispatch: Enqueue update on fiber
Dispatch->>Dispatch: Request lane (requestUpdateLane)
Dispatch->>Schedule: scheduleUpdateOnFiber(root, fiber, lane)
Schedule->>Schedule: Mark root with pending lanes
Schedule->>Root: ensureRootIsScheduled(root)
Root->>Sched: scheduleCallback(priority, performWorkOnRoot)
更新首先以循环链表节点的形式入队到 fiber 的更新队列中,然后根据当前上下文申请对应的 lane——在 startTransition 回调中触发的更新会获得 TransitionLane,点击事件处理函数中的更新获得 SyncLane,来自 useEffect 的更新则获得 DefaultLane。
接着,scheduleUpdateOnFiber 将该 lane 标记到根节点的 pendingLanes 上,并调用 ensureRootIsScheduled。
Root 调度器与 Scheduler 包
ReactFiberRootScheduler 维护着一个包含待处理工作的根节点链表。它将 React 基于 lane 的优先级映射为 Scheduler 包中的五个优先级级别,然后通过 Scheduler_scheduleCallback 注册回调。
Scheduler 包是 React 的协作式调度层,内部维护两个优先队列:
taskQueue:已就绪的任务,按过期时间排序timerQueue:尚未就绪的延迟任务,按开始时间排序
两者均以最小堆实现,对应的是简洁紧凑的 SchedulerMinHeap.js——push、pop、siftUp、siftDown 等操作仅用约 80 行代码实现。比较函数首先按 sortIndex(过期时间)排序,再以 id(插入顺序)作为平局决胜。
Scheduler 使用 MessageChannel 在任务分片之间将控制权让还给浏览器事件循环,这正是并发渲染得以实现的基础——React 可以暂停工作,让浏览器处理用户输入、绘制帧或执行其他任务。
flowchart TD
RC["Root Scheduler"] -->|"maps lanes to priority"| SC["Scheduler"]
SC --> TQ["taskQueue (min-heap)<br/>Ready tasks"]
SC --> TMQ["timerQueue (min-heap)<br/>Delayed tasks"]
SC -->|"MessageChannel"| EL["Browser Event Loop"]
EL -->|"next message"| SC
SC -->|"calls"| PWR["performWorkOnRoot"]
performWorkOnRoot — 渲染的入口
performWorkOnRoot 是整个渲染流程的总控函数,其中最关键的决策是:同步还是并发?
const shouldTimeSlice =
(!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes)) ||
checkIfRootIsPrerendering(root, lanes);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes, true);
以下情况使用同步渲染:SyncLane(需要立即响应的用户交互)、已过期的工作(防止饿死),以及强制同步渲染。并发渲染则用于过渡更新和其他非阻塞更新,支持时间切片。
渲染完成后,performWorkOnRoot 会检查退出状态。如果是并发渲染且外部 store 在渲染过程中发生了变更(通过 isRenderConsistentWithExternalStores 检测),则会回退到同步重渲染以保证一致性。
内层循环 — workLoopSync 与 workLoopConcurrent
实际的渲染发生在两个紧凑的 while 循环中。同步版本位于第 2750 行,逻辑极为简洁:
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
并发版本位于第 3034 行,增加了让出控制权的逻辑:
function workLoopConcurrent(nonIdle: boolean) {
if (workInProgress !== null) {
const yieldAfter = now() + (nonIdle ? 25 : 5);
do {
performUnitOfWork(workInProgress);
} while (workInProgress !== null && now() < yieldAfter);
}
}
注意两个让出间隔的差异:过渡更新获得 25ms 的时间切片(有意将动画节流至约 30fps 以防饿死),而空闲工作每 5ms 让出一次。
performUnitOfWork 调用 beginWork 处理当前 fiber 并获取其子节点。若存在子节点,子节点成为新的 workInProgress;若不存在子节点,则由 completeUnitOfWork 沿兄弟节点和父节点向上回溯。
flowchart TD
PUW["performUnitOfWork(fiber)"]
BW["beginWork(current, wip, lanes)<br/>Returns child or null"]
CUW["completeUnitOfWork(fiber)"]
CW["completeWork(current, wip, lanes)"]
PUW -->|"call"| BW
BW -->|"child exists"| PUW2["wip = child<br/>Loop continues"]
BW -->|"null (leaf node)"| CUW
CUW --> CW
CW -->|"has sibling"| PUW3["wip = sibling<br/>Loop continues"]
CW -->|"no sibling"| CUW2["wip = return<br/>Walk up"]
提示: 工作循环的树遍历本质上是一种非递归的深度优先遍历,借助 fiber 的链表指针实现。
beginWork负责向下深入子节点,completeUnitOfWork则负责向上回溯。
beginWork — 处理每个 Fiber
beginWork 是一个庞大的函数,根据 fiber.tag(即第 2 篇介绍的 WorkTags)将处理分发到各个专门的更新函数:
FunctionComponent→updateFunctionComponent→ 调用renderWithHooks(执行组件函数)HostComponent→updateHostComponent→ 协调子节点SuspenseComponent→updateSuspenseComponent→ 管理 fallback 与内容的切换MemoComponent→updateMemoComponent→ 检查 props 是否变化
bailout 路径是一项关键优化。当一个 fiber 没有待处理的工作(lanes 不包含 renderLanes)且 props 未发生变化时,beginWork 可以跳过整个子树。这正是 React.memo 和 useMemo 的价值所在——它们能触发 bailout,让 React 跳过树中的整个分支。
在处理 fiber 时,beginWork 会通过 ReactChildFiber.js 触发子节点协调(即"diffing"算法)。这里是 key 匹配发生的地方——React 将旧子节点与新子节点进行比较,对匹配 key 的节点复用 fiber,并为插入、删除和移动操作打上相应的 flag。
completeWork — 为提交做准备
completeWork 在工作循环向上回溯时执行。对于宿主组件(DOM 元素),这里完成以下工作:
- 创建新实例 — 通过宿主配置的
createInstance(用于新挂载的元素) - 计算更新差异 — 通过
prepareUpdate和 prop diffing(用于已存在的元素) - 向上冒泡 flags — 父节点的
subtreeFlags按位或运算合并所有子节点的flags | subtreeFlags
subtreeFlags 的冒泡机制使提交阶段的遍历更加高效。每当 completeWork 处理完一个 fiber,都会执行 returnFiber.subtreeFlags |= completedWork.subtreeFlags | completedWork.flags。这样,任意祖先节点都可以直接判断某个后代是否存在特定副作用,而无需遍历整个子树。
commitRoot — 应用变更
整棵树渲染完成后,commitRoot 会以三个同步子阶段加一个异步阶段的方式将变更应用到宿主环境:
sequenceDiagram
participant BM as Before Mutation
participant M as Mutation
participant L as Layout
participant P as Passive (async)
Note over BM: Read DOM before changes
BM->>BM: getSnapshotBeforeUpdate<br/>View Transition snapshots
Note over M: Apply DOM changes
M->>M: insertions (Placement)<br/>updates (Update)<br/>deletions (ChildDeletion)<br/>ref detachment
Note over L: After DOM mutation
L->>L: useLayoutEffect callbacks<br/>componentDidMount/Update<br/>ref attachment
Note over P: Asynchronous
P->>P: useEffect cleanup<br/>useEffect callbacks
每个子阶段通过第 2 篇介绍的阶段掩码来筛选需要处理的 fiber:
commitBeforeMutationEffects— 使用BeforeMutationMask(主要是Snapshot)commitMutationEffects— 使用MutationMask(Placement、Update、ChildDeletion、Ref、Hydrating、Visibility)commitLayoutEffects— 使用LayoutMask(Update、Callback、Ref、Visibility)- 被动副作用 — 使用
PassiveMask(Passive、Visibility、ChildDeletion),通过 Scheduler 异步调度
mutation 阶段结束后,root.current 会切换为指向已完成的工作树。这意味着 layout effects 和 refs 看到的是新 DOM,而 before-mutation effects 看到的是旧 DOM。
提示: 三阶段提交设计的存在,是因为不同操作需要在 DOM 变更的不同时机执行。
getSnapshotBeforeUpdate必须在 React 修改 DOM 之前读取;componentDidMount必须在 DOM 更新之后运行;而useEffect可以延迟执行,避免阻塞浏览器绘制。
下一篇
至此,我们已经完整追踪了渲染与提交的全过程——从状态更新开始,经过 lane 分配与调度、工作循环的树遍历,直至提交阶段的 DOM 变更。下一篇文章将聚焦于 beginWork 遇到函数组件时发生的事情:hooks 系统——useState、useEffect 以及 dispatcher 模式如何赋予函数组件状态与副作用能力。