React Fiber 并发调度机制:优先级、Lane 与时间切片

引言

如果你曾疑惑「React 18 的并发模式到底在并发什么」,那么答案是:它并发的是「计算」,而不是「UI」。React Fiber 架构的真正野心,是让渲染这件曾经「一鼓作气」的原子操作,变成可以随时暂停、插队、恢复的协作式任务流。本文将从数据结构、优先级模型、调度算法三个层面,把并发调度彻底讲透。

阅读本文前,建议先掌握 React 渲染管线的基础概念(可参考本站《React 渲染流程深度剖析》)。本文将聚焦于「渲染如何进行优先级排序」以及「时间切片如何落地」两大核心问题,全文超过五千字,包含六张流程图。

一、从同步渲染到并发渲染的演进

React 15 及更早版本采用同步递归渲染:一次更新从根节点一路递归到叶子,期间主线程被完全占用。这种模型的致命缺陷在于——渲染一旦开始就无法停止。当应用足够复杂(数千个组件)时,一次渲染可能耗时上百毫秒,期间用户点击、输入全部被阻塞,表现为明显的掉帧与卡顿。

为什么同步渲染会卡顿

flowchart TB
  classDef sA fill:#ffebee,stroke:#c62828,color:#b71c1c
  classDef sB fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef sC fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[用户点击按钮] --> B[setState 入队]
  B --> C[同步渲染开始]
  C --> D[递归遍历整棵组件树]
  D --> E{主线程占用时长}
  E -->|短 <16ms| F[绘制完成 无感知]
  E -->|长 >100ms| G[期间用户输入被阻塞]
  G --> H[掉帧/白屏/点击无响应]
  G --> I[动画卡顿]

  click C "https://github.com/facebook/react/blob/main/packages/react-reconciler/README.md" "reconciler 说明"
  class A sA
  class B sA
  class C sA
  class D sB
  class E sB
  class F sC
  class G sA
  class H sA
  class I sA

协作式调度的目标

React 团队给出的答案是:把渲染工作切分成细粒度的工作单元(Fiber),每完成一部分就检查主线程的「剩余时间」,若预算耗尽则主动让出,等浏览器有空闲帧再继续。整个过程由调度器(Scheduler)统一管理,这便是「并发」的本质——不是多线程并行,而是在单线程内交错执行多个优先级不同的任务

二、Fiber 节点:可中断的最小工作单元

要实现可中断渲染,首先要把「递归调用栈」替换为「显式的链表」。Fiber 节点的设计正是为此而生:它存储了组件的全部信息,并通过指针串联成树,调度器可以在任意节点暂停并保存现场。

单链表树结构

