5.1 Lanes 优先级模型
直觉锚定
想象一个医院急诊室。病人不是按到达顺序看的,而是按病情紧急程度分诊:心脏骤停立刻处理,骨折可以等一等,普通感冒排最后。但还有一个关键问题------一个医生同一时间只能看一个病人 ,所以"谁先看"不是一张静态列表,而是一套动态优先级计算系统:新来了一个重症病人,可以打断正在处理的轻症,处理完重症再回来接着轻症。
React 的 Lanes 模型就是这个急诊分诊系统:
- 病人 = 各个 setState 触发的更新
- 病情等级 = lane(优先级)
- 医生 = React 的工作循环(performUnitOfWork)
- 打断轻症去处理重症 = 高优先级更新抢占低优先级
- 回来接着处理 = 被打断的低优先级恢复执行
⚠️ 常见先入为主的误解: 很多人以为 React 18 的优先级就是"高/中/低"三档数字。实际上 Lanes 是位掩码(bitmask) ,一个 32 位整数里的每一位代表一个独立的"通道",React 通过位运算同时管理多个更新的优先级。这不是"一个数字比大小",而是"一组开关的集合"。
问题背景
React 15 的 Stack Reconciler 没有优先级概念------一旦开始渲染就必须一口气完成,无法中断。这导致两个致命问题:
- 用户输入卡顿:一个大的列表渲染正在进行时,用户打字/点击得不到即时响应
- 无法区分更新紧急程度:搜索框输入(需要立刻反馈)和数据加载(可以等一等)被同等对待
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.js 的 getNextLanes):
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 节点 = 一个工作单元),理论上可以随时暂停。但光"可以暂停"不够,还需要回答两个问题:
- 什么时候暂停? 不能每处理一个 Fiber 就问一次浏览器,那样调度开销太大;也不能一直不暂停,那样又回到 Stack Reconciler 的老路
- 暂停后怎么恢复? 中断时的工作进度怎么保存,下次从哪接着来
这两个问题分别由 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)------ 永远不会 yieldflushSync包裹的更新 ------ 强制同步完成- 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 都会:
- 创建 update → 入队 → 调度 → render → commit
- 三次 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 敢这么做,是因为:
- 推荐用函数组件 + hooks,不存在
this.state同步读取的问题 createRoot是全新 API,可以视为 breaking change 的契机- 提供了
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 的问题:
- 延迟是固定的------300ms 在性能好的设备上太慢,在差的设备上又不够
- 不能被抢占------一旦触发就开始渲染,用户连续输入时无法中断上一轮
- 中间状态丢失------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
useTransition 比 startTransition 多一个 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 无法自动判断哪些更新是"可以慢的"。setInputValue 和 setSearchQuery 都是普通的 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
两种模式分叉的核心在 ensureRootIsScheduled(react@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 是什么,永远用
scheduleSyncCallback→workLoopSync(不 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 统一处理
}
设计动机与权衡
为什么不直接把所有更新都做成可中断的?
因为有些场景必须保证同步语义:
- DOM 测量 :读
getBoundingClientRect()、scrollHeight等需要 DOM 和 state 一致 - 第三方库集成:非 React 库可能在 React 更新后立刻操作 DOM
- 测试工具 :
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 包裹更新时,追踪完整的优先级流转路径:
从 handleChange 中 setSearchQuery("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。请说出:
- Scheduler 队列里会有几个回调?
- 第一个回调执行时会发生什么?
- 第二个回调执行时又会怎样?
题 3
时间切片的 yieldInterval 默认是 5ms。如果把它改成 50ms,用户体验会变成什么样?如果改成 0.1ms,又会怎样?分别说明原因。