Dive into React——调度/并发


5.1 Lanes 优先级模型

直觉锚定

想象一个医院急诊室。病人不是按到达顺序看的,而是按病情紧急程度分诊:心脏骤停立刻处理,骨折可以等一等,普通感冒排最后。但还有一个关键问题------一个医生同一时间只能看一个病人 ,所以"谁先看"不是一张静态列表,而是一套动态优先级计算系统:新来了一个重症病人,可以打断正在处理的轻症,处理完重症再回来接着轻症。

React 的 Lanes 模型就是这个急诊分诊系统:

  • 病人 = 各个 setState 触发的更新
  • 病情等级 = lane(优先级)
  • 医生 = React 的工作循环(performUnitOfWork)
  • 打断轻症去处理重症 = 高优先级更新抢占低优先级
  • 回来接着处理 = 被打断的低优先级恢复执行

⚠️ 常见先入为主的误解: 很多人以为 React 18 的优先级就是"高/中/低"三档数字。实际上 Lanes 是位掩码(bitmask) ,一个 32 位整数里的每一位代表一个独立的"通道",React 通过位运算同时管理多个更新的优先级。这不是"一个数字比大小",而是"一组开关的集合"。


问题背景

React 15 的 Stack Reconciler 没有优先级概念------一旦开始渲染就必须一口气完成,无法中断。这导致两个致命问题:

  1. 用户输入卡顿:一个大的列表渲染正在进行时,用户打字/点击得不到即时响应
  2. 无法区分更新紧急程度:搜索框输入(需要立刻反馈)和数据加载(可以等一等)被同等对待

React 16 引入了 Fiber 架构解决了可中断性问题,但还需要一套优先级系统 来决定"中断后先做谁"。React 17 之前用的是 expirationTime 模型(一个递增的数字),但这个模型有个硬伤------它只能表示一个优先级,无法表达"这次渲染同时包含多个不同优先级的更新"。

Lanes 模型在 React 18 正式取代了 expirationTime,核心变化就是从"一个数字"变成"一组位标志"。


核心数据结构

先看 Lanes 的类型定义(react@18.3.1 · packages/react-reconciler/src/ReactFiberLane.js):

ini 复制代码
type Lanes = number;    // 位掩码,多位可同时为 1
type Lane  = number;    // 单个位,只有一位为 1
​
// 关键常量(从小到大 = 优先级从高到低)
SyncLane            = 0b0000000000000000000000000000001  // 同步,最高优先
InputContinuousLane = 0b0000000000000000000000000000010  // 连续输入(拖拽)
DefaultLane         = 0b0000000000000000000000000000100  // 默认(setState)
TransitionLane      = 0b0000000000000000000000010000000  // transition 更新
IdleLane            = 0b0100000000000000000000000000000  // 空闲时执行

注意:上面是简化表示,实际值是位运算定义的,但逻辑等价。

关键区别------Lanes vs Lane:

概念 类型 含义 类比
Lane 单个位 一个具体的优先级等级 "急诊通道"
Lanes 位掩码 多个优先级的组合 "当前开放的所有通道"

一个 Fiber 节点上存储的优先级信息:

arduino 复制代码
FiberNode {
  lanes: Lanes       // 这个节点上挂了多少个优先级的待处理更新
  childLanes: Lanes  // 子树中有多少个优先级的待处理更新(用于快速判断子树是否需要处理)
}

同时,updateQueue 上的每个 update 对象也带着自己的 lane:

typescript 复制代码
Update {
  lane: Lane         // 这个更新属于哪个优先级
  payload: any       // setState 的参数
  next: Update | null // 链表下一个
}

运行流程

优先级从产生到消费的完整流程:

sql 复制代码
用户操作(点击/输入/setState)
        │
        ▼
 ① 确定优先级:eventPriority → lane
    (react@18.3.1 · ReactFiberLane.js · getEventPriority / reactPriorityToLane)
        │
        ▼
 ② 创建 Update 对象,挂载 lane
    (react@18.3.1 · ReactFiberHooks.js · dispatchSetState)
        │
        ▼
 ③ 入队到 fiber.updateQueue + 标记 fiber.lanes
    同时向上冒泡标记 childLanes
    (react@18.3.1 · ReactFiberWorkLoop.js · scheduleUpdateOnFiber → ensureRootIsScheduled)
        │
        ▼
 ④ 调度:根据 root.pendingLanes 选择当前要处理的 lanes
    (react@18.3.1 · ReactFiberWorkLoop.js · ensureRootIsScheduled → getNextLanes)
        │
        ▼
 ⑤ render 阶段:处理 fiber 时只消费属于当前 renderLanes 的 update
    (react@18.3.1 · ReactFiberHooks.js · processUpdateQueue 中过滤 lane)
        │
        ▼
 ⑥ 不属于当前 renderLanes 的 update 留在队列中,下次再处理

第 ④ 步是核心------getNextLanes 的选择逻辑:

这个函数决定"这一轮渲染到底处理哪些 lane"。伪代码(剪裁自 ReactFiberLane.jsgetNextLanes):

ini 复制代码
function getNextLanes(root, wipLanes) {
  // 1. 读取所有待处理的 lanes
  const pendingLanes = root.pendingLanes;
  if (pendingLanes === NoLanes) return NoLanes; // 没有更新
​
  // 2. 确定本次调度的 lanes(和正在进行的合并)
  let nextLanes = getHighestPriorityLanes(
    pendingLanes & ~root.suspendedLanes  // 排除被挂起的
  );
​
  // 3. 如果已经在渲染中,需要和当前渲染的 lanes 做比较
  if (wipLanes !== NoLanes && wipLanes !== nextLanes) {
    if (nextLanes <= wipLanes) {
      // 新来的优先级更低,不打断,继续当前渲染
      nextLanes = wipLanes;
    }
    // 否则新来的优先级更高,打断当前渲染
  }
​
  return nextLanes;
}

第 ⑤ 步------render 阶段过滤 update:

processUpdateQueue 中,遍历 updateQueue 链表时:

csharp 复制代码
// 简化自 ReactFiberClassComponent.js 的 processUpdateQueue
let update = queue.firstBaseUpdate;
while (update !== null) {
  if (isSubsetOfLanes(renderLanes, update.lane)) {
    // update.lane 是 renderLanes 的子集 → 本次处理
    // 计算新 state
  } else {
    // 不属于本次 renderLanes → 跳过,留在队列
    // 但要保留到新的 baseQueue 里
  }
  update = update.next;
}

isSubsetOfLanes 是位运算检查:(lanes & lane) === lane,判断这个 update 的优先级是否在当前要处理的范围内。


设计动机与权衡

为什么用位掩码而不是数字?

旧方案 expirationTime 是一个递增数字,本质是"比大小":