flowchart TB
  classDef fA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef fB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef fC fill:#e0f7fa,stroke:#00838f,color:#004d40

  R[RootFiber] --> A[Fiber: App 组件]
  A --> B[Fiber: div#root]
  B --> C[Fiber: header]
  B --> D[Fiber: main]
  C --> E[Fiber: nav]
  C --> F[Fiber: h1]
  D --> G[Fiber: section]
  D --> H[Fiber: aside]

  A -. return .-> R
  B -. return .-> A
  C -. return .-> B
  D -. return .-> B
  E -. return .-> C
  F -. return .-> C
  G -. return .-> D
  H -. return .-> D

  C -. sibling .-> D
  E -. sibling .-> F
  G -. sibling .-> H

  click B "https://react.dev/learn/understanding-your-ui-as-a-tree" "UI 树概念"
  class A fA
  class B fB
  class C fC
  class D fC
  class E fC
  class F fC
  class G fC
  class H fC
  class R fA

每个 Fiber 都维护了以下核心字段:

  • tag:节点类型(函数组件、类组件、HostComponent 等);
  • typekey:用于 Diff 与复用判断;
  • pendingPropsmemoizedProps:新旧属性快照;
  • stateNode:关联的真实 DOM 或类组件实例;
  • childsiblingreturn:树遍历的三指针;
  • laneschildLanes:该节点参与的更新优先级;
  • alternate:指向双缓存树中的对应节点。

beginWork 与 completeWork

调和阶段遍历这棵链表树时,React 采用「深度优先 + 回溯」策略:beginWork 负责进入节点时的工作(Diff、计算 props、调用函数组件),completeWork 负责节点子树完成后汇总副作用。这种「下山式」遍历替代了原生调用栈,使中断与恢复成为可能。

sequenceDiagram
  participant S as Scheduler
  participant B as beginWork
  participant C as completeWork
  participant L as Lane 标记

  S->>B: 处理节点 A(进入)
  B->>B: Diff 属性与子节点
  B->>C: 返回子节点指针
  C->>C: 汇总子节点副作用
  C->>B: 回溯处理兄弟节点
  B->>S: 本轮时间预算用尽
  S->>S: 记录工作进度(保存遍历栈)
  S->>B: 空闲帧恢复遍历
  C->>L: 全部完成 标记提交 Lane

三、优先级模型:Lane 车道系统

React 17 时代使用数字优先级(PriorityLevel 0~4),粒度过粗。React 18 彻底重构为 Lane 模型:用一个 32 位二进制数(或 31 位的 Lanes)表示「同时存在多条更新」,每一位代表一条车道,数值越小优先级越高。

车道与任务的对应关系

flowchart LR
  classDef lA fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
  classDef lB fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef lC fill:#ffebee,stroke:#c62828,color:#b71c1c
  classDef lD fill:#e3f2fd,stroke:#1976d2,color:#0d47a1

  A[SyncLane 同步车道] --> B[最高优先级<br/>flushSync 强制同步]
  C[InputDiscreteLane<br/>离散输入车道] --> D[点击/输入/按键<br/>必须尽快响应]
  E[InputContinuousLane<br/>连续输入车道] --> F[滚动/拖拽<br/>保持流畅]
  G[DefaultLane 默认车道] --> H[普通 setState<br/>常规更新]
  I[TransitionLane<br/>过渡车道] --> J[useTransition<br/>可延迟的非紧急更新]
  K[IdleLane 空闲车道] --> L[预取/缓存类任务<br/>最后执行]

  B --> M[Lanes 汇总运算]
  D --> M
  F --> M
  H --> M
  J --> M
  L --> M
  M --> N{取最高优先级车道}
  N --> O[进入调度]

  click J "https://react.dev/reference/react/useTransition" "useTransition 说明"
  click I "https://react.dev/reference/react/useTransition" "Transition 车道"

  class A lC
  class C lC
  class E lB
  class G lB
  class I lD
  class K lA
  class N lD

为什么用位运算表示优先级

Lane 模型最强大的地方在于集合运算。一次渲染可能同时包含多条更新,React 需要快速回答三个问题:

  1. 当前更新属于哪条车道? —— lanes & lane !== 0
  2. 多个任务谁先执行? —— 取 getHighestPriorityLane(lanes),即最小的非零位
  3. 该车道的任务还能不能等? —— 通过过期时间 expirationTime 判断是否已过期,过期则升级为不可延迟

位运算的常数级复杂度,让 React 在每次 Fiber 遍历中都能以纳秒级开销完成优先级判断,这是调度性能的基础保障。

四、调度器的工作流程

Scheduler 模块(独立发布的 scheduler 包)负责把渲染任务安排在正确的时机执行。它的核心是一个基于 MessageChannel 的最小堆任务队列

调度循环

flowchart TB
  classDef qA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef qB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef qC fill:#fff3e0,stroke:#f57c00,color:#e65100
  classDef qD fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[scheduleCallback<br/>任务入队] --> B[最小堆按过期时间排序]
  B --> C[requestHostCallback<br/>MessageChannel 触发]
  C --> D[workLoop 开始]
  D --> E{堆顶任务是否过期}
  E -->|是| F[同步执行该任务]
  E -->|否| G{当前帧剩余时间}
  G -->|还有时间| H[执行任务时间片]
  H --> I{任务是否完成}
  I -->|未完成| J[记录中断 重新入堆]
  I -->|完成| K[弹出任务]
  G -->|无时间| L[让出主线程 等下一帧]
  J --> D
  K --> D
  L --> C

  click A "https://github.com/facebook/react/tree/main/packages/scheduler" "scheduler 源码"
  class A qA
  class B qB
  class C qB
  class D qA
  class E qC
  class F qD
  class G qC
  class H qD
  class I qD
  class J qC
  class K qD
  class L qC

时间切片的具体实现

浏览器没有「剩余时间」的原生 API(requestIdleCallback 优先级太低且不兼容),因此 React 自研了一套方案:

  1. requestAnimationFrame 获取当前帧开始时间;
  2. MessageChannelpostMessage 在宏任务阶段尽早触发回调;
  3. 设定 frameInterval(默认 5ms)为单次工作预算;
  4. performance.now() 检测实际耗时,超出预算即返回,shouldYield 返回 true。
pie showData
    title 一帧 16.6ms 的时间分配
    "渲染工作时间片 (5ms)" : 5
    "浏览器布局与绘制" : 8
    "输入响应与空闲缓冲" : 3.6

五、并发特性在应用层的落地

了解了调度内核,再看 React 18 暴露给开发者的并发 API,一切豁然开朗。

useTransition 与 useDeferredValue

flowchart LR
  classDef tA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef tB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef tC fill:#e0f7fa,stroke:#00838f,color:#004d40
  classDef tD fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[用户在搜索框输入] --> B{useTransition 包裹}
  B -->|是| C[输入为同步高优先级]
  C --> D[结果列表为过渡低优先级]
  D --> E[输入流畅不卡顿]
  B -->|否| F[输入与列表同优先级]
  F --> G[列表渲染阻塞输入]
  G --> H[输入掉帧]

  D --> I{useDeferredValue 场景}
  I --> J[图表渲染昂贵 但可延迟]
  J --> K[展示旧值 后台计算新值]

  click D "https://react.dev/reference/react/useTransition" "Transition 概念"
  click K "https://react.dev/reference/react/useDeferredValue" "DeferredValue 说明"

  class A tA
  class B tA
  class C tC
  class D tB
  class E tD
  class F tA
  class G tC
  class H tD
  class I tB
  class J tB
  class K tD

实战要点

  • useTransitionisPending 可用于展示加载占位,避免白屏闪烁;
  • useDeferredValue 适合「渲染昂贵但结果可以晚一点展示」的场景,如大数据筛选;
  • 不要把紧急操作(表单提交、路由跳转)放进 transition,会延迟用户反馈;
  • 并发特性在并发模式下默认开启,StrictMode 会在开发环境执行双调用以暴露不纯函数。

六、中断恢复与副作用安全

并发渲染最微妙的问题在于:被中断的渲染可能永远不会完成。如果组件函数有副作用(如直接修改外部变量、建立订阅后未清理),中断后重跑可能导致状态错乱。因此 React 施加了严格的纪律:

  1. 组件函数必须纯:相同 props 必须返回相同结果;
  2. 副作用必须进 EffectsuseEffect/useLayoutEffect 只在提交阶段执行,天然不会被中断;
  3. 渲染阶段禁止读写 DOM:DOM 操作放在 commit 生命周期或 Effects 中。
flowchart TB
  classDef iA fill:#ffebee,stroke:#c62828,color:#b71c1c
  classDef iB fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[渲染阶段 Render] --> B{是否产生副作用}
  B -->|是 - 允许| C[收集到 Fiber 的 flags 上]
  B -->|否 - 禁止| D{纯计算}
  D -->|是| E[计算结果并入 props/state]
  D -->|否| F[React StrictMode 双调用警告]
  C --> G[提交阶段统一执行副作用]
  G --> H[Passive Effects 异步执行]
  G --> I[Layout Effects 同步执行]

  click F "https://react.dev/reference/react/StrictMode" "StrictMode 双调用说明"
  class A iB
  class B iB
  class C iB
  class D iA
  class E iB
  class F iA
  class G iB
  class H iB
  class I iB

终止与回滚

若渲染因高优先级任务而中断,且低优先级任务最终被废弃(比如用户又执行了更极端的操作),React 会直接丢弃该 workInProgress 树,不执行任何副作用,仅保留一条「已放弃」的记录。副作用只属于提交阶段这一原则,是 React 并发安全性的基石,也解释了为何官方反复强调「不要在渲染期间读写 DOM」。

七、总结

并发调度是一套「空间换时间、结构换可中断」的工程杰作:

  1. Fiber 链表树取代递归调用栈,让渲染可暂停、可恢复;
  2. Lane 位运算模型以常数级开销表达复杂的优先级集合运算;
  3. Scheduler + 时间切片保证输入响应优先,渲染让位于交互;
  4. 双缓存树让中断发生在幕后,用户永远看到完整的 UI;
  5. 副作用后置确保被丢弃的渲染不留任何痕迹。

对应用开发者而言,最重要的不是背诵源码细节,而是理解三条纪律:保持组件纯函数、副作用全部进 Effects、紧急更新用常规 setState、非紧急更新用 Transition。做到这三点,你的应用自然能在复杂交互下保持流畅。

最后给出进阶阅读建议:官方仓库的 react-reconciler/src/ReactFiberLane.js(Lane 定义)、scheduler/src/forks/Scheduler.js(调度循环)、ReactFiberWorkLoop.js(工作循环入口)。三份文件读完,并发调度再无秘密。