Read OSS

工作循环 — 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)将处理分发到各个专门的更新函数:

  • FunctionComponentupdateFunctionComponent → 调用 renderWithHooks(执行组件函数)
  • HostComponentupdateHostComponent → 协调子节点
  • SuspenseComponentupdateSuspenseComponent → 管理 fallback 与内容的切换
  • MemoComponentupdateMemoComponent → 检查 props 是否变化

bailout 路径是一项关键优化。当一个 fiber 没有待处理的工作(lanes 不包含 renderLanes)且 props 未发生变化时,beginWork 可以跳过整个子树。这正是 React.memouseMemo 的价值所在——它们能触发 bailout,让 React 跳过树中的整个分支。

在处理 fiber 时,beginWork 会通过 ReactChildFiber.js 触发子节点协调(即"diffing"算法)。这里是 key 匹配发生的地方——React 将旧子节点与新子节点进行比较,对匹配 key 的节点复用 fiber,并为插入、删除和移动操作打上相应的 flag。

completeWork — 为提交做准备

completeWork 在工作循环向上回溯时执行。对于宿主组件(DOM 元素),这里完成以下工作:

  1. 创建新实例 — 通过宿主配置的 createInstance(用于新挂载的元素)
  2. 计算更新差异 — 通过 prepareUpdate 和 prop diffing(用于已存在的元素)
  3. 向上冒泡 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 系统——useStateuseEffect 以及 dispatcher 模式如何赋予函数组件状态与副作用能力。