ini 复制代码
// 旧方案:一次渲染只能有一个优先级
renderExpirationTime = 5000
// 遇到 expirationTime = 3000 的更新 → 跳过
// 遇到 expirationTime = 6000 的更新 → 立刻处理

问题:如果一个组件同时有高优先级和低优先级的更新,旧方案必须选一个,另一个就被丢掉或强制提升优先级(starvation 问题)。

新方案 Lanes 是位掩码,可以同时携带多个优先级

ini 复制代码
// 新方案:renderLanes = SyncLane | DefaultLane
// 0b001 | 0b100 = 0b101
// 同时包含同步和默认优先级,两种 update 都会被处理

牺牲了什么?

  • 代码复杂度大幅增加:位运算不如数字比较直观,调试时需要手动转换二进制
  • 概念理解门槛高:Lanes / Lane / childLanes / pendingLanes 多层抽象

与 expirationTime 的关键对比:

维度 expirationTime(旧) Lanes(新)
本质 单个数字 位掩码(多个标志的集合)
一次渲染 只能处理一个优先级 可以同时处理多个优先级
优先级判断 数值比较 位运算(包含/排除/交集)
子树跳过 需要遍历才知道 childLanes 位运算直接判断
批量更新 容易 starve 低优先级 可以按 lane 分组处理

次级误解和边界

误解 1:"高优先级更新会立即执行"

错。高优先级更新会立即调度,但调度不等于执行。React 的工作循环受 Scheduler 控制,即使优先级是 SyncLane(最高),也要等当前正在执行的 JS 宏任务/微任务完成,Scheduler 才会把回调放入事件循环。所谓"立即",指的是"下一个事件循环周期",而不是"打断当前正在执行的 JS 代码"。

误解 2:"lanes 和 childLanes 是同一个东西"

它们的数据类型相同(都是 Lanes 位掩码),但语义不同:

  • fiber.lanes:这个 Fiber 节点自身有多少优先级的待处理更新
  • fiber.childLanes:这个 Fiber 节点的整个子树中,有多少优先级的待处理更新

childLanes 的作用是快速剪枝 :在 render 阶段遍历到某个 Fiber 时,如果 fiber.childLanes & renderLanes === 0,说明整个子树都没有需要处理的更新,可以直接跳过(bailout),不用继续遍历子节点。这就是为什么 scheduleUpdateOnFiber 要沿 return 链向上冒泡标记 childLanes------为了后续遍历时能快速判断。

误解 3:"所有 setState 都用 DefaultLane"

不完全对。setState 的优先级取决于触发它的上下文

  • 在事件处理函数中调用 → 由事件类型决定(点击 = SyncLane,输入 = InputContinuousLane)
  • 在 setTimeout / Promise 中调用 → DefaultLane
  • startTransition 中调用 → TransitionLane
  • requestIdleCallback 中调用 → IdleLane

现在我们知道了 Lanes 是基于位掩码的多优先级模型,每个更新带着自己的 lane,render 阶段按 renderLanes 过滤消费。但这里有一个问题还没回答:当高优先级更新打断了正在进行的低优先级渲染时,低优先级的工作怎么恢复?被中断的 Fiber 树状态怎么处理?

这就是 5.2 时间切片(Time Slicing)实现 要回答的事情。


5.2 时间切片(Time Slicing)实现

直觉锚定

继续用急诊室的类比。之前的 Lanes 解决了"谁先看"的问题,但还有一个问题没解决:一个医生看一个复杂病例(比如全身检查)可能要花 30 分钟,这期间其他病人全在等

时间切片的方案是:医生给自己设一个闹钟(比如每 5 分钟响一次)。闹钟一响,即使检查没做完,也先暂停当前病例,去看看候诊室有没有更紧急的病人。如果没有,回来接着刚才的进度继续做检查。

这里的映射:

  • 医生 = React 的工作循环(workLoop)
  • 闹钟 = Scheduler 的 deadline(时间片到期)
  • 5 分钟 = yieldInterval(默认 5ms)
  • 暂停当前病例 = yield 给浏览器(让浏览器处理用户输入、渲染等)
  • 接着进度继续 = 从中断的 Fiber 节点恢复(wip 指针还在)

问题背景

Fiber 架构把渲染拆成了一个个小单元(每个 Fiber 节点 = 一个工作单元),理论上可以随时暂停。但光"可以暂停"不够,还需要回答两个问题:

  1. 什么时候暂停? 不能每处理一个 Fiber 就问一次浏览器,那样调度开销太大;也不能一直不暂停,那样又回到 Stack Reconciler 的老路
  2. 暂停后怎么恢复? 中断时的工作进度怎么保存,下次从哪接着来

这两个问题分别由 Scheduler (调度器)和 Reconciler 的工作循环(workLoop)协作解决。

⚠️ 常见先入为主的误解: 很多人以为时间切片是"每处理一个组件就 yield 一次"。实际上 React 只在每个 Fiber 节点处理完后检查一次是否应该让出控制权,而且只有在 Concurrent Mode 下才会 yield。Legacy Mode 下即使每个 Fiber 都处理完了也不会 yield,必须一口气完成。


核心数据结构

时间切片涉及两个包的协作:

Scheduler 侧(packages/scheduler/src/forks/Scheduler.js):

typescript 复制代码
Scheduler 全局状态 {
  firstCallbackNode: LinkedListNode  // 按优先级排序的回调队列(链表)
  currentTime: number                // 当前时钟
  deadline: number                   // 当前时间片的截止时间
  yieldInterval: number              // 时间片长度(默认 5ms)
  isPerformingWork: boolean          // 是否正在执行回调
}

每个调度回调 {
  callback: Function                 // Reconciler 传入的 performConcurrentWorkOnRoot
  expirationTime: number             // 超时时间(优先级越高越早)
  priorityLevel: number              // 优先级
}

Reconciler 侧(packages/react-reconciler/src/ReactFiberWorkLoop.js):

php 复制代码
工作循环状态 {
  workInProgress: Fiber | null       // 当前正在处理的 Fiber 节点(这就是"断点")
  renderLanes: Lanes                 // 当前渲染轮次处理的优先级集合
}

workInProgress 指针就是中断恢复的关键------只要它不为 null,说明还有没处理完的 Fiber,下次恢复时从这里继续。


运行流程

整个时间切片的完整流程:

scss 复制代码
① scheduleUpdateOnFiber
   └── ensureRootIsScheduled
       └── 调用 Scheduler.scheduleCallback(priority, performConcurrentWorkOnRoot)
           │
           ▼
② Scheduler 收到回调,按优先级插入链表队列
   └── 通过 MessageChannel(宏任务)触发 performWorkUntilDeadline
       │
       ▼
③ performWorkUntilDeadline
   ├── 计算 deadline = currentTime + yieldInterval(5ms)
   └── 循环执行回调:
       │
       ▼
④ performConcurrentWorkOnRoot(root)
   └── renderRootConcurrent(root, renderLanes)
       └── workLoopConcurrent()
           │
           ▼
⑤ workLoopConcurrent ------ 核心循环
   ┌──────────────────────────────────────┐
   │ while (workInProgress !== null) {    │
   │   performUnitOfWork(workInProgress)  │
   │                                      │
   │   if (shouldYield()) {  ← 关键判断   │
   │     break;  // 暂停,wip 保存断点    │
   │   }                                  │
   │ }                                    │
   └──────────────────────────────────────┘
       │
       ├─ shouldYield = false → 继续下一个 Fiber
       │
       └─ shouldYield = true → break 退出循环
           │
           ▼
⑥ performConcurrentWorkOnRoot 返回一个新的回调函数
   (Scheduler 约定:如果回调返回函数,就自动重新调度)
   └── 返回值 = performConcurrentWorkOnRoot.bind(null, root)
       │
       ▼
⑦ Scheduler 把返回的回调重新放入队列
   └── 下一个宏任务继续执行 → 回到 ③

shouldYield 的实现

这是 Scheduler 提供给 Reconciler 的核心接口:

csharp 复制代码
// packages/scheduler/src/forks/Scheduler.js
function shouldYieldToHost() {
  const currentTime = getCurrentTime();
  return currentTime >= deadline;
}

就这么简单------比较当前时间和 deadline。deadline 在每次进入 performWorkUntilDeadline 时设置为 currentTime + 5ms

对比:Concurrent Mode vs Legacy Mode

Reconciler 有两个工作循环,区别就在于是否检查 shouldYield:

scss 复制代码
// Concurrent Mode ------ 可中断
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

// Legacy Mode ------ 不可中断
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

唯一的区别就是 !shouldYield()。Legacy Mode 的工作循环没有退出条件(除了 workInProgress 为 null),所以必须一口气跑完。

恢复机制

中断后重新调度时,不需要特殊处理------workInProgress 指针天然就是断点:

ini 复制代码
第一次进入:
  workInProgress = FiberA → FiberB → FiberC → shouldYield() = true(停了)
                                              ↑ wip 停在 FiberC

第二次进入(新的宏任务):
  workInProgress 还是 FiberC → 继续处理 → FiberD → ... → null(完成)

因为 workInProgress 是模块级变量(闭包中),在两次宏任务之间不会丢失。


设计动机与权衡

为什么是 5ms?

React 团队的选择基于两个约束:

  • 浏览器通常以 60fps 运行,一帧约 16.6ms
  • 其中浏览器自己的工作(样式计算、布局、绘制)大约需要 6-10ms
  • 留给 JS 的时间约 5-6ms ,所以 yieldInterval = 5ms 是合理的上限

如果时间片设得太短(如 1ms),调度开销(MessageChannel 通信、上下文切换)占比过大;设得太长(如 10ms),用户可能感知到卡顿。

为什么用 MessageChannel 而不是 setTimeout?

arduino 复制代码
// Scheduler 的调度机制(优先级从高到低尝试)
// 1. setImmediate(Node.js 环境,最快)
// 2. MessageChannel(浏览器环境,宏任务)
// 3. setTimeout(callback, 0)(兜底方案)

setTimeout(fn, 0) 实际有 4ms 的最小延迟(HTML 规范),这意味着 yield 后至少等 4ms 才能恢复。MessageChannel 没有这个限制,可以在下一个宏任务中立即恢复。

牺牲了什么?

  • 总渲染时间变长 :如果一次渲染需要 20ms,时间切片让它变成 4 个 5ms 的片段,加上 3 次宏任务切换的开销,总耗时可能到 25-30ms。时间切片不加快渲染,只是让高优先级交互不被阻塞
  • 状态一致性复杂度:中断时 Fiber 树处于半完成状态(部分节点已处理,部分未处理),这要求后续机制能正确处理这种中间态

次级误解和边界

误解 1:"时间切片是 React 18 才有的"

时间切片的核心机制在 React 16 就已经有了(Concurrent Mode 作为实验特性)。React 18 的变化是默认开启并发特性createRoot 替代 ReactDOM.render),而不是发明了时间切片本身。

误解 2:"每个 setState 都会被时间切片"

不是。只有 Concurrent Mode 下使用非 SyncLane 优先级的更新才会被时间切片。以下场景不会被切片:

  • ReactDOM.render(Legacy Mode)------ 永远不会 yield
  • flushSync 包裹的更新 ------ 强制同步完成
  • SyncLane 优先级的更新 ------ 即使在 Concurrent Mode 下也使用 workLoopSync(不 yield)

误解 3:"yield 之后浏览器立刻就能响应用户输入"

yield 后,控制权回到事件循环。但如果 Scheduler 已经排了更高优先级的回调(比如之前的 DefaultLane 还没处理完),新回调会在用户输入事件之后、浏览器渲染之前被处理。用户输入的事件处理器能立刻执行,但 React 的响应还是要等调度。不过这在实践中影响极小,因为事件处理器本身就是同步的。


现在我们知道了时间切片通过 Scheduler 的 deadline 机制和 workInProgress 指针实现了可中断-可恢复的渲染。但这里有一个问题:如果一个组件在同一个事件里连续调用了 5 次 setState,React 会不会渲染 5 次?

这就是 5.3 批量更新(Batching)原理 要回答的事情。

5.3 批量更新(Batching)原理

直觉锚定

想象你在餐厅点菜。如果你每说一道菜,服务员就跑一趟后厨下单,那 5 道菜要跑 5 趟,后厨也要开 5 次灶。聪明的做法是服务员拿个小本记着,等你点完所有菜,一次性交给后厨

React 的批量更新就是这个小本:

  • 你点菜 = 多次 setState 调用
  • 小本记录 = updateQueue 链表累积 update
  • 一次性交给后厨 = 只调度一次渲染,processUpdateQueue 一次性消费所有 update

问题背景

假设有这样一个组件:

scss 复制代码
function handleClick() {
  setCount(1);      // setState 1
  setFlag(true);    // setState 2
  setName('React'); // setState 3
}

如果不做批量处理,每次 setState 都会:

  1. 创建 update → 入队 → 调度 → render → commit
  2. 三次 setState = 三次完整渲染

三次渲染中,前两次的结果会被第三次覆盖,用户最终只看到最终状态。前两次渲染完全是浪费。

批量更新的目标:同一个执行上下文中的多次 setState,只触发一次渲染。

⚠️ 常见先入为主的误解: 很多人以为 React 18 之前完全没有批量更新。实际上 React 17 在事件处理函数内 就有批量更新,只是在 setTimeout、Promise 回调等"非 React 管理的上下文"中不生效。React 18 的改进是所有上下文都自动批量更新


核心数据结构

批量更新的关键不在于新的数据结构,而在于 ensureRootIsScheduled去重逻辑

php 复制代码
FiberRoot {
  pendingLanes: Lanes          // 所有待处理更新的 lanes 并集
  callbackNode: mixed         // 当前已调度到 Scheduler 的回调句柄
  callbackPriority: Lane      // 当前已调度回调的优先级
}

这三个字段就是批量更新的核心状态:

  • callbackNode 不为 null → 说明已经有一次渲染被调度了
  • callbackPriority → 这次调度对应什么优先级
  • pendingLanes → 累积的所有待处理 lanes

运行流程

React 18 的自动批量更新

scss 复制代码
handleClick() 被调用
  │
  ├── setCount(1)  ─→ dispatchSetState ─→ scheduleUpdateOnFiber
  │                    创建 update1,挂到 fiber.updateQueue
  │                    fiber.lanes |= SyncLane
  │                    markRootUpdated(root, SyncLane)
  │                    ensureRootIsScheduled(root) ←── 第 1 次
  │                    ├── root.callbackNode === null
  │                    └── 调用 Scheduler.scheduleCallback → 得到 callbackNode1
  │
  ├── setFlag(true) ─→ dispatchSetState ─→ scheduleUpdateOnFiber
  │                    创建 update2,挂到 fiber.updateQueue(链表追加)
  │                    fiber.lanes |= SyncLane(已经是1,没变)
  │                    markRootUpdated(root, SyncLane)(没变)
  │                    ensureRootIsScheduled(root) ←── 第 2 次
  │                    ├── root.callbackNode !== null(已经有调度了!)
  │                    ├── 检查优先级是否一致 → 一致
  │                    └── 直接 return,不调度新的回调
  │
  ├── setName('React') ─→ 同上 ←── 第 3 次
  │                      ensureRootIsScheduled
  │                      ├── callbackNode 已存在
  │                      └── 直接 return
  │
  ▼ (handleClick 执行完毕,控制权回到事件循环)

Scheduler 在下一个宏任务执行 callbackNode1
  └── performConcurrentWorkOnRoot
      └── renderRootConcurrent
          └── processUpdateQueue
              ├── update1: count = 1     ✓ 消费
              ├── update2: flag = true   ✓ 消费
              └── update3: name = 'React' ✓ 消费
              → 一次渲染,得到最终状态

关键在 ensureRootIsScheduled 的去重判断react@18.3.1 · ReactFiberWorkLoop.js):

ini 复制代码
function ensureRootIsScheduled(root) {
  const existingCallbackNode = root.callbackNode;
  const nextLanes = getNextLanes(root, root === currentlyRenderingRoot
    ? workInProgressRootRenderLanes : NoLanes);

  if (nextLanes === NoLanes) {
    // 没有需要处理的更新
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    return;
  }

  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  // ↓↓↓ 批量更新的核心判断 ↓↓↓
  if (existingCallbackNode !== null) {
    const existingCallbackPriority = root.callbackPriority;
    if (existingCallbackPriority === newCallbackPriority) {
      // 优先级相同,已有的调度就能覆盖这次更新
      // 直接返回,不重新调度
      return;
    }
    // 优先级不同(来了更高优先级的更新),取消旧调度
    cancelCallback(existingCallbackNode);
  }

  // 首次调度或优先级变化时,才真正调度
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root)
  );
}

React 17 的批量更新(对比)

React 17 通过一个全局标志位实现事件处理器内的批量更新:

php 复制代码
// packages/react-reconciler/src/ReactFiberWorkLoop.js(React 17)
let isBatchingUpdates = false;

function batchedUpdates(fn, a) {
  const prevIsBatchingUpdates = isBatchingUpdates;
  isBatchingUpdates = true;       // ← 开启批量
  try {
    return fn(a);                 // ← 事件处理函数在这里执行
  } finally {
    isBatchingUpdates = prevIsBatchingUpdates;
    if (!prevIsBatchingUpdates) {
      flushSync();                // ← 批量结束,一次性刷新
    }
  }
}

function scheduleUpdateOnFiber(fiber, lane) {
  // 标记 lanes...
  if (isBatchingUpdates) {
    // 只标记,不调度
    return;
  }
  // 非批量模式下,立即调度
  ensureRootIsScheduled(root);
}

问题:isBatchingUpdates 只在 React 事件处理函数内被设为 true。在 setTimeout / Promise 中是 false:

scss 复制代码
// React 17 的行为
function handleClick() {
  setCount(1);  // 批量,不立即渲染
  setFlag(true); // 批量,不立即渲染
} // ← 这里一次性渲染

setTimeout(() => {
  setCount(2);  // 非批量!立即渲染
  setFlag(false); // 非批量!又立即渲染
}, 0);
// ↑ 两次渲染

React 18 为什么不需要标志位了?

因为 React 18 的 ensureRootIsScheduled 通过 root.callbackNode 天然实现了去重------不管你是在事件处理器还是 setTimeout 里,只要上一次调度还没被执行(callbackNode 不为 null),新的 setState 就只是往 updateQueue 追加 update、往 pendingLanes 追加 lane,不会产生新的调度

这个设计把"是否批量"的判断从执行上下文 (事件处理器 vs setTimeout)转移到了调度状态 (callbackNode 是否存在),从而实现了所有上下文自动批量


设计动机与权衡

为什么 React 17 不直接做到所有上下文自动批量?

因为自动批量意味着 setState 变成异步的(状态不立刻更新)。在 React 17 的 Legacy Mode 下,很多代码依赖"setState 之后立刻从 this.state 读到新值"的行为(Class 组件的 this.state 是同步读取的)。如果在 setTimeout 里也自动批量,会打破这些现有代码的预期。

React 18 敢这么做,是因为:

  1. 推荐用函数组件 + hooks,不存在 this.state 同步读取的问题
  2. createRoot 是全新 API,可以视为 breaking change 的契机
  3. 提供了 flushSync 作为逃生舱(强制同步渲染,不做批量)

牺牲了什么?

  • 调试困难:多个 setState 批量执行后,中间状态不可观测,打断点看不到逐步变化
  • 心智模型变化:开发者需要理解"setState 不立即生效",这在 React 18 之前只在事件处理器内如此

次级误解和边界

误解 1:"React 18 里 setState 永远是批量的"

不是。flushSync 可以强制同步渲染,退出批量模式:

scss 复制代码
setTimeout(() => {
  flushSync(() => {
    setCount(1);  // 立即渲染一次
  });
  // 此时 DOM 已更新,count = 1
  flushSync(() => {
    setCount(2);  // 又立即渲染一次
  });
  // count = 2
});

flushSync 的代价是强制同步渲染,不经过 Scheduler 调度,不会 yield,性能差。只在确实需要"setState 后立刻读到 DOM"的场景使用。

误解 2:"不同组件的 setState 也会被批量"

是的。批量更新不区分组件------只要是在同一个同步执行上下文中,A 组件的 setState 和 B 组件的 setState 都会被 ensureRootIsScheduled 去重,最终只触发一次渲染。整个 FiberRoot 树只调度一个回调。

误解 3:"批量更新意味着只更新一次 DOM"

准确。批量更新不仅减少 render 次数,也减少 commit 次数。一次 render + 一次 commit = 一次 DOM 更新。多个组件的多个 state 变化,最终只在 commit 阶段一次性写入 DOM。


现在我们知道了批量更新通过 ensureRootIsScheduled 的去重机制实现,同一个执行上下文中的多次 setState 只触发一次渲染。但这里有一个问题:如果开发者想主动标记某些更新是"不紧急的",让它们不阻塞用户输入,该怎么做?

这就是 5.4 useTransition / startTransition 要回答的事情。

5.4 useTransition / startTransition

直觉锚定

继续急诊室的类比。之前 Lanes 解决了分诊问题,时间切片解决了"闹钟暂停"问题,批量更新解决了"点菜合并"问题。但还有一个场景没覆盖:

用户正在输入搜索关键词,同时搜索结果列表要做大量渲染。输入框的响应(高优先级)不应该被结果列表的渲染(低优先级)拖慢。 但默认情况下,同一个事件处理函数里的 setState 都拿到同样的优先级。

startTransition 就是让开发者手动标记:"这几个 setState 不紧急,可以被抢占。"

映射:

  • 紧急的事 = 输入框值更新(用户在等光标反应)
  • 不紧急的事 = 搜索结果列表渲染(用户可以等几百毫秒)
  • 手动标记 = 用 startTransition 包裹不紧急的 setState

问题背景

没有 startTransition 之前,这类场景的典型解法是手动 debounce:

scss 复制代码
function handleChange(e) {
  setInputValue(e.target.value);      // 立即更新输入框
  setTimeout(() => {
    setSearchQuery(e.target.value);    // 延迟更新搜索结果
  }, 300);
}

debounce 的问题:

  1. 延迟是固定的------300ms 在性能好的设备上太慢,在差的设备上又不够
  2. 不能被抢占------一旦触发就开始渲染,用户连续输入时无法中断上一轮
  3. 中间状态丢失------debounce 只执行最后一次,中间的渲染结果被跳过

React 需要一个声明式的优先级标记机制,让开发者告诉 React"这个更新可以慢",React 自动处理抢占和恢复。


核心数据结构

startTransition 的全局标记packages/react/src/ReactStartTransition.js):

csharp 复制代码
ReactSharedInternals.T = TransitionLane | null

// 当 T 不为 null 时,内部的 setState 调用会读到这个值
// 从而获得 TransitionLane 优先级,而非默认的 SyncLane

useTransition 的 Hook 状态packages/react-reconciler/src/ReactFiberTransition.js):

javascript 复制代码
TransitionHook {
  memoizedState: {
    _transitions: Array<Transition>   // 当前待处理的 transition 数组
  }
}

Transition {
  _callbacks: Array<Function>         // transition 完成后要执行的回调
}

React 内部跟踪 transition 的待处理状态:

csharp 复制代码
FiberRoot {
  pendingTransitions: Transition | null  // 待处理的 transition
}

运行流程

startTransition 的执行

scss 复制代码
用户输入 "Re"
  │
  ▼
handleChange(e)
  │
  ├── setInputValue("Re")              ← 不包裹,正常优先级
  │   └── dispatchSetState
  │       └── requestUpdateLane(fiber)
  │           └── ReactSharedInternals.T === null
  │               → 返回 SyncLane(来自事件处理器上下文)
  │               → update1.lane = SyncLane
  │
  └── startTransition(() => {
        setSearchQuery("Re")           ← 包裹在 transition 里
      })
      │
      ▼
  ① ReactSharedInternals.T = TransitionLane   ← 设置全局标记
  │
  ├── setSearchQuery("Re")
  │   └── dispatchSetState
  │       └── requestUpdateLane(fiber)
  │           └── ReactSharedInternals.T === TransitionLane
  │               → 返回 TransitionLane        ← 优先级变了!
  │               → update2.lane = TransitionLane
  │
  └── ReactSharedInternals.T = null            ← 恢复

核心就是三步:设置标记 → 执行回调(内部的 setState 读到标记)→ 清除标记。

requestUpdateLane 的优先级选择逻辑(简化自 react@18.3.1 · ReactFiberWorkLoop.js):

kotlin 复制代码
function requestUpdateLane(fiber) {
  const transition = ReactSharedInternals.T;
  if (transition !== null) {
    // 在 transition 上下文内 → 返回 TransitionLane
    return claimNextTransitionLane();
  }
  // 否则根据当前事件优先级决定
  const updateLane = getCurrentUpdatePriority();
  if (updateLane !== NoLane) {
    return updateLane;
  }
  // 兜底
  return DefaultLane;
}

调度和渲染时的行为

ini 复制代码
两个 update 入队后:
  root.pendingLanes = SyncLane | TransitionLane

ensureRootIsScheduled:
  getNextLanes → 返回 SyncLane(更高优先级)
  renderLanes = SyncLane

第一轮渲染(renderLanes = SyncLane):
  processUpdateQueue:
    update1 (SyncLane)     → isSubsetOfLanes → true  → 消费
    update2 (TransitionLane) → isSubsetOfLanes → false → 跳过,保留
  → 只渲染输入框更新,commit 到 DOM

第一轮 commit 后:
  ensureRootIsScheduled 再次检查
  root.pendingLanes 还剩 TransitionLane
  → 调度第二轮渲染

第二轮渲染(renderLanes = TransitionLane):
  processUpdateQueue:
    update2 (TransitionLane) → isSubsetOfLanes → true → 消费
  → 渲染搜索结果列表,commit 到 DOM

如果用户在第二轮渲染(TransitionLane)过程中又输入了 "Rea"?

erlang 复制代码
第二轮渲染进行中,用户输入 "Rea"

handleChange("Rea"):
  setInputValue("Rea")  → SyncLane update 入队
  startTransition(() => setSearchQuery("Rea")) → TransitionLane update 入队

ensureRootIsScheduled:
  发现新来的 SyncLane > 当前正在渲染的 TransitionLane
  → 打断当前渲染!
  → 重新以 SyncLane 开始第一轮渲染

这就是 startTransition 的"可抢占"特性------TransitionLane 的渲染可以随时被 SyncLane 打断,用户输入永远不会被搜索结果渲染阻塞。

useTransition 的 isPending

useTransitionstartTransition 多一个 isPending 状态,用于显示过渡 UI:

ini 复制代码
const [isPending, startTransition] = useTransition();

// isPending = true → 显示旧内容 + 加载指示器
// isPending = false → 显示新内容

实现原理:React 在 transition 开始时将 isPending 设为 true 并触发一次同步渲染(更新加载指示器 UI),transition 完成后设为 false 再触发一次渲染。


设计动机与权衡

为什么不用 debounce?

维度 debounce startTransition
延迟 固定时间(如 300ms) 自适应(依赖设备性能)
可中断 不能 可以(高优先级来了就打断)
中间状态 丢弃(只执行最后一次) 保留(每次 transition 都会渲染,只是可能被更新替代)
用户感知 可能有固定等待感 输入永远流畅,结果"跟在后面"出现

为什么 startTransition 是手动标记,而不是自动检测?

React 无法自动判断哪些更新是"可以慢的"。setInputValuesetSearchQuery 都是普通的 setState,React 不知道哪个是用户直接感知的、哪个是衍生结果。这个语义信息只有开发者知道,所以必须手动标记。

牺牲了什么?

  • API 复杂度:开发者需要理解优先级概念才能正确使用
  • 过渡 UI 的额外渲染isPending 的 true/false 切换各触发一次额外渲染
  • 不是所有场景都适用:如果"紧急"和"不紧急"的 state 在同一个组件且紧密耦合,拆分优先级可能带来状态不一致的困扰

次级误解和边界

误解 1:"startTransition 里的代码是异步执行的"

不是。startTransition(callback)同步执行 callback 的。callback 里的 setState 也是同步入队的。"异步"的是渲染时机------TransitionLane 的渲染优先级低,可能被延迟到后续的调度周期才执行。但 callback 本身是同步的。

误解 2:"isPending 为 true 时,旧 UI 已经被新渲染替换了"

恰恰相反。isPending = true 时,旧 UI 还在屏幕上 。React 在 transition 完成前不会 commit 新的渲染结果。isPending 的作用就是让你在旧 UI 上叠加一个加载指示器,告诉用户"新的内容正在准备中"。

误解 3:"useDeferredValue 和 startTransition 是一回事"

它们的目标相同(降低优先级),但用法不同:

  • startTransition:你主动控制哪些 setState 是低优先级
  • useDeferredValue:对一个已有的值自动延迟更新
scss 复制代码
// 等价写法
const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');

// 方式 1:startTransition
handleChange = (e) => {
  setQuery(e.target.value);
  startTransition(() => setDeferredQuery(e.target.value));
};

// 方式 2:useDeferredValue
const deferredQuery = useDeferredValue(query);

useDeferredValue 内部实现就是用 startTransition 包了一个 setState。


现在我们知道了 startTransition 通过全局标记让内部的 setState 获得 TransitionLane 优先级,实现可抢占的低优先级更新。但这里有一个更大的问题:Concurrent Mode 和 Legacy Mode 到底有什么本质区别?React 18 的 createRoot 做了什么让一切并发特性生效?

这就是 5.5 Concurrent Mode 与 Legacy Mode 的区别 要回答的事情。

5.5 Concurrent Mode 与 Legacy Mode 的区别

直觉锚定

想象两种厨房运作模式:

Legacy Mode(传统厨房) :厨师拿到一张订单,就必须从头做到尾,中间不能停。即使 VIP 客人来了也得等当前这一桌做完。好处是简单------每桌菜保证一口气上齐。坏处是高峰期 VIP 也要排队。

Concurrent Mode(并发厨房) :厨房里装了个叫号系统。普通订单可以按时间片做,做到一半如果 VIP 订单进来,立刻停下来先做 VIP 的。做完再回来继续普通订单。厨房的灶台还是那一个(单线程),但接单策略变了。

注意:两种模式用的是同一套 Fiber 架构、同一套 Lanes 优先级、同一套 Diff 算法。区别只在"渲染能不能被中断"这一个开关。


问题背景

React 18 之前,ReactDOM.render 是唯一入口,所有更新都是同步的。React 16-17 虽然内部已经有 Concurrent Mode 的实验实现,但默认不开。

React 18 引入了 createRoot 作为新入口,将并发特性设为默认。但为了向后兼容,ReactDOM.render 仍然保留。两个入口创建的 FiberRoot 有不同的 tag,这个 tag 决定了后续所有行为差异。

⚠️ 常见先入为主的误解: 很多人以为 Concurrent Mode 是 React 18 新发明的。实际上 Fiber 架构在 React 16 就设计好了可中断渲染的能力,只是 React 16-17 把它作为实验特性(ReactDOM.unstable_createRoot)。React 18 做的是正式发布 并且改变默认入口


核心数据结构

RootTag ------两种模式的根本区分(packages/react-reconciler/src/ReactRootTags.js):

ini 复制代码
const LegacyRoot    = 0;  // ReactDOM.render 创建
const ConcurrentRoot = 2; // ReactDOM.createRoot 创建

FiberRoot 创建时的差异packages/react-reconciler/src/ReactFiberRoot.js):

php 复制代码
FiberRoot {
  tag: RootTag                    // ← 这个字段决定一切
  // LegacyRoot = 0
  // ConcurrentRoot = 2
  
  // 其余字段两种模式完全相同
  current: Fiber
  pendingLanes: Lanes
  callbackNode: mixed
  ...
}

两种模式的 FiberRoot 结构完全相同 ,唯一区别就是 tag。所有后续的行为差异都是代码读 root.tag 后走不同分支。


运行流程

入口差异

javascript 复制代码
// Legacy Mode 入口
ReactDOM.render(<App />, document.getElementById('root'));
// 内部:legacyCreateRootFromDOMContainer → createRoot(tag = LegacyRoot)

// Concurrent Mode 入口
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
// 内部:createRoot(tag = ConcurrentRoot)

关键分支:ensureRootIsScheduled

两种模式分叉的核心在 ensureRootIsScheduledreact@18.3.1 · ReactFiberWorkLoop.js):

scss 复制代码
function ensureRootIsScheduled(root) {
  const nextLanes = getNextLanes(root, ...);

  // 检查是否包含同步优先级
  const includesSyncLane = (nextLanes & (SyncLane | InputContinuousLane)) !== NoLanes;

  // ↓↓↓ 关键分支 ↓↓↓
  const shouldScheduleSync =
    includesSyncLane ||
    (root.tag === LegacyRoot);  // ← Legacy 模式下强制同步!

  if (shouldScheduleSync) {
    // 同步调度:微任务(queueMicrotask)
    // → 不经过 Scheduler,直接在当前微任务中执行
    // → workLoopSync(不可中断)
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  } else {
    // 并发调度:通过 Scheduler(MessageChannel 宏任务)
    // → 有时间切片(5ms deadline)
    // → workLoopConcurrent(可中断)
    root.callbackNode = scheduleCallback(
      schedulerPriority,
      performConcurrentWorkOnRoot.bind(null, root)
    );
  }
}

核心区别就一行:root.tag === LegacyRoot 强制走同步路径。

这意味着:

  • LegacyRoot :无论 lane 是什么,永远用 scheduleSyncCallbackworkLoopSync(不 yield)
  • ConcurrentRoot :SyncLane 走同步路径,其他 lane 走并发路径 → workLoopConcurrent(会 yield)

两条工作循环的对比

scss 复制代码
// LegacyRoot 永远走这条
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
    // 没有 shouldYield 检查
  }
}

// ConcurrentRoot 的非同步更新走这条
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
    // 有 shouldYield 检查 → 可中断
  }
}

完整行为差异

行为 LegacyRoot ConcurrentRoot
时间切片 有(非 SyncLane 更新)
可中断渲染 不可以 可以(非 SyncLane 更新)
批量更新范围 仅 React 事件处理器内 所有上下文(setTimeout、Promise 等)
startTransition 退化为同步(无优先级区分) 正常工作(可被抢占)
useDeferredValue 无效果 延迟更新
Suspense 数据获取 有限支持 完整支持
Strict Mode 双调用 有(开发模式) 有(开发模式,但行为更一致)
setState 后读 DOM 可能读到中间状态 不应该依赖(推荐 flushSync)

LegacyRoot 批量更新的局限

scss 复制代码
// LegacyRoot 下
setTimeout(() => {
  setCount(1);   // ensureRootIsScheduled → shouldScheduleSync = true
                 // → scheduleSyncCallback → 立即同步渲染
  setFlag(true); // 又一次同步渲染
}, 0);
// ↑ 两次渲染,没有批量

// ConcurrentRoot 下
setTimeout(() => {
  setCount(1);   // ensureRootIsScheduled → shouldScheduleSync = false
                 // → scheduleCallback → 宏任务延迟调度
  setFlag(true); // callbackNode 已存在 → 不重新调度
}, 0);
// ↑ 一次渲染,自动批量

原因是 LegacyRoot 强制 scheduleSyncCallback(微任务),而微任务在当前同步代码执行完后立刻执行 ------setCount(1) 入队微任务,然后同步代码继续执行 setFlag(true),但第一个微任务已经在队列里等着了,执行 setFlag 之前第一个微任务就触发了渲染。

ConcurrentRoot 走 scheduleCallback(宏任务),宏任务在下一个事件循环才执行,所以中间的所有 setState 都能累积。


设计动机与权衡

为什么不直接把 LegacyRoot 删掉?

向后兼容。大量现有 React 代码(特别是 Class 组件)依赖 Legacy Mode 的行为:

  • this.state 在 setState 后同步可读
  • componentDidMount / componentDidUpdate 中 DOM 一定已更新
  • 事件处理器外的 setState 会立即触发渲染

这些行为在 ConcurrentRoot 下不能保证。React 团队选择用新入口 createRoot 而非修改 ReactDOM.render,让迁移成为显式选择

牺牲了什么?

  • 两套行为:同一个 React 版本有两种模式,开发者需要理解差异
  • 迁移成本 :从 ReactDOM.render 迁移到 createRoot 可能暴露之前被同步行为掩盖的 bug
  • 库兼容性:第三方 React 库如果依赖同步渲染行为,在 ConcurrentRoot 下可能出问题

次级误解和边界

误解 1:"Concurrent Mode 意味着多线程渲染"

不是。React 的渲染始终在主线程(单线程)。"并发"指的是单个更新的渲染过程可以被中断,让其他更新先执行,类似于操作系统的单核 CPU 通过时间片实现"并发"。没有 Web Worker,没有多线程。

误解 2:"ConcurrentRoot 下所有更新都是可中断的"

不是。即使在 ConcurrentRoot 下,SyncLane 优先级的更新(如用户点击事件触发的 setState)仍然走同步路径,不可中断。可中断的只是 DefaultLane、TransitionLane 等较低优先级的更新。

误解 3:"ReactDOM.render 在 React 18 中被删除了"

没有删除,只是标记为 @deprecated(控制台会有警告)。它仍然能用,但强烈建议迁移到 createRoot。React 团队在 React 19 中才考虑完全移除。


现在我们知道了 ConcurrentRoot 和 LegacyRoot 的本质区别就是 root.tag 决定了走同步还是并发的调度路径。但这里有一个问题:如果开发者确实需要"立刻同步渲染,不要任何延迟",有没有显式的 API?

这就是 5.6 flushSync 的作用 要回答的事情。

5.6 flushSync 的作用

直觉锚定

之前的并发厨房有了叫号系统,可以暂停普通订单去做 VIP 的。但有一种特殊情况:食品安全检查员来了。他要求"我现在就要看到这道菜从生到熟的全过程,不能停,不能做别的事"。

flushSync 就是这个检查员------强制同步完成,不允许中断,不允许批量,不允许延迟。 检查员走了之后,厨房恢复正常的叫号系统。


问题背景

ConcurrentRoot 下,setState 是"异步"的------调用后不会立即渲染。但有些场景必须在 setState 后立刻读到最新的 DOM:

scss 复制代码
// 场景:根据新内容的高度决定滚动位置
function handleExpand() {
  setIsExpanded(true);
  // Concurrent Mode 下,这里 DOM 还没更新
  // 读到的是旧高度!
  const height = ref.current.scrollHeight;
  window.scrollTo(0, height); // 用了错误的值
}

这类"写 state → 读 DOM"的模式在以下场景很常见:

  • 测量元素尺寸后做布局计算
  • 与非 React 库(如图表库、动画库)集成,需要 DOM 已更新
  • 在同步代码流中需要确定性顺序

核心数据结构

flushSync 的实现非常轻量,核心就是一个标志位:

typescript 复制代码
// packages/react-reconciler/src/ReactFiberWorkLoop.js

let isFlushingSync: boolean = false;
// 当为 true 时,所有更新强制走同步路径

没有新的数据结构------flushSync 复用了现有的同步渲染管线。


运行流程

flushSync 的执行过程

scss 复制代码
flushSync(() => {
  setCount(1);
  setFlag(true);
})
│
▼
① isFlushingSync = true
│
▼
② 执行 callback
  ├── setCount(1)
  │   └── dispatchSetState → scheduleUpdateOnFiber
  │       └── update 入队,标记 lanes
  │
  └── setFlag(true)
      └── dispatchSetState → scheduleUpdateOnFiber
          └── update 入队,标记 lanes
│
▼
③ callback 执行完毕,进入 flushSync 的核心逻辑
│
▼
④ flushSyncWork()
  └── 遍历所有 root,对有 pendingLanes 的 root 执行:
      └── renderRootSync(root, renderLanes)
          └── workLoopSync()        ← 同步工作循环,不 yield
              ├── 处理所有 Fiber 节点
              └── 直到 workInProgress === null
      └── commitRoot(root)          ← 同步 commit
          ├── mutation 阶段 → DOM 更新
          ├── layout 阶段 → useLayoutEffect 执行
          └── passive 阶段调度(这个还是异步的)
│
▼
⑤ isFlushingSync = false
│
▼
⑥ flushSync 返回
   此时 DOM 已确定更新完成

注意第 ④ 步:用的是 renderRootSync(不带 Concurrent),内部是 workLoopSync(不检查 shouldYield)。 这和 LegacyRoot 的渲染路径一样------一口气跑完。

flushSync 打破了批量更新

scss 复制代码
// ConcurrentRoot 下正常行为:一次渲染
setCount(1);
setFlag(true);
// ↑ 批量,一次渲染

// flushSync 下:每次调用都是独立渲染
flushSync(() => {
  setCount(1);   // ← flushSync 内批量
  setFlag(true); // ← 同一个 callback 内还是批量的
});
// 一次渲染,DOM 更新

flushSync(() => {
  setName('React'); // 另一次渲染
});
// 又一次渲染

实际上同一个 flushSync callback 内的 setState 仍然是批量的 ,因为它们在同一个同步执行上下文中入队,flushSyncWork 最后一次性消费。但不同的 flushSync 调用之间不会批量

源码定位

php 复制代码
// react@18.3.1 · packages/react-dom/src/client/ReactDOM.js
function flushSync(fn) {
  // 开发环境警告:不要在 flushSync 内嵌套 flushSync
  return flushSyncFromReconciler(fn);
}

// react@18.3.1 · packages/react-reconciler/src/ReactFiberWorkLoop.js
function flushSyncFromReconciler(fn) {
  const previousIsFlushingSync = isFlushingSync;
  isFlushingSync = true;        // ← 设置标志
  try {
    return fn();                 // ← 执行用户 callback
  } finally {
    isFlushingSync = previousIsFlushingSync;
    flushSyncWork();            // ← 强制同步渲染所有 pending work
  }
}

isFlushingSync 影响的路径:

scss 复制代码
// scheduleUpdateOnFiber 内部
if (isFlushingSync) {
  // 不走 Scheduler 调度
  // 直接标记 root 需要处理
  // 等 flushSyncWork 统一处理
}

设计动机与权衡

为什么不直接把所有更新都做成可中断的?

因为有些场景必须保证同步语义

  1. DOM 测量 :读 getBoundingClientRect()scrollHeight 等需要 DOM 和 state 一致
  2. 第三方库集成:非 React 库可能在 React 更新后立刻操作 DOM
  3. 测试工具act() 内部用 flushSync 确保断言时状态已更新

flushSync 是逃生舱,不是日常工具。 React 团队的建议是:能用 Concurrent 特性就别用 flushSync。

牺牲了什么?

  • 性能:flushSync 跳过所有优化(批量、时间切片、可中断),大量 setState 时可能造成明显卡顿
  • 可组合性差:如果组件 A 的 flushSync 触发了组件 B 的重新渲染,组件 B 也是同步渲染的------flushSync 不区分组件边界
  • 可能触发意外的级联更新:layoutEffect 中的 setState 会同步执行,如果和 flushSync 叠加,可能造成多次同步渲染

次级误解和边界

误解 1:"flushSync 会让整个应用变成 Legacy Mode"

不会。flushSync 只影响它包裹的那一次渲染。执行完之后,后续的 setState 恢复正常的 Concurrent 行为。它是临时的同步模式切换,不是全局模式变更。

误解 2:"flushSync 内的 setState 和 Legacy Mode 的 setState 一样"

大体相同,但不完全一样。差异在于:

  • Legacy Mode 的同步是永久的------所有更新都同步
  • flushSync 的同步是一次性的------只对 flushSync 期间累积的更新同步
  • flushSync 后如果有被动效果(useEffect)触发的更新,仍然走 Concurrent 路径

误解 3:"flushSync 能优化性能"

恰恰相反。flushSync 降低 性能------它绕过了 React 的所有并发优化。唯一的用途是保证同步语义,不是为了快。如果有人告诉你"用 flushSync 让渲染更快",那是在误导。


题目考核

题 1

当用户在输入框中连续输入,搜索结果用 startTransition 包裹更新时,追踪完整的优先级流转路径:

handleChangesetSearchQuery("Re") 开始(在 startTransition 内部),到这个 update 最终被 processUpdateQueue 消费为止。请说出每一步的关键函数和 lane 值的变化。

题 2

假设 React 去掉了 ensureRootIsScheduled 中对 root.callbackNode 的去重判断(即每次 scheduleUpdateOnFiber 都无条件调用 scheduleCallback),会造成什么问题?在哪个具体环节出错?

给你一个具体的场景来推理:

scss 复制代码
function handleClick() {
  setCount(1);   // setState 1
  setFlag(true); // setState 2
}

如果去掉去重,每次 scheduleUpdateOnFiber 都调用 scheduleCallback。请说出:

  1. Scheduler 队列里会有几个回调?
  2. 第一个回调执行时会发生什么?
  3. 第二个回调执行时又会怎样?

题 3

时间切片的 yieldInterval 默认是 5ms。如果把它改成 50ms,用户体验会变成什么样?如果改成 0.1ms,又会怎样?分别说明原因。

相关推荐
YHHLAI1 小时前
告别传统开发!Bun + TS 解锁前端新体验
前端
秋天的一阵风1 小时前
AGENTS.md:你的AI代码助手,需要一份"项目说明书"
前端·后端·ai编程
rising start1 小时前
七、Vue Router
前端·vue.js·router
羊羊小栈1 小时前
停车场管理系统(基于前后端Web开发)
前端·人工智能·毕业设计·大作业
用户938515635071 小时前
从JS的“坑”到TS的“墙”,再到Bun与AI:打造健壮的全栈应用
前端·javascript
jserTang1 小时前
手撕 Claude Code-7:自动压缩与记忆恢复
前端·后端
橘子星2 小时前
浅谈 TypeScript 与 Bun:现代 JavaScript 开发的利器
前端·javascript
铁皮饭盒2 小时前
Bun 的三种并发"暗器":reusePort、Worker、spawn,能硬刚 Java 吗?
前端·javascript·后端
CodeSheep2 小时前
宇树科技,即将上市!
前端·后端·程序员