React 19.3.1 源码深度剖析-第 5 章 Scheduler 调度器与时间切片
源码版本:React 19.3.1(
packages/scheduler/src/forks/Scheduler.js等) 阅读前置:第 3 章《Fiber 架构与双缓冲》(含 Lane 优先级模型)、建议先读第 8 章《并发特性(Concurrent Features)》以便理解“为什么需要可中断渲染”。 本章目标:把 React 并发渲染的“发动机”彻底讲透——调度器如何把纤程(Fiber)工作拆成可中断、可让出、可续体(continuation)的时间切片(time slice),以及它如何用最小堆与MessageChannel宏任务在浏览器主线程上实现协作式多任务。
0. 本章在整套书中的位置
React 19 的“并发”(Concurrent)不是一个特性,而是一整套机制的总和:
- 第 3 章的 Lane 负责“谁更重要、谁先被处理”——这是优先级语义层;
- 第 3 章的 Fiber 负责“工作可以暂停、可恢复、可丢弃”——这是可中断的数据结构层;
- 本章的 Scheduler 负责“在什么时候、让出多久、把时间片切到多细”——这是执行节奏层(引擎);
- 第 8 章的 useTransition / useDeferredValue / Suspense 负责“把可中断能力暴露给开发者”——这是能力暴露层。
没有 Scheduler,Fiber 的“可中断”只是一句空话:Fiber 允许你暂停,但必须有人决定“何时暂停”。这个“决定者”就是 Scheduler。performUnitOfWork 每次循环开头都会问一句 shouldYieldToHost()(其实是经由 reconciler 工作的 workLoop 问),而这个判断背后的时钟、切片长度、让出时机,全部由本章要剖析的 Scheduler 提供。
一句话心智模型:
Scheduler 是一台装在浏览器主线程上的“协作式抢占调度器”:它用两个最小堆管理任务,用
MessageChannel宏任务把工作切成 5ms 一片,在任务过期前让出主线程,把“可中断的 Fiber 渲染”变成现实。
1. 动机与背景:调度器为什么存在
要理解 Scheduler 存在的必要性,必须回到 React 渲染模型的根本矛盾:渲染是 CPU 密集的,而浏览器主线程是稀缺且单一的资源。
1.1 浏览器主线程上的“隐形税”
现代浏览器(Chromium / Gecko / WebKit)把几乎所有与“页面”相关的事情都塞进同一个线程——JavaScript 执行、样式计算(Style)、布局(Layout)、绘制(Paint)、合成(Composite)以及用户输入事件的分发,绝大多数都发生在**主线程(Main Thread)**上。虽然合成与某些绘制可以下放到合成线程(Compositor Thread),但 JS 逻辑、React 的 reconciliation、DOM 变更全部在主线程串行执行。
这意味着一条铁律:
只要 JavaScript 在跑,用户就既点不动按钮、也看不到页面更新。
这就是所谓的“长任务(Long Task)”问题。W3C 的 Long Tasks API 把“执行时间超过 50ms 的任务”定义为长任务;一旦一个 task 超过一个帧预算(60fps 下约为 16.67ms),浏览器就会丢掉这一帧,用户肉眼看到的就是卡顿(jank)、输入延迟、滚动不跟手。Google 的 RAIL 性能模型把响应(Response)目标定在 100ms 内、动画(Animation)定在 16ms 内、空闲(Idle)用于预加载。React 作为一个 UI 库,所有渲染都吃主线程时间,所以它天然是“长任务制造机”。
1.2 React 15 的 Stack Reconciler:不可中断的噩梦
在 React 15 及更早版本里,reconciler 是“栈式”的:一个 setState 触发一次完整递归,从根组件一路递归遍历整棵虚拟 DOM 树,边遍历边生成真实 DOM 变更,整个过程是一个同步的、不可打断的函数调用栈。你可以把它想象成一列没有中途站点的火车:一旦发车,必须到终点才能停。
这带来两个致命问题:
- 渲染与交互互斥:如果组件树很大(比如渲染 10000 行表格),这一次递归可能耗去几百毫秒。在这几百毫秒里,浏览器无法处理任何用户输入、无法绘制、无法响应滚动。用户体验是“点了没反应,页面冻住”。
- 优先级无从谈起:栈式递归里“先算哪棵子树”完全由组件树顺序决定,你无法说“这个输入框的更新比那个列表的更新更紧急,先把它算完”。所有更新一视同仁地同步执行完。
React 团队在 2016–2017 年的多次演讲(比如 Lin Clark 的 “React Fiber” 演讲)里明确点出:问题不是“渲染慢”,而是“渲染不可中断”。如果我们能让渲染“暂停,去处理一下用户输入,再回来接着渲染”,卡顿问题就迎刃而解。
1.3 Fiber:把工作切成可以暂停的单元
第 3 章已经讲透,React 16 引入的 Fiber 架构把“渲染一个组件树”这个大任务,拆成了一个个可独立执行、可暂停、可恢复的工作单元(unit of work)。performUnitOfWork 每处理一个 Fiber 节点就检查一次“是否该让出了”。Fiber 提供了“能暂停”的能力,但它自己并不知道“何时暂停”。
这就是关键的空缺:Fiber 是“可中断的数据结构”,但还缺一个“决定何时中断的时钟与策略”。这个空缺由 Scheduler 填补。一句话:
Fiber 说“我可以停”,Scheduler 说“现在就停”。
1.4 为什么不直接用浏览器原生的 requestIdleCallback?
一个自然的想法是:浏览器既然提供了 requestIdleCallback(RIC),何不把渲染切片塞进“空闲时段”?React 早期(v16 实验期)确实尝试过 RIC,但最终放弃了,原因有以下几点,这些原因直接塑造了自研 Scheduler 的设计:
- 兼容性差:
requestIdleCallback长期只有 Chrome 实现,Safari / iOS WebView 始终不支持。一个要服务全体用户的 UI 库不能依赖一个浏览器独有的 API。 - 触发时机太保守:RIC 只在“浏览器确信接下来一段时间空闲”时才回调,而 React 的渲染往往是“用户刚交互完、正需要尽快把结果画出来”的场景——此时浏览器并不空闲,RIC 会迟迟不触发,导致渲染被无限推迟(latency 暴涨)。
- 50ms 钳制(clamp):为了不饿死其他任务,RIC 的 deadline 被钳制在 50ms 量级,而且它不与渲染(帧)对齐。一个 50ms 的回调足以让三帧掉光,和“时间切片”的初衷(每片 < 16ms)相悖。
- 不表达优先级:RIC 只允许你设置
timeout,没有多优先级概念,无法区分“用户输入”和“后台预取”的紧急程度。 - 无法主动让出以绘制:React 需要在“主线程被 JS 占满、但浏览器其实想绘制了”的时候主动让出,RIC 不提供这个信号。
因此 React 团队从 16.5/16.6 起把调度逻辑从 react-reconciler 抽出,独立成 @react-three 之外的一个包——scheduler,并自研了一套基于 MessageChannel 宏任务的调度机制。这就是本章要剖析的对象。值得强调的是:本版本(19.3.1)的 DOM 主分支 Scheduler.js 已经把当年独立的 SchedulerDOM.js 合并进来,所以“选择 MessageChannel 还是 setTimeout”的逻辑直接在本文件里。
1.5 “协作式抢占”:在单线程里假装多任务
操作系统有“抢占式调度”:内核可以在任意时刻强制把 CPU 从一个进程抢走给另一个。但 JavaScript 是单线程,没有真正的抢占——除非当前函数主动 return 或 await,否则没人能打断它。
React 的解法是协作式(cooperative)调度:每个工作单元做完后主动问一圈“我是不是该让出了?”,如果答案是 yes,就主动 return,把控制权交还给浏览器(即结束当前宏任务),等下一个宏任务再继续。Scheduler 的 workLoop 就是“主动问、主动 return”的执行者。这套机制在单线程里模拟出了一个抢占式调度器的效果:低优任务即使没跑完,也会被高优任务“挤掉”,因为高优任务总是排在最小堆前面、更早被取出来执行。
理解这一点至关重要:Scheduler 不是真的在“抢占”你的函数,而是你的函数(Fiber work)在“每做一个小步骤后,礼貌地让出”。 所以“可中断渲染”的另一个名字是“协作式可中断渲染”。
1.6 时间切片(Time Slicing)到底是什么
“时间切片”是 Scheduler 对外最直观的能力:把一整块大渲染工作,切成若干小块(默认每块约 5ms),块与块之间把主线程让出来给浏览器处理输入和绘制。注意这里有两个数字容易混淆:
- 帧预算 16.67ms:一帧的时间(60fps)。这是“屏幕不卡”的硬上限。
- 时间切片 5ms(
frameYieldMs):Scheduler 默认的“每片工作量上限”,刻意设在帧预算的约 1/3 处。
Scheduler 默认不在 5ms 就让出一次(否则一帧里要反复进出宏任务,开销太大),而是跨越多帧累积,每片最多吃 5ms——也就是说它“每 5ms 的主线程占用后让出一次”,但让出后下一个宏任务可能要等下一帧才被调度。这一点在 §9 讲 shouldYieldToHost 与 frameInterval 时会有精确到行的论证。
1.7 小结:调度器解决的三件事
把本节压缩成一句话清单,Scheduler 解决的是:
- 节奏:一次最多在主线程上跑多久(5ms 切片)才让出。
- 优先级:哪块工作先跑( Immediate > UserBlocking > Normal > Low > Idle,由最小堆排序保证)。
- 可恢复:跑不完就存下进度,下一个宏任务接着跑(续体机制 + 堆里留存未完成任务)。
下节我们用一张全景图把这些关系钉死。
2. 整体心智模型:一张图看懂调度器
Scheduler 对外只有几个极简的 API:scheduleCallback(投递任务)、cancelCallback(取消)、shouldYield(查询是否该让出)、getCurrentPriorityLevel / runWithPriority(优先级上下文)、requestPaint(请求优先绘制)、forceFrameRate(调切片长度)。它的内部却由三层协作完成工作:
flowchart TB
subgraph 外部["调用方(react-reconciler / 应用代码)"]
A["unstable_scheduleCallback(priority, callback, options)"]
end
subgraph 内核["Scheduler 内核(forks/Scheduler.js)"]
B["计算 startTime / timeout / expirationTime"]
C{"startTime > now ?"}
D["push 到 timerQueue<br/>(按 startTime 排序的堆)"]
E["push 到 taskQueue<br/>(按 expirationTime 排序的堆)"]
F["requestHostCallback()"]
G["MessageChannel 宏任务<br/>performWorkUntilDeadline"]
H["flushWork → workLoop"]
I{"expirationTime > now<br/>且 shouldYieldToHost()?"}
J["执行 callback<br/>(Fiber work)"]
K{"callback 返回函数?"}
L["设为续体 currentTask.callback<br/>立即 return true(让出)"]
M["pop 出队,继续下一个"]
N["让出主线程,结束宏任务"]
end
subgraph 宿主["浏览器主线程"]
O["处理用户输入 / 绘制 / 布局"]
end
A --> B --> C
C -- 是(延时任务)--> D
C -- 否(立即任务)--> E --> F
D -. 到点后 advanceTimers 转移 .-> E
F --> G --> H --> I
I -- 是,让出 --> N --> O
I -- 否,继续 --> J --> K
K -- 是 --> L --> N
K -- 否 --> M --> I
O -. 下一帧再 postMessage .-> G
这张图把本章要讲的几乎所有关键角色都标了出来。请记住三个箭头方向:投递(schedule)→ 排队(heap)→ 执行(workLoop)→ 让出(yield)→ 再投递,这就是 Scheduler 的主循环。
下面把“一次完整链路”用时序图再表达一遍,这是本章必须画透的全链路(scheduleCallback → 消息循环 → workLoop → yield):
sequenceDiagram
participant App as 调用方/事件
participant Sch as Scheduler
participant Heap as taskQueue/timerQueue
participant MC as MessageChannel
participant BT as 浏览器主线程
App->>Sch: scheduleCallback(Normal, work)
Sch->>Sch: 算 startTime/timeout/expirationTime
Sch->>Heap: push(taskQueue) 按 expirationTime
Sch->>MC: requestHostCallback → port.postMessage
MC->>BT: 作为宏任务入队(绕开 4ms 钳制)
BT-->>Sch: performWorkUntilDeadline 被调用
Sch->>Sch: flushWork → workLoop 取堆顶
loop 每个 task / 每个时间片
Sch->>Sch: currentTime = now()
alt expirationTime > now 且 shouldYield()
Sch-->>MC: return true(让出)
MC->>BT: 触发下一宏任务(下一帧)
else 任务未过期
Sch->>Sch: 执行 callback(Fiber work)
alt 返回函数(续体)
Sch-->>MC: currentTask.callback=续体, return true
else 完成
Sch->>Heap: pop(taskQueue)
end
end
end
Sch-->>BT: 无更多任务 → isMessageLoopRunning=false
读完这张图,你基本就能用一句话向别人讲清楚 Scheduler 了。接下来我们钻进它的数据结构。
3. 核心数据结构:Task、两个最小堆、全局状态
Scheduler 的“状态”极其集中:全部任务就放在两个数组(被当作最小堆使用)里,再加上十来个模块级变量。理解了这些,整个文件的运行逻辑就通透了。本节把每个字段、每个变量的来历、作用、赋值时机、关联对象讲清楚。
3.1 Task 类型:一个被调度的工作单元
这段来自 packages/scheduler/src/forks/Scheduler.js:49-57,是本章所有讨论的核心对象:
// packages/scheduler/src/forks/Scheduler.js:49-57
export opaque type Task = {
id: number,
callback: Callback | null,
priorityLevel: PriorityLevel,
startTime: number,
expirationTime: number,
sortIndex: number,
isQueued?: boolean,
};
注意它写的是 export opaque type,也就是不透明类型(opaque type):模块外只能拿到 Task 这个类型名,不能直接构造或读取它的字段(Flow 的封装能力)。这保证了任务对象只能由 Scheduler 自己创建,调用方只能拿着返回的引用去 cancelCallback。
逐字段拆解:
| 字段 | 类型 | 含义 | 何时赋值 | 与谁关联 |
|---|---|---|---|---|
id | number | 全局自增的任务序号,用于稳定排序的同值决胜(tie-breaker) | unstable_scheduleCallback 里 taskIdCounter++ | SchedulerMinHeap.compare 用它做二级排序键 |
callback | Callback | null | 真正要执行的工作。可能是一个函数,也可能是 null(表示已取消)。函数若返回另一个函数,则那个返回值是“续体” | 投递时给定;执行前被置 null;续体返回时重新赋回 | Fiber 的 performWorkOnRootViaSchedulerTask |
priorityLevel | PriorityLevel | 任务的优先级(0~5,0 表示无) | 投递时由调用方指定 | 决定 timeout,间接决定 expirationTime |
startTime | number | 任务最早可执行的时刻(延迟任务用) | delay>0 时为 now+delay,否则为 now | timerQueue 的排序键 |
expirationTime | number | 任务过期的时刻;过期后不再让出 | startTime + timeout | taskQueue 的排序键;workLoop 用它判断“是否必须做完” |
sortIndex | number | 当前在堆中的排序键:在 taskQueue 里等于 expirationTime,在 timerQueue 里等于 startTime | 入队时赋值;从 timerQueue 转移时改写为 expirationTime | SchedulerMinHeap.compare 的一级排序键 |
isQueued | boolean? | 仅 profiling 构建下使用,标记任务是否已入队(用于 cancel 时打点) | enableProfiling 时赋值 | SchedulerProfiling |
有几个极易踩坑的设计点,先在此点破,后面会有源码印证:
callback为null是“已取消”的标记,而不是“已执行完”。已执行完的任务是通过“从堆里 pop 掉”来移除的,其callback在执行前就被置null了,但节点仍留在堆顶直到被 pop。所以“callback===null”同时可能表示两种状态,需要结合上下文区分(§10 会专门讲这个坑)。sortIndex不是固定字段:一个任务在timerQueue里时sortIndex = startTime,被advanceTimers转移到taskQueue时会被改写成expirationTime(见 §8advanceTimers)。这就是为什么堆里的排序键是“动态的”。- 续体(continuation)的载体就是
callback字段本身:任务执行后如果返回函数,Scheduler 把这个返回值写回currentTask.callback,然后让出;下一轮workLoop取出该任务时,callback已经是续体函数,于是直接执行续体。这是一个非常巧妙的“原地复用”,避免了额外数据结构。
3.2 两个最小堆:taskQueue 与 timerQueue
这段来自 packages/scheduler/src/forks/Scheduler.js:78-80:
// packages/scheduler/src/forks/Scheduler.js:78-80
// Tasks are stored on a min heap
var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];
两个数组,都被 SchedulerMinHeap 当作二叉最小堆使用。二者职责严格区分:
taskQueue(任务队列):存放已经到期、可以立即执行的任务。排序键是expirationTime(过期时间越早越靠前)。timerQueue(定时器队列):存放还没到startTime的延时任务。排序键是startTime(开始时间越早越靠前)。
为什么一定要用两个堆、而不是一个?因为延时任务的“什么时候该被拿出来”和“拿出来后谁先跑”是两件事:
- 在延时阶段,我们关心“哪个延时任务最早到点”——所以用
startTime排序。 - 一旦到点转入可执行队列,我们关心“哪个任务最紧急(最该优先、最该先过期)”——所以用
expirationTime排序。
如果把所有任务放一个堆,无法同时按两个维度排序。advanceTimers 负责在“到点”时把一个任务从 timerQueue 迁移到 taskQueue,并改写 sortIndex(§8 详述)。
关键约束(全章最重要的一个不变式):因为这两个数组被当作数组实现的二叉堆,而二叉堆只能高效删除堆顶(peek/pop),不能 O(1) 删除任意中间节点。所以:
取消一个任务时,Scheduler 不能把它从堆里“抠掉”,只能把它的
callback置为null,等轮到它(成为堆顶被 pop)时再丢弃。详见unstable_cancelCallback(§11.1)。
这个约束直接决定了“取消任务不是即时生效”,也决定了 workLoop 里到处要检查 callback === null。
3.3 模块级全局状态变量
Scheduler 不用类、不用实例对象,全部状态都是模块级 var/局部变量。下面把 forks/Scheduler.js 里所有全局状态一次性列清:
| 变量 | 行号 | 作用 |
|---|---|---|
getCurrentTime | 59 | 取当前时间的函数,优先用 performance.now(),否则退化成 Date.now() 差值 |
maxSigned31BitInt | 76 | 1073741823(= 2^30 - 1),31 位有符号整数上限,Idle 任务的 expirationTime 用它,代表“永不过期” |
taskIdCounter | 83 | 任务 id 自增计数器,保证插入顺序稳定 |
currentTask | 85 | 当前正在执行的 Task,供 workLoop 与 profiling 使用 |
currentPriorityLevel | 86 | 当前优先级上下文,由 runWithPriority/next/wrapCallback 维护,决定新投递任务的默认优先级 |
isPerformingWork | 89 | 是否正在执行工作循环(防重入标志) |
isHostCallbackScheduled | 91 | 是否已经向宿主(浏览器)投递了一个任务回调(防止重复 requestHostCallback) |
isHostTimeoutScheduled | 92 | 是否已经用 setTimeout 注册了“延时任务到点”的定时器 |
needsPaint | 94 | 是否有 pending 的绘制请求(由 requestPaint 置位),shouldYieldToHost 会因此立即让出 |
localSetTimeout/localClearTimeout/localSetImmediate | 97-101 | 捕获原生 API 的本地引用,防止被 polyfill 改写 |
isMessageLoopRunning | 449 | 消息循环是否正在跑(防止重复启动 MessageChannel 宏任务) |
taskTimeoutID | 450 | requestHostTimeout 返回的定时器句柄 |
frameInterval | 456 | 当前时间切片长度,默认 frameYieldMs(5),forceFrameRate 可改 |
startTime | 457 | 当前这批工作的起始时间戳,shouldYieldToHost 用它算“主线程已被占用多久” |
这些变量看似零散,其实可以分三类理解:
- 队列相关:
taskQueue、timerQueue、taskIdCounter。 - 执行上下文相关:
currentTask、currentPriorityLevel、isPerformingWork、isHostCallbackScheduled、isHostTimeoutScheduled、isMessageLoopRunning、needsPaint。 - 时钟相关:
getCurrentTime、startTime、frameInterval、taskTimeoutID。
记住 currentPriorityLevel 是个上下文变量:调用方用 runWithPriority 临时切换它,Scheduler 内部在 workLoop 执行 callback 前会把 currentPriorityLevel 设为 currentTask.priorityLevel(见 §7 workLoop 逐行),这样“嵌套在 React 工作里的回调”能继承正确的优先级。
下一节我们把 SchedulerMinHeap 这个“堆引擎”彻底讲透,因为 taskQueue/timerQueue 的所有排序语义都依赖它。
4. SchedulerMinHeap:两个队列的排序引擎
taskQueue 和 timerQueue 都是数组,但它们被当作**二叉最小堆(binary min-heap)**使用,所有“取最小、插入、删除堆顶”的算法都在 packages/scheduler/src/SchedulerMinHeap.js 里。理解这个文件,才能理解“为什么最高优先级/最早过期的任务总在堆顶”。
4.1 堆的基本约定
// packages/scheduler/src/SchedulerMinHeap.js:10-15
type Heap<T: Node> = Array<T>;
type Node = {
id: number,
sortIndex: number,
...
};
注释里的 ... 表示 Node 是结构化的(“至少有 id 和 sortIndex 两个字段”),实际存放的是 Task 对象。Heap 就是普通数组。
二叉堆用数组的“隐式父/子索引”表示一棵完全二叉树:
- 节点
i的父节点在(i - 1) >>> 1(>>>是无符号右移,等价于Math.floor((i-1)/2),但对负数安全)。 - 节点
i的左孩子在(i + 1) * 2 - 1,右孩子在(i + 1) * 2。 peek(看堆顶)永远是heap[0],即排序键最小者。
为什么用堆而不是有序数组?因为 Scheduler 的核心操作是“频繁取最小 + 频繁插入”,堆的插入/取最小都是 O(log n),而有序数组的插入是 O(n)。React 应用里任务量大、调度频繁,O(log n) 至关重要。
4.2 比较器 compare:先 sortIndex,后 id
// packages/scheduler/src/SchedulerMinHeap.js:91-95
function compare(a: Node, b: Node) {
// Compare sort index first, then task id.
const diff = a.sortIndex - b.sortIndex;
return diff !== 0 ? diff : a.id - b.id;
}
这是整个堆的“排序灵魂”。它定义了两个任务谁更小(更靠前):
- 一级键
sortIndex:在taskQueue中是expirationTime,在timerQueue中是startTime。 - 二级键
id:当sortIndex相等时,用id(即taskIdCounter的自增序号)决胜,保证先入队的先执行,即 FIFO 稳定排序。
这点极其关键:id 是防止“同优先级任务饿死/乱序”的兜底。如果没有 id 决胜,两个 sortIndex 相同的任务在堆调整时的相对顺序是不确定的(取决于堆的结构),可能出现“后来者反而先跑”的诡异现象。id 把“插入顺序”变成确定的决胜键,使调度变成稳定的。
compare 返回负数表示 a < b(a 更靠前)。注意它只用 >/< 比较,绝不直接用 === 判等来定序,且对 NaN 不友好——但 sortIndex 在 Scheduler 里永远是合理数字(expirationTime/startTime),所以无需担心。
4.3 push:插入并上浮(siftUp)
// packages/scheduler/src/SchedulerMinHeap.js:17-21
export function push<T: Node>(heap: Heap<T>, node: T): void {
const index = heap.length;
heap.push(node);
siftUp(heap, node, index);
}
push 很简单:先把节点塞到数组末尾(成为完全二叉树的最后一个叶子),再调用 siftUp 把它“上浮”到正确位置。
// packages/scheduler/src/SchedulerMinHeap.js:42-57
function siftUp<T: Node>(heap: Heap<T>, node: T, i: number): void {
let index = i;
while (index > 0) {
const parentIndex = (index - 1) >>> 1;
const parent = heap[parentIndex];
if (compare(parent, node) > 0) {
// The parent is larger. Swap positions.
heap[parentIndex] = node;
heap[index] = parent;
index = parentIndex;
} else {
// The parent is smaller. Exit.
return;
}
}
}
逐行讲解 siftUp:
while (index > 0):只要不是根节点,就继续比较。parentIndex = (index - 1) >>> 1:算出父节点索引。用>>>而非/2,因为位运算更快且对边界安全。if (compare(parent, node) > 0):父节点“大于”(更靠后于)当前节点,说明当前节点应该上移。注意compare(parent, node) > 0等价于parent.sortIndex > node.sortIndex(或sortIndex相同但parent.id > node.id)。- 若需要上移,交换二者位置(
heap[parentIndex] = node; heap[index] = parent;),并把index更新为parentIndex,继续向上。 - 一旦
compare(parent, node) <= 0(父节点更小或相等),说明位置已正确,直接return退出,无需继续。
复杂度:最坏情况从叶子走到根,深度为 log2(n),所以 push 是 O(log n)。
4.4 peek:看堆顶(O(1))
// packages/scheduler/src/SchedulerMinHeap.js:23-25
export function peek<T: Node>(heap: Heap<T>): T | null {
return heap.length === 0 ? null : heap[0];
}
peek 只是返回 heap[0],空堆返回 null。这是 workLoop 取“下一个要执行的任务”的方式,也是 handleTimeout 判断“下一个延时要点到没”的方式。
4.5 pop:取堆顶并下沉(siftDown)
// packages/scheduler/src/SchedulerMinHeap.js:27-40
export function pop<T: Node>(heap: Heap<T>): T | null {
if (heap.length === 0) {
return null;
}
const first = heap[0];
const last = heap.pop();
if (last !== first) {
// $FlowFixMe[incompatible-type]
heap[0] = last;
// $FlowFixMe[incompatible-type]
siftDown(heap, last, 0);
}
return first;
}
pop 的逻辑:
first = heap[0]:记下要返回的原堆顶。last = heap.pop():把最后一个元素从数组末尾弹出(这样数组长度 -1,同时拿到一个“待安置”的节点)。- 如果
last !== first(即堆里不止一个元素),把last放到堆顶heap[0],然后siftDown(heap, last, 0)把它“下沉”到正确位置。 - 返回
first(原堆顶)。
为什么用“最后一个元素顶到堆顶再下沉”而不是直接删堆顶?因为堆顶在数组中间不好删,而数组末尾的删除是 O(1) 的(pop)。这个“尾元素顶上、再下沉”是标准二叉堆删除算法的经典写法,保证删除后仍是合法最小堆。
4.6 siftDown:下沉调整
// packages/scheduler/src/SchedulerMinHeap.js:59-89
function siftDown<T: Node>(heap: Heap<T>, node: T, i: number): void {
let index = i;
const length = heap.length;
const halfLength = length >>> 1;
while (index < halfLength) {
const leftIndex = (index + 1) * 2 - 1;
const left = heap[leftIndex];
const rightIndex = leftIndex + 1;
const right = heap[rightIndex];
// If the left or right node is smaller, swap with the smaller of those.
if (compare(left, node) < 0) {
if (rightIndex < length && compare(right, left) < 0) {
heap[index] = right;
heap[rightIndex] = node;
index = rightIndex;
} else {
heap[index] = left;
heap[leftIndex] = node;
index = leftIndex;
}
} else if (rightIndex < length && compare(right, node) < 0) {
heap[index] = right;
heap[rightIndex] = node;
index = rightIndex;
} else {
// Neither child is smaller. Exit.
return;
}
}
}
逐行讲解 siftDown:
halfLength = length >>> 1:完全二叉树中,有孩子的节点索引一定< length/2(因为最后一个有孩子的节点的左孩子在length-1,其父为(length-2)/2)。循环条件index < halfLength提前终止于叶子节点,避免无意义比较。- 取出左孩子
left、右孩子right的索引与节点。 if (compare(left, node) < 0):左孩子比当前节点小,说明需要下沉。接着比较右孩子:- 若
right也存在且compare(right, left) < 0(右孩子比左孩子还小),则和更小的右孩子交换,下沉到rightIndex。 - 否则和左孩子交换,下沉到
leftIndex。
- 若
else if (rightIndex < length && compare(right, node) < 0):左孩子不小于当前节点,但右孩子更小,则和右孩子交换。else:左右孩子都不比当前节点小,说明已在正确位置,return。
注意这里永远选两个孩子中更小的那个交换,这正是“最小堆”的保证:父节点必须 ≤ 两个孩子。
4.7 一个完整示例:把 5 个任务 push 进 taskQueue
设我们依次 scheduleCallback,得到 5 个任务(用 expirationTime 当 sortIndex,id 自增):
| id | sortIndex(expirationTime) |
|---|---|
| 1 | 100 |
| 2 | 50 |
| 3 | 200 |
| 4 | 50 |
| 5 | 80 |
按顺序 push,每次 push 后 siftUp。最终 taskQueue 数组(堆顶在 0)会是:[id2(50), id4(50), id3(200), id1(100), id5(80)]。堆顶是 id2(expirationTime=50 且 id 更小);id2 与 id4 同 sortIndex,但 id2 先入队,因 compare 的二级键 id 而胜出在前。peek 永远拿到 id2,pop 拿掉 id2 后 siftDown 重新平衡,下一堆顶仍是 id4。这就是“同过期时间按 FIFO”的来源。
4.8 复杂度小结与“为什么不是数组 sort()”
| 操作 | 堆实现 | 若用 array.sort() |
|---|---|---|
| 插入 push | O(log n) | O(n log n) |
| 取最小 peek | O(1) | O(1)(若已排序) |
| 删堆顶 pop | O(log n) | O(n)(要 splice) |
React 的 workLoop 是“取一个、执行、再取一个”的循环,配堆的 O(log n) 是恰到好处的。如果用排序数组,每次插入都要重排,n 一大就会成为渲染瓶颈。这也呼应了第 3 章说的“Fiber 把大任务切碎”——切碎后任务数量变多,对堆的插入/删除频率变高,O(log n) 的优势就更明显。
5. 优先级体系:5 个级别与它们的超时
Scheduler 的优先级由 packages/scheduler/src/SchedulerPriorities.js 定义,而“每个优先级对应多久过期”由 packages/scheduler/src/SchedulerFeatureFlags.js 定义。这两个文件加起来不到 30 行,但每一行都关系到“时间切片何时结束、紧急任务如何被保障”。
5.1 五个优先级常量
// packages/scheduler/src/SchedulerPriorities.js:10-18
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
// TODO: Use symbols?
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
注意:
PriorityLevel是0|1|2|3|4|5的联合类型,数字越小优先级越高(ImmediatePriority=1最高,IdlePriority=5最低,NoPriority=0是“无/默认占位”)。- 注释
// TODO: Use symbols?说明 React 团队知道用裸数字不够安全(容易被误传),但出于性能与 bundle 体积考虑沿用了数字。在 Scheduler 这种被高频调用的内层,数字比较比 Symbol 比较快且无分配。 NoPriority=0不是“最低优先级”,而是“尚未设置”的哨兵。在unstable_scheduleCallback里若传入未知优先级,会被当作NormalPriority处理(结合 §6 的 switch)。
5.2 每个优先级对应的超时(timeout)
“过期时间” = startTime + timeout。timeout 由优先级决定,定义在 SchedulerFeatureFlags.js:
// packages/scheduler/src/SchedulerFeatureFlags.js:10-18
export const enableProfiling = false;
export const frameYieldMs = 5;
export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;
export const enableRequestPaint = true;
export const enableAlwaysYieldScheduler = __EXPERIMENTAL__;
注意 SchedulerFeatureFlags.js 里没有 immediatePriorityTimeout 和 idlePriorityTimeout 的常量——它们写在 unstable_scheduleCallback 的 switch 里直接硬编码(-1 和 maxSigned31BitInt)。我们把全部五档整理成表:
| 优先级 | 数字 | timeout | expirationTime 含义 | 典型来源(React 侧) |
|---|---|---|---|---|
| ImmediatePriority | 1 | -1 | startTime - 1,即“立即过期” | 旧版同步渲染(现多改用 microtask,见 §11) |
| UserBlockingPriority | 2 | userBlockingPriorityTimeout = 250 | 250ms 后过期 | 离散/连续用户输入(点击、输入、拖拽) |
| NormalPriority | 3 | normalPriorityTimeout = 5000 | 5000ms(5s)后过期 | 普通 setState、网络返回后的更新 |
| LowPriority | 4 | lowPriorityTimeout = 10000 | 10000ms(10s)后过期 | 后台分析、非紧急日志 |
| IdlePriority | 5 | maxSigned31BitInt = 1073741823 | 约 12.4 天后过期(≈“永不过期”) | requestIdleCallback 风格的后台任务、offscreen |
把 timeout 翻译成“调度语义”:
- Immediate(-1):
expirationTime = startTime - 1 < startTime ≤ now,所以任何时刻都已过期。workLoop看到expirationTime <= currentTime就会“不让出、必须做完”——这正是“同步、不可中断”的语义(§7 会证明)。 - UserBlocking(250ms):用户正在交互,你希望在 250ms 内给出反馈,否则用户会感觉卡。所以给它一个较短的过期窗口,过期后强制让出也来不及了——于是它会被优先做完。
- Normal(5000ms):普通更新,5 秒内做完都算“及时”,所以有充足的时间切片空间。
- Low(10000ms):更不紧急,可以一直被更急的任务挤到后面。
- Idle(maxSigned31BitInt):永不强制过期,只在主线程真的空闲时才做。注意它不是真的 12.4 天,而是“大到几乎不会触发过期判断”,等价于“只在其它事都没得做时才执行”。
5.3 为什么用“过期时间”而非“绝对优先级数字”来排序?
这里有个反直觉但极其重要的设计:taskQueue 不是按 priorityLevel(1~5)排序的,而是按 expirationTime 排序。那“优先级”体现在哪?
答案是:优先级 → timeout → expirationTime,而 expirationTime 越早(越小)越靠前。高优任务 timeout 小,所以很快过期、很快被排到堆顶;低优任务 timeout 大,过期得晚,自然排在后面。换句话说:
“优先级”被编码进了“过期时刻的早晚”里。队列按“谁先过期”排序,等价于按“谁最该先被服务”排序。
这个编码的妙处在于:它让“优先级”和“时间压力”统一成一个可比较的数字。一个 UserBlocking 任务如果已经等了 240ms(快过期了),它就会排到一个刚投递的 Normal 任务前面——因为它更接近过期。这正是“饥饿保护(starvation avoidance)”的一种自然体现:等得越久越紧急。React reconciler 侧还有 markStarvedLanesAsExpired 显式把等太久的 Lane 标记为过期(§11.2),与这里的设计哲学一致。
5.4 maxSigned31BitInt 的来历
// packages/scheduler/src/forks/Scheduler.js:73-76
// Max 31 bit integer. The max integer size in V8 for 32-bit systems.
// Math.pow(2, 30) - 1
// 0b111111111111111111111111111111
var maxSigned31BitInt = 1073741823;
注释解释了:1073741823 = 2^30 - 1,是 V8 在 32 位系统上对“31 位有符号整数”能安全表示的最大值(JavaScript 的位运算基于 32 位有符号整数,最高位是符号位,所以安全整数运算范围是 31 位)。用这个值当 Idle 的 expirationTime,保证它永远大于任何真实时间戳(当前时间 performance.now() 要涨到 12.4 天才会接近它),从而使得 Idle 任务在 workLoop 的“是否过期”判断里永不为真——除非主线程真的空闲到只剩 Idle 任务。这是一个“足够大但又不至于溢出 32 位运算”的巧妙取值。
5.5 实验开关 enableAlwaysYieldScheduler
// packages/scheduler/src/SchedulerFeatureFlags.js:18
export const enableAlwaysYieldScheduler = __EXPERIMENTAL__;
这个开关在 19.3.1 里是 __EXPERIMENTAL__(即构建期常量,默认关闭)。它改变 workLoop 的让出策略:默认情况下只有“任务未过期且 shouldYieldToHost() 为 true”才让出;开启后,每执行完一个任务就主动让出一次,即使时间片还没满。这是 React 团队在调研“更激进的让出是否能改善交互响应”的实验特性(§10.4 专门深挖它的行为差异与代价)。本章以“默认关闭”为主线索讲解,但会在边界章节完整分析它对 workLoop 控制流的影响。
5.6 enableRequestPaint 与 needsPaint 的伏笔
enableRequestPaint = true 在本版本是开启的。它控制 requestPaint() 能否置位 needsPaint,进而让 shouldYieldToHost() 在“有绘制请求”时立即让出(§9 详述)。这一条是“时间切片 + 优先绘制”的桥梁:Scheduler 不该在浏览器想画一帧的时候还霸着主线程。
6. unstable_scheduleCallback:任务投递全流程逐行
unstable_scheduleCallback 是 Scheduler 唯一的对入口。所有“要做的工作”都从它进入:React reconciler 的 scheduleCallback(ReactFiberRootScheduler.js:626)最终调它,应用直接调用 scheduler.unstable_scheduleCallback 也是它。本节逐行拆解这个函数(源码 packages/scheduler/src/forks/Scheduler.js:335-427)。
// packages/scheduler/src/forks/Scheduler.js:335-427
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
var currentTime = getCurrentTime();
var startTime;
// $FlowFixMe[invalid-compare]
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
第 1 步:取当前时间,算 startTime。
currentTime = getCurrentTime():拿到一个高精度时间戳(通常是performance.now(),单位毫秒,浮点)。options可能带delay。注意typeof options === 'object' && options !== null这个判断——它排除了undefined、null、原始类型。delay必须typeof === 'number' && delay > 0才视为“延时任务”。- 若
delay > 0,startTime = currentTime + delay(未来某个时刻才开始);否则startTime = currentTime(立刻可开始)。
这里有个边界细节:delay 等于 0 或负数时,走 else 分支,startTime = currentTime,当成“立即任务”处理,不会进 timerQueue。也就是说 scheduleCallback(pri, cb, {delay: 0}) 和 scheduleCallback(pri, cb) 语义完全一致。
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
timeout = normalPriorityTimeout;
break;
}
var expirationTime = startTime + timeout;
第 2 步:按优先级算 timeout,再算 expirationTime。
- 这是 §5.2 那张表的代码实现。
switch把priorityLevel映射到timeout:- Immediate →
-1 - UserBlocking →
userBlockingPriorityTimeout(250) - Idle →
maxSigned31BitInt(1073741823) - Low →
lowPriorityTimeout(10000) - Normal 及
default→normalPriorityTimeout(5000)
- Immediate →
expirationTime = startTime + timeout:这是任务“过期时刻”。注意default分支兜底——如果调用方传了非法的优先级(比如0/NoPriority或随便一个数字),会被当作NormalPriority,不会崩溃。这是一个防御性设计:Scheduler 不信任外部传入的优先级。- 对 Immediate:
expirationTime = startTime - 1,必然< currentTime,所以一投递就“已过期”。
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// $FlowFixMe[constant-condition]
if (enableProfiling) {
newTask.isQueued = false;
}
第 3 步:构造 Task 对象。
id: taskIdCounter++:拿当前计数器并自增,每个任务拿到全局唯一、递增的 id(这是compare的二级排序键,见 §4.2)。callback:直接引用调用方传来的函数(不拷贝,零分配)。priorityLevel:原样存。startTime/expirationTime:上两步算的。sortIndex: -1:初值为 -1,一个明显的哨兵值,表示“还没入队、排序键未定”。真正入队时会被改写为startTime或expirationTime。- profiling 构建下把
isQueued置false(仅打点用)。
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// $FlowFixMe[constant-condition]
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}
return newTask;
}
第 4 步:按 startTime 决定入哪个堆,并触发宿主回调。
分两大分支:
分支 A:startTime > currentTime(延时任务)
newTask.sortIndex = startTime:在timerQueue里用startTime排序(越早到点越靠前)。push(timerQueue, newTask):入定时器堆。- 关键判断
peek(taskQueue) === null && newTask === peek(timerQueue):peek(taskQueue) === null:任务队列是空的(所有活儿都在延时)。newTask === peek(timerQueue):刚 push 的这个任务正好是timerQueue的堆顶(最早到点)。- 两者同时成立,说明“当前没有任何可立即执行的任务,且这个新任务就是最近的那个延时任务”。此时才需要向浏览器注册一个
setTimeout定时器,等它到点。
- 若之前已经注册过
isHostTimeoutScheduled,先cancelHostTimeout()取消旧的(因为新的更早到点,旧的定时器没用了),再注册新的requestHostTimeout(handleTimeout, startTime - currentTime)。 - 注意:如果
timerQueue里已经有更早的延时任务(即newTask !== peek(timerQueue)),就不重新注册定时器——因为已有的定时器会在更早的时刻触发handleTimeout,那次触发会顺带把本次的新任务也检查、转移。避免重复setTimeout。
分支 B:立即任务(startTime <= currentTime)
newTask.sortIndex = expirationTime:在taskQueue里用expirationTime排序。push(taskQueue, newTask):入任务堆(堆顶就是最该先执行的)。- profiling 打点。
if (!isHostCallbackScheduled && !isPerformingWork):只有在“还没有向宿主注册回调”且“当前没正在执行工作循环”时才注册。这俩条件缺一不可:isHostCallbackScheduled已为 true → 已经有人(上一个任务)注册了MessageChannel消息,无需重复。同一批任务共享一个宏任务,靠workLoop内部循环一口气跑多个。isPerformingWork为 true → 当前正在flushWork里跑,新任务会被workLoop在下一轮peek时自然看到(因为taskQueue已经 push 进去了),不能在“工作循环内部”再嵌套启动一个消息循环(否则重入)。Scheduler 靠这个标志实现“重入保护”。
第 5 步:返回 Task 引用。
return newTask:调用方拿到这个对象,后续可以拿它去unstable_cancelCallback。注意返回的是同一对象引用,Scheduler 内部后续修改(比如把callback置 null、把callback改成续体)都会反映在这个对象上。
6.1 scheduleCallback 的全流程归纳(决策树)
flowchart TD
S["scheduleCallback(pri, cb, opts)"] --> T["currentTime = now()"]
T --> A["算 startTime:delay>0? now+delay : now"]
A --> B["按 pri 算 timeout(Immediate -1 / UserBlocking 250 / Normal 5000 / Low 10000 / Idle MAX)"]
B --> C["expirationTime = startTime + timeout"]
C --> D["构造 Task:id=taskIdCounter++, sortIndex=-1"]
D --> E{"startTime > now ?"}
E -- 是 --> F["sortIndex=startTime → push(timerQueue)"]
F --> G{"taskQueue空 且 本任务是timerQueue堆顶?"}
G -- 是 --> H["取消旧timeout(若有) → requestHostTimeout(handleTimeout, 到点延迟)"]
G -- 否 --> I["不动定时器(已有更早的)"]
E -- 否 --> J["sortIndex=expirationTime → push(taskQueue)"]
J --> K{"!isHostCallbackScheduled 且 !isPerformingWork?"}
K -- 是 --> L["isHostCallbackScheduled=true → requestHostCallback()"]
K -- 否 --> M["等待现有消息循环/本轮workLoop消化"]
H --> R["return newTask"]
I --> R
L --> R
M --> R
6.2 容易混淆的三种“不立即执行”
startTime > currentTime 的延时任务、已入队但 isHostCallbackScheduled 已置位的立即任务、正在 isPerformingWork 期间新加入的立即任务——这三种都会“不立刻执行”,但原因完全不同:
- 延时任务:因为时间点没到,躺在
timerQueue,等handleTimeout转移。 - 第二种:不是不执行,而是“合并进同一个即将到来的宏任务”,由
workLoop连续处理(省去反复 postMessage 的开销)。 - 第三种:因为当前宏任务正在跑,
workLoop的while循环下一轮peek(taskQueue)就会看到它,所以不另起炉灶。
理解这三者的差异,是读懂 Scheduler 为什么“看起来一个 scheduleCallback 有时立刻跑、有时过一会儿跑、有时下帧跑”的关键。
7. 主机回调与消息循环:把工作切进宏任务
scheduleCallback 把任务推进堆后,只是“登记”了工作。真正的执行要等浏览器“有空”时通过宏任务(macrotask)召唤 Scheduler。这套“召唤—执行—再召唤”的机制就是消息循环(message loop)。本节讲 requestHostCallback、performWorkUntilDeadline,以及为什么选 MessageChannel 而非 setTimeout。
7.1 requestHostCallback:启动消息循环
// packages/scheduler/src/forks/Scheduler.js:563-568
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
逻辑极简:若消息循环还没在跑(!isMessageLoopRunning),就置位并调用 schedulePerformWorkUntilDeadline,把 performWorkUntilDeadline 作为一个宏任务投递出去。isMessageLoopRunning 这个标志保证同一时刻只有一个消息循环在跑——哪怕有多个任务被 schedule,它们共享同一个正在运行的循环(靠 workLoop 内部的 while 一口气处理多个任务),不会各自 postMessage。
7.2 performWorkUntilDeadline:一次宏任务的入口
// packages/scheduler/src/forks/Scheduler.js:498-528
const performWorkUntilDeadline = () => {
// $FlowFixMe[constant-condition]
if (enableRequestPaint) {
needsPaint = false;
}
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
// Keep track of the start time so we can measure how long the main thread
// has been blocked.
startTime = currentTime;
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
//
// Intentionally not using a try-catch, since that makes some debugging
// techniques harder. Instead, if `flushWork` errors, then `hasMoreWork` will
// remain true, and we'll continue the work loop.
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};
逐行讲解:
-
if (enableRequestPaint) needsPaint = false;:进入新的一轮工作前,先把“需要绘制”的标记清掉。因为上一轮让出是为了绘制,绘制已经在两次宏任务之间被浏览器消费了,所以这里重置。注意performWorkUntilDeadline是宏任务回调,它在“上一次让出后、浏览器有机会绘制/处理输入之后”才被执行——所以到这里needsPaint的使命已完成。 -
if (isMessageLoopRunning):防御性判断(理论上进入这个回调时循环应该在跑)。 -
startTime = currentTime:这是时间切片的“起点时钟”。shouldYieldToHost之后会用getCurrentTime() - startTime算“主线程被这一批工作占用了多久”。注意startTime是模块级变量(§3.3),每进入一次宏任务就重置。 -
let hasMoreWork = true;:先乐观假设还有活。 -
try { hasMoreWork = flushWork(currentTime); }:flushWork返回true表示“还有没干完的活”(让出了或还有任务),false表示“全干完了”。 -
注释特意说明没有用 try-catch 包住错误:因为如果用 try-catch,开发者在 DevTools 里设的某些断点/错误来源追踪会失效。React 故意让错误“冒泡”出这个宏任务——这样错误能被浏览器的全局错误处理器观察到,用户也能看到真实的报错栈。代价是:一旦
flushWork抛错,hasMoreWork仍为true(初始值),于是finally里会再 schedule 一次……等等,这不就死循环了吗?这里有个 subtle 的点:注释说“if
flushWorkerrors, thenhasMoreWorkwill remain true, and we'll continue the work loop”。但实际上flushWork内部(§8)对 profiling 构建有 try-catch,生产构建没有。若生产构建里callback抛错,错误会冒出performWorkUntilDeadline,hasMoreWork保持true,finally会再次schedulePerformWorkUntilDeadline——但出错的那个currentTask在flushWork返回前不会被执行完,也不会被 pop(因为workLoop是while中途 throw 出来的),所以它仍在taskQueue堆顶。下一次宏任务再跑它,又会抛错……看起来会无限重试。实际上 React 依赖的是:错误最终会被 React 的错误边界(Error Boundary)捕获并清理掉 root 的回调(cancelCallback),从而终止循环。换句话说,Scheduler 把“错误恢复”的责任交给了 React 上层的错误边界机制,自己只负责“不让一个崩溃的任务悄悄消失”。这是一个有意为之的权衡。 -
finally块:hasMoreWork为真 → 再schedulePerformWorkUntilDeadline()(把下一个宏任务排进队列,继续干活);为假 →isMessageLoopRunning = false(循环结束,主线程回到空闲,等待下次requestHostCallback重新启动)。
这里的 “schedule 下一个宏任务” 而非 “递归调用” 是关键:schedulePerformWorkUntilDeadline 是异步的(postMessage / setTimeout),它把控制权交还浏览器,让浏览器能在两次宏任务之间处理输入、绘制、回收——这正是“时间切片”成立的前提。如果它是同步递归调用 performWorkUntilDeadline(),那就不叫让出了,主线程会被一直霸占。
7.3 schedulePerformWorkUntilDeadline 的三个分支:为什么是 MessageChannel
这是本章“必讲透”的核心问题之一。源码 packages/scheduler/src/forks/Scheduler.js:530-561:
// packages/scheduler/src/forks/Scheduler.js:530-561
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
// There's a few reasons for why we prefer setImmediate.
//
// Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
// (Even though this is a DOM fork of the Scheduler, you could get here
// with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
// https://github.com/facebook/react/issues/20756
//
// But also, it runs earlier which is the semantic we want.
// If other browsers ever implement it, it's better to use it.
// Although both of these would be inferior to native scheduling.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
// $FlowFixMe[not-a-function] nullable value
localSetTimeout(performWorkUntilDeadline, 0);
};
}
这是一段“特性探测 + 三级降级”的代码。按优先级选调度宿主:
分支 1:setImmediate(Node.js / 老 IE)
- 条件:
typeof localSetImmediate === 'function'。localSetImmediate在文件顶部捕获为setImmediate(若环境有)。这在 Node.js 和老 IE 里存在。 - 为什么 Prefer setImmediate?
- 不让 Node 进程挂起:
MessageChannel(Node 15+ 也有)创建的端口会阻止 Node 进程退出(因为它持有活跃的句柄)。在 jsdom + Node 混合测试环境下,这会让测试进程无法自然结束。setImmediate没有这个副作用。 - 语义更靠前:
setImmediate的回调在 I/O 之后、下个 tick 之前执行,比MessageChannel的onmessage在某些实现里更早,符合 React “尽快干活”的语义。
- 不让 Node 进程挂起:
- 注意注释自承:“both of these would be inferior to native scheduling(都比原生调度差)”。React 团队理想中是想用浏览器原生的
scheduler.postTask(优先级感知的调度 API),但因兼容与稳定性尚未全面采用。
分支 2:MessageChannel(DOM / Worker,默认路径)
-
条件:
typeof MessageChannel !== 'undefined'(所有现代浏览器、Web Worker 都有)。 -
做法:创建一个
MessageChannel,拿到port2,把port1.onmessage设为performWorkUntilDeadline,schedulePerformWorkUntilDeadline就是port.postMessage(null)。 -
为什么 Prefer MessageChannel 而不是 setTimeout(…, 0)? 这是面试/深挖高频题,源码注释给了直接答案:“We prefer MessageChannel because of the 4ms setTimeout clamping.” 展开讲有三点:
- 绕开 setTimeout 的 4ms 钳制:HTML 规范规定,嵌套超过 5 层的
setTimeout回调,其最小延迟被钳制到 4ms(即使你传0)。React 的渲染往往是“调度→执行→再调度→再执行”的深层嵌套,setTimeout(fn, 0)在这种场景下实际延迟会跳到 4ms,造成不必要的延迟累积。而MessageChannel的postMessage产生的message事件宏任务不受这 4ms 钳制,延迟可以低到亚毫秒级(实测约 0.1~1ms),让“让出后再快速回来”的代价极小。 - 不受渲染阻塞、是独立宏任务队列:
message事件位于宏任务队列,但浏览器在调度message事件时不受当前帧渲染的直接阻塞(相对requestAnimationFrame而言)。Scheduler 不需要“帧对齐”——它要的是“尽快回来继续干”,而不是“在下一帧开始时干”。rAF会在每帧开始时才触发,对于“切片内多次让出再回来”太慢了。 - 稳定且可控:
MessageChannel在所有现代浏览器都可用,行为一致,且不受setTimeout被页面节流(timer throttling,后台标签页setTimeout会被降到 1s)影响那么严重(虽然message在后台标签页也会被节流,但前台交互场景表现更好)。
- 绕开 setTimeout 的 4ms 钳制:HTML 规范规定,嵌套超过 5 层的
-
还有一个工程细节:
port.postMessage(null)传的是null(一个极小的消息负载),避免序列化开销。port1.onmessage持续监听,所以一条 channel 可复用——不用每次新建MessageChannel。
分支 3:setTimeout 兜底(极老/非浏览器环境)
- 条件:既没有
setImmediate也没有MessageChannel(几乎只存在于某些非浏览器 polyfill/极老环境)。 - 做法:
localSetTimeout(performWorkUntilDeadline, 0)。这是“总之先能用”的兜底,性能最差(受 4ms 钳制、受后台节流),但保证 Scheduler 在任何 JS 环境都能跑。
7.4 宏任务 vs 微任务:Scheduler 为什么不用微任务串联工作
值得强调:Scheduler 用宏任务(MessageChannel message)来让出再回来,而不是微任务(microtask / Promise.then)。原因:
- 微任务在“当前宏任务结束后、下一个宏任务前”全部 flush 完,期间浏览器没有机会渲染、处理输入、绘制。如果用微任务串联,等于“一口气把工作做完”,完全丧失了时间切片的让出效果。
- 宏任务之间,浏览器有机会做一次渲染、分发输入事件、跑
rAF。这正是时间切片要的“缝隙”。 - React 19 的同步工作(SyncLane)确实改用微任务了(
ensureRootIsScheduled在 microtask 里 flush sync,见 §11.2),但那是“同步、不可中断”的场景,本就不该让出;而 Scheduler 负责的是“可中断的并发工作”,必须用宏任务。两者分工明确。
7.5 一个完整的“让出—回来”时间线
假设一帧 16.67ms,时间切片 5ms,一个 Normal 任务要跑 30ms:
sequenceDiagram
participant BT as 浏览器主线程
participant MC as MessageChannel
participant W as workLoop
Note over BT: 用户输入触发更新
BT->>MC: scheduleCallback → postMessage
MC->>BT: message 宏任务(t=0)
BT->>W: performWorkUntilDeadline (startTime=0)
W->>W: 跑 5ms,shouldYield=true,return true
BT->>BT: 让出:浏览器可处理输入/绘制
MC->>BT: 下一个 message 宏任务(t≈5.x)
BT->>W: 继续 workLoop 跑 5ms,让出
BT->>BT: 让出
MC->>BT: 再一个 message(t≈10.x)
BT->>W: 跑 5ms(还剩15ms)
Note over W: 若已过期则不让出,一口气跑完剩余
W->>BT: 全部完成 → isMessageLoopRunning=false
注意:每次让出后,MessageChannel 的下一个 message 不一定紧挨着(浏览器可能先去绘制、先处理用户输入),这正是 Scheduler “配合浏览器节奏”的体现——它不强行占满每一帧,而是“有空就干一点,没空就让”。
8. flushWork 与 workLoop:时间切片的心脏(逐行)
scheduleCallback 把活登记进堆,performWorkUntilDeadline 把活请进宏任务,而真正“一片一片干活、并在该让出时让出”的逻辑,全在 flushWork → workLoop 里。这是本章最该读透的部分。
8.1 flushWork:进入工作循环前的准备
// packages/scheduler/src/forks/Scheduler.js:145-191
function flushWork(initialTime: number) {
// $FlowFixMe[constant-condition]
if (enableProfiling) {
markSchedulerUnsuspended(initialTime);
}
// We'll need a host callback the next time work is scheduled.
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
// We scheduled a timeout but it's no longer needed. Cancel it.
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
// $FlowFixMe[constant-condition]
if (enableProfiling) {
try {
return workLoop(initialTime);
} catch (error) {
if (currentTask !== null) {
const currentTime = getCurrentTime();
// $FlowFixMe[incompatible-call] found when upgrading Flow
// $FlowFixMe[incompatible-type]
markTaskErrored(currentTask, currentTime);
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.isQueued = false;
}
throw error;
}
} else {
// No catch in prod code path.
return workLoop(initialTime);
}
} finally {
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
// $FlowFixMe[constant-condition]
if (enableProfiling) {
const currentTime = getCurrentTime();
markSchedulerSuspended(currentTime);
}
}
}
逐行讲解:
markSchedulerUnsuspended(initialTime)(仅 profiling):给 Profiler 打点“调度器被唤醒”。isHostCallbackScheduled = false;:关键重置。进入工作循环前,先声明“当前这个主机回调已经‘被消耗’了”。这样workLoop里若产生新任务,又能重新requestHostCallback(而不是误以为已经有人注册了)。注意这和 §6 里if (!isHostCallbackScheduled && !isPerformingWork)的判据呼应——isPerformingWork此刻已经为true,所以即便isHostCallbackScheduled被重置,新任务也不会立即去注册回调(因为正处在工作中),而是等本轮workLoop自然看到它。if (isHostTimeoutScheduled) { ... cancelHostTimeout(); }:如果之前为某个延时任务注册了setTimeout定时器,而现在我们已经进入工作循环了,说明“要么有立即任务要跑,要么那个延时任务的定时器本就要取消”。总之进入 flush 后,旧的 host timeout 不再需要,取消掉,避免重复触发handleTimeout。isHostTimeoutScheduled = false一并复位。isPerformingWork = true;:置位“正在工作”标志,用于 §6 的重入保护。const previousPriorityLevel = currentPriorityLevel;:保存进入前的优先级上下文,以便finally里恢复(保证 Scheduler 的优先级上下文不被污染到外部)。- 然后是
try:profiling 构建里workLoop外包一层catch——workLoop若在任务执行中抛错,这里markTaskErrored打点,把currentTask.isQueued = false,然后throw error向上冒泡(让performWorkUntilDeadline也看到错误,进而决定要不要再 schedule,见 §7.2)。生产构建里没有这层 catch,直接return workLoop(initialTime)(错误直接冒泡出flushWork)。 finally:无论如何都会执行:currentTask = null:清空“当前任务”引用(防止悬挂引用,也防止 profiling 误打点)。currentPriorityLevel = previousPriorityLevel:恢复优先级上下文(§3.3 提到的重建机制)。isPerformingWork = false:工作结束,解除重入保护。- profiling 下再打点
markSchedulerSuspended。
flushWork 返回值就是 workLoop 的返回值(true/false),直接透传给 performWorkUntilDeadline 决定“要不要再 schedule 一个宏任务”。
8.2 workLoop:协作式让出的核心循环
// packages/scheduler/src/forks/Scheduler.js:193-266
function workLoop(initialTime: number) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (!enableAlwaysYieldScheduler) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
// $FlowFixMe[incompatible-use] found when upgrading Flow
const callback = currentTask.callback;
if (typeof callback === 'function') {
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.callback = null;
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentPriorityLevel = currentTask.priorityLevel;
// $FlowFixMe[incompatible-use] found when upgrading Flow
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// $FlowFixMe[constant-condition]
if (enableProfiling) {
// $FlowFixMe[incompatible-type] found when upgrading Flow
markTaskRun(currentTask, currentTime);
}
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.callback = continuationCallback;
// $FlowFixMe[constant-condition]
if (enableProfiling) {
// $FlowFixMe[incompatible-type] found when upgrading Flow
markTaskYield(currentTask, currentTime);
}
advanceTimers(currentTime);
return true;
} else {
// $FlowFixMe[constant-condition]
if (enableProfiling) {
// $FlowFixMe[incompatible-type] found when upgrading Flow
markTaskCompleted(currentTask, currentTime);
// $FlowFixMe[incompatible-use] found when upgrading Flow
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
if (enableAlwaysYieldScheduler) {
if (currentTask === null || currentTask.expirationTime > currentTime) {
// This currentTask hasn't expired we yield to the browser task.
break;
}
}
}
// Return whether there's additional work
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
这是整个 Scheduler 最关键的 70 行,下面逐段拆解。
8.2.1 循环前:先搬延时任务、取堆顶
let currentTime = initialTime;:currentTime从initialTime(即performWorkUntilDeadline进入时的时间)起算,但注意它会在循环里被getCurrentTime()刷新。advanceTimers(currentTime);:每次进入workLoop先调用一次advanceTimers,把timerQueue里已经到点的延时任务转移到taskQueue(§10 详述)。这一步保证“延时到点”的任务能立即参与本轮调度。currentTask = peek(taskQueue);:取taskQueue堆顶(最该先执行的任务)作为当前任务。
8.2.2 让出判断(默认分支)
if (!enableAlwaysYieldScheduler) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
}
这是默认情况下(未开启实验开关)让出的唯一判据,请逐字读:
currentTask.expirationTime > currentTime:当前任务还没过期(它的过期时刻在未来)。如果已经过期(<= currentTime),这个条件为假,就不会走到break——意味着“过期任务必须做完,不让出”。shouldYieldToHost():宿主(浏览器)希望让出(时间片用尽,或有绘制请求)。- 两者同时为真 →
break,让出当前宏任务,结束workLoop。
这正是“已过期任务不让出”的代码证明:对于 Immediate 任务(expirationTime = startTime - 1 < currentTime),expirationTime > currentTime 永远为假,所以它永远不会因为时间片而让出——必须一口气做完。这就是“同步/不可中断”语义在 Scheduler 层的落地。对于即将过期的 UserBlocking 任务(已等了 249ms,快到 250ms 阈值),expirationTime > currentTime 仍然为真,所以理论上它还会让出——但只要跨过阈值(expirationTime <= currentTime),就立刻转为“不让出,做完”。这保证了“再紧急的任务,一旦过期就绝不被打断”。
8.2.3 取出 callback 并执行
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const callback = currentTask.callback;:拿到任务真正要执行的函数。currentTask.callback = null;:先把自己的 callback 置 null。这是一个巧妙的“防重入 + 标记”技巧:如果执行期间这个任务被cancelCallback置了 null,那callback(局部变量)已经是我们要跑的旧函数,不受影响;同时这里的置 null 表示“我正在/已经占用这个任务的执行权”。注意:对于“正常完成”的任务,置 null 后不会再恢复;对于“续体”任务,下面会把续体函数写回currentTask.callback(§8.2.4)。currentPriorityLevel = currentTask.priorityLevel;:把 Scheduler 的优先级上下文切到这个任务的优先级。这样callback内部若调用getCurrentPriorityLevel()或嵌套runWithPriority,能拿到正确的值。didUserCallbackTimeout = currentTask.expirationTime <= currentTime:这个布尔值传给callback当参数,告诉执行者“你的任务是否已经超时了”。React reconciler 的performWorkOnRootViaSchedulerTask(root, didTimeout)就用它决定forceSync(§11.3)。这是 Scheduler 把“时间压力”暴露给上层工作的唯一通道。
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
- 执行 callback,并捕获它的返回值
continuationCallback。这一行就是真正干活的时刻(对 React 来说,这就是跑 Fiber 的performConcurrentWorkOnRoot)。 - 执行后立刻
currentTime = getCurrentTime();刷新时间——因为callback可能跑了很久,必须重新读时钟,后面判断是否让出才准确。
8.2.4 续体(continuation):返回函数就立刻让出
if (typeof continuationCallback === 'function') {
// If a continuation is returned, immediately yield to the main thread
// regardless of how much time is left in the current time slice.
currentTask.callback = continuationCallback;
if (enableProfiling) {
markTaskYield(currentTask, currentTime);
}
advanceTimers(currentTime);
return true;
}
这是“续体机制”的核心(§11 会展开讲 React 侧如何利用它):
- 如果
callback返回另一个函数,说明“我这一片干完了,但还有后续,请把这个续体函数留着,下次再跑”。 currentTask.callback = continuationCallback;:把续体函数写回currentTask.callback,于是这个 Task 对象“原地复用”,不需要新建对象、不需要重新入堆(它本来就没被 pop 出taskQueue)。return true;:直接结束workLoop并返回true(表示“还有活”)。注意这里即使时间片没满也立即让出——注释明说 “regardless of how much time is left”。为什么?因为返回一个续体通常是 React 主动判断“该让出了”(比如 render 阶段告一段落、希望 commit 或处理更高优先任务),Scheduler 尊重这个主动让出。advanceTimers(currentTime)在让出前再搬一次延时任务,保证下一轮不漏。
8.2.5 任务完成:出队或丢弃
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
continuationCallback不是函数(即undefined/null/其他)→ 任务正常完成。- profiling 打点(仅 profiling 构建执行这块,生产构建
else分支直接走到if (currentTask === peek(taskQueue)))。 if (currentTask === peek(taskQueue)) pop(taskQueue);:关键判断:只有“当前任务仍是堆顶”时才 pop。为什么不直接 pop?因为callback执行期间可能scheduleCallback了新任务(新任务 push 进taskQueue,可能交换了堆顶),此时currentTask不一定是堆顶了——贸然 pop 会删错节点。这个=== peek保护确保“只删我们正在处理的这个堆顶”。如果中途插入了更紧急的任务,currentTask被挤下堆顶,那它会被留在堆里(因为它已经执行完、callback 已置 null),下一轮while取到它时走else { pop(taskQueue); }分支(§8.2.6)被丢弃。- 再次
advanceTimers(currentTime)。
8.2.6 callback 为 null:已取消,直接出队
} else {
pop(taskQueue);
}
- 如果
currentTask.callback不是函数(是null):说明这个任务在“被排到堆顶之前/期间”被cancelCallback取消了(§6 提到取消只是把 callback 置 null)。此时它没活可干,直接pop(taskQueue)移出堆。这就是“取消任务 = 置 null,等轮到堆顶时丢弃”的实现落点。
8.2.7 取下一个 + 实验开关让出
currentTask = peek(taskQueue);
if (enableAlwaysYieldScheduler) {
if (currentTask === null || currentTask.expirationTime > currentTime) {
// This currentTask hasn't expired we yield to the browser task.
break;
}
}
currentTask = peek(taskQueue);:取下一个堆顶,进入while下一轮。if (enableAlwaysYieldScheduler):这是实验开关开启时的额外让出点。注意它和 §8.2.2 是“或”的关系(默认关时 §8.2.2 生效,开时 §8.2.2 被跳过、这里生效)。开启后逻辑是:“只要下一个任务是 null(没活了)或还没过期,就让出”。也就是说每执行完一个任务就主动让出,即便时间片还没满。这会显著提高交互响应,但增加宏任务切换开销。详见 §10.4。
8.2.8 循环结束:还有活吗?
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
- 循环因为
break(让出)退出时,currentTask仍非 null(还有未处理的堆顶),返回true→ 上层再 schedule 一个宏任务继续。 - 循环因为
peek(taskQueue)取到 null(所有任务都处理完)而自然结束,currentTask === null,返回false。但返回前还要看timerQueue:如果还有延时任务在等(firstTimer !== null),就requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)注册一个setTimeout,等最早的延时任务到点时触发handleTimeout转移它(§10)。这是“空 taskQueue 但有延时任务时,如何让浏览器在未来唤醒 Scheduler”的机制——注意此时isMessageLoopRunning会被performWorkUntilDeadline置 false(因为hasMoreWork=false),所以 Scheduler 进入“靠 setTimeout 守候”的休眠态,直到handleTimeout重新requestHostCallback。
8.3 workLoop 控制流总图
把上面所有分支画成一张图(默认 enableAlwaysYieldScheduler=false):
flowchart TD
Start["workLoop(initialTime)"] --> Adv["advanceTimers(now)"]
Adv --> Peek["currentTask = peek(taskQueue)"]
Peek --> While{"currentTask != null ?"}
While -- 否 --> End["返回 false(无活)"]
While -- 是 --> Yield{"未过期 且 shouldYield()?"}
Yield -- 是 --> Break["break → 返回 true(让出)"]
Yield -- 否 --> CB{"callback 是函数?"}
CB -- 否 --> PopCancel["pop(taskQueue) 取消态"]
CB -- 是 --> Run["执行 callback(didTimeout)"]
Run --> Ret{"返回函数是续体?"}
Ret -- 是 --> Cont["callback=续体, return true(让出)"]
Ret -- 否 --> Done["pop(若仍是堆顶), 继续"]
PopCancel --> Next["currentTask=peek(taskQueue)"]
Done --> Next
Next --> While
8.4 时间切片在这里如何“生效”
把 §8.2.2 的让出判据和 §7 的 startTime 时钟、shouldYieldToHost 三者连起来,时间切片就完整了:
performWorkUntilDeadline进入时记录startTime = now(§7.2)。workLoop每执行一个 callback 后刷新currentTime = now()。- 下一个任务若“未过期”,调用
shouldYieldToHost(),它内部算now - startTime >= frameInterval(5ms)(§9)。 - 若 >= 5ms,
shouldYieldToHost返回 true →workLoopbreak → 让出宏任务。 - 让出后浏览器处理输入/绘制,下一帧(或之后)
MessageChannel再触发performWorkUntilDeadline,重新记录startTime,开始下一片。
所以“5ms 切片”是“连续占用主线程不超过约 5ms 就主动让出一次”,而非“每帧只干 5ms 然后睡到下一帧”。实际一帧里可能干 0 片(浏览器忙)、1 片、或因为任务短而干完整个任务——它是对“主线程占用时长”的约束,不是对“每帧工作量”的约束。
9. shouldYieldToHost 与 forceFrameRate:5ms 从哪里来
§8 的让出判据里,shouldYieldToHost() 是“是否该让出”的守门员;frameInterval(默认 5)是切片长度;requestPaint/needsPaint 是“优先绘制”的开关。本节把三者一起讲透,并回答“为什么是 5ms 而不是 16ms”。
9.1 shouldYieldToHost:让出的两个理由
// packages/scheduler/src/forks/Scheduler.js:459-472
function shouldYieldToHost(): boolean {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
// Yield now.
return true;
}
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}
逐行讲解:
- 理由一(绘制优先):
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) return true;。当实验开关关闭、enableRequestPaint开启、且needsPaint为 true 时,立即让出。这意味着“React/上层已经请求过绘制(比如 commit 阶段产生了 DOM 变更,希望浏览器尽快画出来),Scheduler 不该再霸着主线程”,哪怕时间片还没满也要让。注意!enableAlwaysYieldScheduler条件:若实验开关开启,这个分支被跳过(实验开关用自己的让出逻辑,§10.4)。needsPaint由requestPaint()置位(§9.3)。enableRequestPaint在 19.3.1 默认true。 - 理由二(时间片用尽):
timeElapsed = getCurrentTime() - startTime;。startTime是performWorkUntilDeadline进入时记的(§7.2),timeElapsed就是“本次宏任务里主线程被连续占用的时长”。- 若
timeElapsed < frameInterval(默认 5ms)→return false(不让出,继续干)。注释说“The main thread has only been blocked for a really short amount of time; smaller than a single frame. Don't yield yet.”——因为占用时间还远小于一帧,让出反而增加宏任务切换开销,不划算。 - 否则
return true(让出)。
- 若
两个理由的逻辑关系:理由一是“有绘制需求就优先让”,理由二是“占够 5ms 了也让”。两者任一成立就 yield。shouldYieldToHost 是 Scheduler 对“让出”的唯一裁决函数,被 workLoop 在“任务未过期”时调用,被 React reconciler 侧(workLoop 内部 performUnitOfWork 之外)也会通过 unstable_shouldYield 暴露出去——Fiber 每处理一个单元都会问它。
9.2 frameInterval 与 5ms 的来历
// packages/scheduler/src/forks/Scheduler.js:452-457
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let frameInterval: number = frameYieldMs;
let startTime = -1;
frameInterval初始为frameYieldMs,即来自SchedulerFeatureFlags.js的5。- 注释点出关键设计哲学:“它每帧让出多次(yields multiple times per frame),且不试图和帧边界对齐(does not attempt to align with frame boundaries)”。展开:
- 帧预算是 16.67ms(60fps),但 Scheduler 不在 16.67ms 才让出,而是 5ms 就让一次。这样一帧里 React 可能“干 5ms、让、干 5ms、让、干 5ms”,把主线程切成更细的片,给用户输入/绘制留更多缝隙。
- 它不用
requestAnimationFrame对齐帧——因为 rAF 只在帧开始时触发,太“粗”;Scheduler 要的是“随时可以被打断、随时让出”,用宏任务即可,不必等帧。需要“帧对齐”的工作(动画)应自己用 rAF,Scheduler 不替它管。
为什么是 5ms?一个工程权衡
这是本章“必讲透的 trade-off”。5ms 不是拍脑袋,而是 React 团队在“响应性”与“调度开销”之间反复权衡的结果:
- 给浏览器留足余量:16.67ms 一帧。如果 Scheduler 一次占满 16ms,浏览器就几乎没有时间做样式/布局/绘制,必然掉帧。留到 5ms 让出,意味着“每片最多吃 5ms”,剩下 11ms+ 给浏览器渲染和用户输入。5ms 大约是一帧预算的 30%,是一个“既不会让出太频繁(开销大),又不会占用太久(卡顿)”的甜点。
- 宏观任务的切换成本:每次让出 = 一次宏任务调度(postMessage)+ 浏览器一次事件循环回合。如果切片设得太小(比如 1ms),让出/回来的次数暴增,宏任务切换的固定开销会吃掉收益;设太大(比如 15ms),又会卡顿。5ms 是“切换开销可忽略、同时足够碎”的平衡点。
- 经验值 + 可调:React 早期版本用的是更保守的值,后来随实测调成 5ms。而且它是可调的——
forceFrameRate(fps)能改frameInterval(§9.4),所以 5ms 是默认值而非铁律。 - 注意 5ms 是“上限”而非“配额”:
shouldYieldToHost里是timeElapsed < frameInterval → 不让出,即“不到 5ms 坚决不让”。所以单个任务若能在 5ms 内跑完,Scheduler 不会中途打断它;只有跨过 5ms 的那个任务边界才会让出。这避免了“一个 3ms 的小任务被切成两半”的浪费。
与 RAIL 模型的对应
RAIL 建议“动画 16ms、响应 100ms、空闲做预载”。Scheduler 的 5ms 切片直接服务于“动画不卡(每片留给浏览器 11ms+ 去 composite/paint)”与“输入响应(每 5ms 就有机会处理事件)”。它把 RAIL 的“响应”目标拆进了渲染过程本身——不是“渲染完再响应”,而是“渲染中间就响应”。
9.3 requestPaint 与 needsPaint:把绘制权交还浏览器
// packages/scheduler/src/forks/Scheduler.js:474-479
function requestPaint() {
// $FlowFixMe[constant-condition]
if (enableRequestPaint) {
needsPaint = true;
}
}
requestPaint(导出为unstable_requestPaint)是上层(主要是 React reconciler 的 commit 阶段)用来告诉 Scheduler“我刚改了 DOM,请尽快让出以便浏览器绘制” 的通道。- 它只置
needsPaint = true(前提是enableRequestPaint开启)。needsPaint在下一次performWorkUntilDeadline进入时被重置为 false(§7.2),所以它只在“两次宏任务之间”有效——即“本轮干活时有人请求了绘制,本轮就该在方便时让出”。 - 为什么要单独有这个通道?因为
shouldYieldToHost的时间片判断(5ms)可能还没到,但 commit 阶段已经产生了可见的 DOM 变更,此时继续跑下一个低优渲染任务会推迟用户“看到更新”的时间。React 通过requestPaint让 Scheduler 在当前时间片内提前让出,优先把已经算好的 UI 画出来。这是“调度与渲染节奏协同”的关键一环:Scheduler 不只在乎“时间”,也在乎“画”。
9.4 forceFrameRate:运行时调节切片长度
// packages/scheduler/src/forks/Scheduler.js:481-496
function forceFrameRate(fps: number) {
if (fps < 0 || fps > 125) {
// Using console['error'] to evade Babel and ESLint
console['error'](
'forceFrameRate takes a positive int between 0 and 125, ' +
'forcing frame rates higher than 125 fps is not supported',
);
return;
}
if (fps > 0) {
frameInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
frameInterval = frameYieldMs;
}
}
逐行讲解:
- 参数校验:
fps必须在0~125之间。超过 125(即frameInterval < 8ms)不支持——因为宏任务切换本身就有毫秒级开销,强制更小切片只会让开销占比失控。注释用console['error'](而非console.error)是故意绕开 Babel/ESLint 对console.error的可能改写/警告。 fps > 0:frameInterval = Math.floor(1000 / fps)。例如:forceFrameRate(60)→frameInterval = 16(每片 16ms,对齐 60fps 帧,几乎不让出)。forceFrameRate(125)→frameInterval = 8(每片 8ms)。forceFrameRate(30)→frameInterval = 33(每片 33ms,更激进地少让出,适合不关心交互的后台渲染)。
fps === 0或非法:重置frameInterval = frameYieldMs(5),恢复默认。
注意:forceFrameRate 改变的是“让出的时间间隔”,不改变优先级和过期时间。它影响的是“Scheduler 在主线程上愿意连续占多久”,是一个全局的“节奏旋钮”。React 自身基本不调用它(默认 5ms 足够好),但测试环境或特殊场景可以调。
9.5 shouldYieldToHost 的调用方不止 workLoop
虽然 workLoop 用它在“任务未过期”时判断让出,但 shouldYieldToHost 同时被导出为 unstable_shouldYield,供 React reconciler 在更细的粒度上询问。例如 Fiber 的 workLoopConcurrent(performConcurrentWorkOnRoot 内部)每处理一个 Fiber 单元后也会检查 shouldYieldToHost(),从而让“单个任务内部”也响应让出——即使 workLoop 的“任务边界”还没到。这样“时间切片”是双层的:任务层(workLoop 的任务边界)+ 单元层(Fiber 单元边界),共同保证主线程不被长时间霸占。
不过要厘清:本章聚焦的 forks/Scheduler.js 里,shouldYieldToHost 只在 workLoop 被调用;reconciler 侧的“单元级让出”是通过它自己的 workLoop 调用同一个 unstable_shouldYield 实现,二者共享同一个 frameInterval 时钟。所以 5ms 的约束在“任务级”和“单元级”是统一的。
10. 延时任务的生命周期:advanceTimers / handleTimeout / requestHostTimeout
scheduleCallback 把延时任务(startTime > now)塞进 timerQueue 后,它不会自动跑到 taskQueue。必须有人“在它到点时”把它搬过去。这一节讲清楚这条“延时 → 可执行”的转移链路,以及 Scheduler 如何“睡着等唤醒”。
10.1 advanceTimers:把到点的延时任务搬进 taskQueue
// packages/scheduler/src/forks/Scheduler.js:103-126
function advanceTimers(currentTime: number) {
// Check for tasks that are no longer delayed and add them to the queue.
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
// $FlowFixMe[constant-condition]
if (enableProfiling) {
markTaskStart(timer, currentTime);
timer.isQueued = true;
}
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
}
逐行讲解:
let timer = peek(timerQueue);:从timerQueue堆顶取最早到点的延时任务(timerQueue按startTime排序,堆顶就是最早该触发的)。while (timer !== null):循环处理,因为可能同时有多个延时任务都已到点(比如积压的定时任务)。- 分支 1:
timer.callback === null→ 这个延时任务被cancelCallback取消了。直接pop(timerQueue)把它移出堆(同样是因为“不能在堆中间删任意节点”,只能 pop 堆顶;取消只是置 null,等这里 peek 到它时丢弃)。 - 分支 2:
timer.startTime <= currentTime(到点了):pop(timerQueue);:从timerQueue移除。timer.sortIndex = timer.expirationTime;:关键改写排序键!任务在timerQueue里用startTime排序,现在要进taskQueue,排序语义变成“按过期时间”,所以把sortIndex从startTime改成expirationTime。这一步在 §3.1 提过,这里看到实际操作。push(taskQueue, timer);:push 进任务堆。由于sortIndex已改为expirationTime,它会在taskQueue里被正确排序(早于过期的靠前)。- profiling 打点。
- 分支 3:
timer.startTime > currentTime(还没到点):因为timerQueue按startTime升序,peek到的第一个还没到点,说明后面所有都没到点,return直接结束(不用再检查)。这是堆有序性的红利。 - 循环末尾
timer = peek(timerQueue);取下一项继续。
advanceTimers 被三处调用:workLoop 进入时(§8.2.1)、workLoop 处理完一个任务后(§8.2.4/8.2.5)、handleTimeout 里(§10.2)。保证“任何一次开始干活或让出前,到点的延时任务都已经转移好”。
10.2 handleTimeout:定时器触发后的处理
// packages/scheduler/src/forks/Scheduler.js:128-143
function handleTimeout(currentTime: number) {
isHostTimeoutScheduled = false;
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback();
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
逐行讲解:
isHostTimeoutScheduled = false;:先声明“这个 setTimeout 定时器已经被触发/消费了”。advanceTimers(currentTime);:把到点的延时任务转移进taskQueue(§10.1)。if (!isHostCallbackScheduled):只有当当前没有在跑消息循环时,才需要重新唤醒。如果在scheduleCallback的同时已经有立即任务在跑(isHostCallbackScheduled为 true 或isPerformingWork为 true),那workLoop已经会处理这些转移过来的任务,无需再启动循环。if (peek(taskQueue) !== null):转移后taskQueue非空 → 有活可干了。置isHostCallbackScheduled = true并requestHostCallback()启动消息循环,宏任务里workLoop会处理它们。else:taskQueue仍空(比如转移的延时任务又被某种原因清空,或本来就没活)→ 看timerQueue还有没有更晚的延时任务,有则requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)重新注册一个 setTimeout,等下一个最早的延时任务。这形成了“守候链”:Scheduler 在只有延时任务时,靠setTimeout一个一个地“睡到下一个到点时刻”。
10.3 requestHostTimeout / cancelHostTimeout:用 setTimeout 守候
// packages/scheduler/src/forks/Scheduler.js:570-584
function requestHostTimeout(
callback: (currentTime: number) => void,
ms: number,
) {
// $FlowFixMe[not-a-function] nullable value
taskTimeoutID = localSetTimeout(() => {
callback(getCurrentTime());
}, ms);
}
function cancelHostTimeout() {
// $FlowFixMe[not-a-function] nullable value
localClearTimeout(taskTimeoutID);
taskTimeoutID = -1 as any as TimeoutID;
}
requestHostTimeout(callback, ms):用原生setTimeout注册一个ms毫秒后的定时器,ms就是firstTimer.startTime - currentTime(离最早延时任务还有多久)。回调里用getCurrentTime()重新取当前时间传给callback(这样handleTimeout/advanceTimers拿到的是“真实到点时刻”,而非估算的currentTime)。注意这里用setTimeout是刻意的:延时任务的“到点”不需要绕开 4ms 钳制(它本来就是几秒、几百毫秒的延迟),而且setTimeout在后台标签页会被节流——对延时任务来说反而省电(不需要后台频繁唤醒)。所以延时守候用setTimeout,实时切片用MessageChannel,两者各取所长。cancelHostTimeout():clearTimeout(taskTimeoutID)并复位句柄。在flushWork进入工作循环时(§8.1)、以及scheduleCallback注册新的更早定时器前(§6 分支 A)都会被调用,避免“多个 setTimeout 同时守候”。
10.4 延时任务完整状态机
延时任务的一生经过 timerQueue → (到点) → taskQueue → (执行) → 完成/取消:
stateDiagram-v2
[*] --> PendingTimer: scheduleCallback(delay>0)
PendingTimer: timerQueue (sortIndex=startTime)
PendingTimer --> PendingTimer: 新更早的延时任务注册 requestHostTimeout
PendingTimer --> Ready: advanceTimers 发现 startTime<=now
Ready: taskQueue (sortIndex=expirationTime)
Ready --> Running: workLoop peek 取出
Running --> Done: callback 返回非函数
Running --> YieldCont: callback 返回续体(让出后回来)
YieldCont --> Running: 下一宏任务继续
Running --> Cancelled: cancelCallback 置 callback=null
Cancelled --> [*]: pop 时丢弃
Done --> [*]
PendingTimer --> Cancelled2: cancelCallback 置 callback=null
Cancelled2 --> [*]: advanceTimers pop 丢弃
注意两个取消出口:延时阶段取消(callback 置 null,在 advanceTimers 的 peek 时 pop 丢弃)与执行阶段取消(在 workLoop 取到时因 callback===null 而 pop 丢弃)。无论哪个阶段,cancelCallback 都只是置 null,真正的移除发生在“轮到它成为堆顶”时——这就是 §3.2 不变式的体现。
10.5 requestHostTimeout 与 enableAlwaysYieldScheduler 的交互
当 enableAlwaysYieldScheduler 开启时,workLoop 每执行完一个任务就额外让出一次(§8.2.7)。这会让 workLoop 更早返回 true,从而更频繁地 schedule 宏任务——但延时任务的唤醒仍只依赖 requestHostTimeout 的 setTimeout,与实验开关无关。换句话说,实验开关改变的是“taskQueue 内任务的让出频率”,不改变“timerQueue → taskQueue 的转移节奏”。
11. 任务取消的真实代价:unstable_cancelCallback 为什么只置 null
§3.2 埋了一个不变式:数组实现的二叉堆只能高效删除堆顶,不能删除任意中间节点。这一节把这条不变式的“后果”讲透——取消(cancel)在 Scheduler 里到底做了什么、没做什么、以及“没做的那部分”如何在后续被补上。这是理解 Scheduler 一切“惰性清理(lazy cleanup)”设计的钥匙。
11.1 unstable_cancelCallback 逐行
这段来自 packages/scheduler/src/forks/Scheduler.js:429-443,是本章最短、却最容易被误读的函数:
// packages/scheduler/src/forks/Scheduler.js:429-443
function unstable_cancelCallback(task: Task) {
// $FlowFixMe[constant-condition]
if (enableProfiling) {
if (task.isQueued) {
const currentTime = getCurrentTime();
markTaskCanceled(task, currentTime);
task.isQueued = false;
}
}
// Null out the callback to indicate the task has been canceled. (Can't
// remove from the queue because you can't remove arbitrary nodes from an
// array based heap, only the first one.)
task.callback = null;
}
逐行讲解:
if (enableProfiling):profiling 构建下,如果任务当前还在队里(task.isQueued为 true),打一个“任务被取消”的事件点,并把isQueued置回 false(避免重复打点)。生产构建里这整个if被 DCE(dead code elimination,死代码消除)掉,因为enableProfiling是false这个构建期常量。task.callback = null;:这是取消的全部。注释写得极其直白:“把 callback 置 null 表示任务已取消。(不能从队列里移除,因为你无法从数组实现的堆里删除任意节点,只能删第一个。)”
也就是说,unstable_cancelCallback:不从 taskQueue / timerQueue 里移除节点;不修改 sortIndex、expirationTime、priorityLevel;不触碰任何全局标志(isHostCallbackScheduled、isMessageLoopRunning 等);并且天然是幂等的——重复调用只会反复写 null,无副作用(profiling 下因 isQueued 已置 false 也不会重复打点)。
这里有一个必须点破的语义:task.callback = null 之后,任务对象本身依然“活”在堆里,只是它已经“没有了活儿”。所以“取消”在这个实现里等价于“标记撤销 + 惰性回收”,而不是“立即删除”。
11.2 为什么不能从堆中间删除:pop 的硬约束
回到 SchedulerMinHeap.js,我们看“删除”只有一条路径:
// packages/scheduler/src/SchedulerMinHeap.js:27-40
export function pop<T: Node>(heap: Heap<T>): T | null {
if (heap.length === 0) {
return null;
}
const first = heap[0];
const last = heap.pop();
if (last !== first) {
heap[0] = last;
siftDown(heap, last, 0);
}
return first;
}
pop 只能删 heap[0](堆顶),做法是把末尾元素搬到堆顶再 siftDown。它没有任何“指定索引删除”的入口。为什么?
- 用数组表示堆时,“堆序”只保证父子关系(父 ≤ 子),不保证兄弟之间或跨子树之间的顺序。因此给定一个 Task 对象,你无法通过比较
sortIndex用二分或类似手段快速定位它在数组里的下标——它的位置完全由历史插入/删除的siftUp/siftDown轨迹决定。要找到它,只能线性扫描 O(n)。 - 找到下标
i后,删除它还需要“用末尾元素填i,再决定siftUp还是siftDown”——这是 O(log n)。但前置的 O(n) 扫描已经让整体退化成 O(n)。 - 另一种做法是“标记 + 定期重建堆”,但那会引入额外的时机判断(什么时候重建?重建时怎么保证不丢任务?),复杂度上去了,收益却很低。
React 的选择是第三种、也是最省事的一种:惰性删除(lazy deletion)。取消时不删,只标记;等到节点自然成为堆顶被 peek/pop 到的那一刻,再顺手丢弃。这带来两个好处:
cancelCallback是 O(1) 的(一次属性写入),在“频繁取消”的场景下极其廉价;- 堆的维护逻辑(
siftUp/siftDown)完全不需要改动,不引入任何新分支。
代价是:堆里会短时间滞留“已取消但未回收”的僵尸节点。这个代价有多大?我们量化一下。
11.3 取消的三种时序:僵尸节点何时被回收
“取消”发生在不同时刻,回收路径(谁是那个把僵尸节点弹出去的 pop)也完全不同。分四种情况:
- 情况一:延时阶段取消。任务还在
timerQueue。因为advanceTimers每次都会peek(timerQueue)取堆顶,一旦这个被取消的任务成为timerQueue堆顶,advanceTimers里if (timer.callback === null) pop(timerQueue);(Scheduler.js:107-109)就把它弹掉。注意:如果它在timerQueue里不是堆顶(后面还有更早到点的延时任务),它也会一直滞留,直到前面的延时任务都处理完。 - 情况二:在
taskQueue且恰是堆顶。workLoop的while循环下一轮peek(taskQueue)拿到的就是它,typeof callback === 'function'为假,走else { pop(taskQueue); }(Scheduler.js:245-247)直接丢弃。这是最“及时”的回收。 - 情况三:在
taskQueue但被压在下面。它要等排在它前面的任务都被处理(执行完或也让出)后,才成为堆顶被回收。如果持续有更高优任务插入,它可能滞留不短的时间。 - 情况四:正在执行中。这是最容易被忽略的一种:
workLoop在调用callback之前就已经把currentTask.callback = null(Scheduler.js:208),并把原函数存在局部变量callback里。此时外部再调cancelCallback写null是无效的——任务照样跑完。这说明:
Scheduler 的取消只能取消“尚未开始的任务”,无法中止“正在执行的任务”。 因为 JS 单线程,正在跑的同步函数没法被外部打断。
这一点对 React 至关重要:React 通过 root.callbackNode 记录当前排进 Scheduler 的节点,取消时调 Scheduler_cancelCallback。如果那个任务正在 workLoop 里执行,取消是“来不及”的——所以 React 必须自己在 performWorkOnRootViaSchedulerTask 内部检查“我的任务是否已被取消/替换”(§11.5)。
11.4 僵尸节点的真实代价:空间与时间的量化
空间:一个 Task 对象在 64 位 V8 里约 80~120 字节(对象头 + 6 个字段),取消只把它变成“字段为 null 的普通对象”,不释放。滞留数量上界 ≈ “在一个 workLoop 处理周期内被取消、且未被 peek 到”的任务数——React 里通常是个位数(每个 root 只有一个 callbackNode);只有应用自己大量 scheduleCallback 再取消(拿 Scheduler 做节流/防抖)才可能堆积,且若被取消任务的 sortIndex 很大(如 Idle)它会沉到堆底长期滞留。
时间:compare 完全不看 callback,只看 sortIndex 与 id,所以僵尸节点不影响堆序;workLoop/advanceTimers 每轮对堆顶的那次 callback === null / typeof callback === 'function' 判断是 O(1);唯一“浪费”是僵尸节点挡在堆顶时多消耗一次循环迭代,可忽略。
结论:惰性删除的代价在 React 的真实负载下几乎为零,换来的是 cancelCallback O(1) 与堆逻辑零改动——典型的“把复杂度从热路径(取消)搬到冷路径(自然回收)”。
11.5 React 侧如何取消:callbackNode 与 fakeActCallbackNode
React 并不直接把自己暴露给 Scheduler 的取消语义,而是包了一层(ReactFiberRootScheduler.js:624-648):
act环境下的“影子节点”:scheduleCallback在__DEV__ && ReactSharedInternals.actQueue !== null时不真的把任务排进 Scheduler,而是 push 进actQueue并返回一个恒等对象fakeActCallbackNode当“节点句柄”;cancelCallback只要判断句柄是不是这个假节点,就跳过真正的Scheduler_cancelCallback。这是act能把调度完全接管为同步执行的机制之一(详见 §14)。- 正常路径:
Scheduler_scheduleCallback(priorityLevel, performWorkOnRootViaSchedulerTask.bind(null, root)),返回的 Task 被存进root.callbackNode(ReactFiberRootScheduler.js:500-506)。
React 里有两处关键的“取消/失效”判断:
第一处,在 scheduleTaskForRootDuringMicrotask 里——当 nextLanes === NoLanes、或 root 是正在挂起等数据的 workInProgressRoot、或 root.cancelPendingCommit !== null 时,若 existingCallbackNode !== null 就 cancelCallback(existingCallbackNode),并把 root.callbackNode 置 null、root.callbackPriority 置 NoLane,最后 return NoLane(ReactFiberRootScheduler.js:419-439)。
第二处,在 performWorkOnRootViaSchedulerTask 里——因为“正在执行中的任务无法被 cancel 中止”(§11.3 情况四),React 必须靠节点身份判断自己是否已被替换(ReactFiberRootScheduler.js:545-558):先记下 originalCallbackNode = root.callbackNode,若 flushPendingEffectsDelayed() 返回真(有被动副作用被 flush),就检查 root.callbackNode !== originalCallbackNode——不等说明当前任务已被取消/替换,直接 return null 退出(注释:无需再调 ensureRootIsScheduled,因为节点变化本身就意味着“有新任务或已无活”)。
注意这个判断的本质:即使 Scheduler 已经把 task.callback 置 null(例如被动副作用里触发了影响调度的更新),正在执行的这个 callback 局部变量仍会继续跑。React 只能通过“我自己在 root 上记的节点身份变了没有”来判断“我这一趟是不是已经作废”。这就是“取消不能中止执行中的任务”在 React 侧留下的补偿逻辑。
最后,performWorkOnRootViaSchedulerTask 返回续体前还会再做一次身份校验(ReactFiberRootScheduler.js:599-605):scheduleTaskForRootDuringMicrotask(root, now()) 之后,只有 root.callbackNode === originalCallbackNode 才 return performWorkOnRootViaSchedulerTask.bind(null, root)(返回续体,让 Scheduler 原地复用这个 Task,§8.2.4);否则 return null,表示“我这个 Task 已经作废,让堆把它丢弃”。这条 return null 正是让 Scheduler 里那个僵尸 Task 走“正常完成”分支被 pop 的通道。
11.6 小结:取消的“三个不”与“一个必须”
不立即删(只置 callback = null)、不保证及时(回收取决于它在堆中的位置)、不能中止执行中(已在 workLoop 里跑的任务取消无效);因此 React 侧必须自己判身份(用 callbackNode 校验兜底“我是否已作废”)。
12. 优先级上下文:runWithPriority / next / wrapCallback 的栈式恢复
Scheduler 的优先级有两套东西容易混淆:
- 任务的优先级:每个 Task 自带
priorityLevel,在unstable_scheduleCallback时确定,决定timeout与expirationTime(§5、§6)。 - 上下文的优先级:一个模块级变量
currentPriorityLevel(Scheduler.js:86),它决定“此刻如果没有显式传优先级,新的工作该算哪一档”。
前者是“数据”,后者是“环境”。本节讲的是后者,由四个 API 共同维护:unstable_runWithPriority、unstable_next、unstable_wrapCallback、unstable_getCurrentPriorityLevel。
12.1 currentPriorityLevel 的语义与初值
// packages/scheduler/src/forks/Scheduler.js:85-86
var currentTask = null;
var currentPriorityLevel: PriorityLevel = NormalPriority;
- 初值是
NormalPriority(3),即“没有特别指定时,一律按普通优先级”。 - 它不是某个任务的优先级,而是“当前执行栈所处的位置该按什么优先级看待新工作”。典型用途:一个高优事件处理函数里再
setState,新更新应该继承高优;一个后台任务里再派活儿,应该保持低优。 - 它是栈式(stack-like)语义:进入一段作用域时保存旧值、写入新值,退出时无条件恢复旧值(
finally)。这保证了“嵌套调用后,上下文不会泄漏到外部”。
12.2 unstable_runWithPriority:临时切换优先级
// packages/scheduler/src/forks/Scheduler.js:268-291
function unstable_runWithPriority<T>(
priorityLevel: PriorityLevel,
eventHandler: () => T,
): T {
switch (priorityLevel) {
case ImmediatePriority:
case UserBlockingPriority:
case NormalPriority:
case LowPriority:
case IdlePriority:
break;
default:
priorityLevel = NormalPriority;
}
var previousPriorityLevel = currentPriorityLevel;
currentPriorityLevel = priorityLevel;
try {
return eventHandler();
} finally {
currentPriorityLevel = previousPriorityLevel;
}
}
逐行讲解:
- 先做优先级合法性校验(
switch ... default):只有五个合法档位之一才保留,其余(包括NoPriority=0与任意非法数字)一律降级为NormalPriority。这与unstable_scheduleCallback的default分支一致——Scheduler 对外部传入的优先级永远不信任。 previousPriorityLevel = currentPriorityLevel:保存旧值(栈式保存的关键)。currentPriorityLevel = priorityLevel:写入新值。try { return eventHandler(); } finally { currentPriorityLevel = previousPriorityLevel; }:用finally保证即使eventHandler抛错也会恢复上下文。这是整个 Scheduler 里反复出现的“保存—写入—finally恢复”范式(flushWork、workLoop里都能见到)。- 返回值
T直接透传,不吞异常,不包装。
语义:在 eventHandler 执行期间,currentPriorityLevel 被“提升/压低”到指定档位;期间若有“读取当前优先级”的逻辑(如某些库用 getCurrentPriorityLevel() 决定新工作优先级),会看到这个新值;eventHandler 一返回,立刻复原。
12.3 unstable_next:把“后续工作”降级到 Normal
unstable_next(Scheduler.js:293-316)与 runWithPriority 的差别只在“新优先级怎么算”:它先 switch (currentPriorityLevel)——若当前是 Immediate / UserBlocking / Normal(≥Normal 的高优),一律降到 NormalPriority;default(低于 Normal 的 Low / Idle)则保持当前档位不动。随后同样是“保存旧值 → 写入新值 → try 执行 → finally 恢复”。
注释用词很妙:“Shift down to normal priority”(降档到 Normal)与“Anything lower than normal priority should remain at the current level”(比 Normal 更低的一律维持原样)。规则的设计意图是:“紧接着这次高优工作之后要做的事”,不该继续霸占高优,但也绝不该被抬升。 例如一次用户输入(UserBlocking)触发了 A 更新,你在 A 的处理里“顺手”做 B(不紧急):用 next 包住 B,B 就被降成 Normal——它不会跟 A 抢 UserBlocking 的过期窗口,也不会因为“从高优环境里来”而被误当成高优;反过来当前已是 Idle 时 next 保持 Idle,不会把纯后台任务“升级”去打扰用户。
12.4 unstable_wrapCallback:捕获“父优先级”的闭包
unstable_wrapCallback(Scheduler.js:318-333)在调用它的那一刻执行 var parentPriorityLevel = currentPriorityLevel; 把当前优先级用闭包“冻住”,然后返回一个新函数:新函数在将来每次被调用时把 currentPriorityLevel 设为 parentPriorityLevel,try { return callback.apply(this, arguments); },finally 再恢复。
两个要点:
- 它没有调用
unstable_runWithPriority,而是把那段逻辑内联了一遍——注释点明“This is a fork of runWithPriority, inlined for performance.”。原因很实在:wrapCallback常用于把回调交给外部宿主(setTimeout、事件监听器),可能被高频触发,多一层函数调用就是多一点开销,复制 8 行代码换掉一层间接调用是划算的。 callback.apply(this, arguments):保留this与全部实参,不改变被包装函数的调用语义。
语义差异一句话总结:
| API | 读取优先级的时机 | 写入优先级的时机 | 典型场景 |
|---|---|---|---|
runWithPriority | 调用者立即指定 | 立即(同步作用域内) | 在已知优先级的作用域里执行一段代码 |
next | 调用时读当前值并降档 | 立即 | “这次之后顺手做的事”降级 |
wrapCallback | 包装时读一次 | 将来每次被调用时 | 把一个回调“带着优先级”交给异步宿主 |
12.5 与 workLoop / flushWork 的交互:谁在什么时候改 currentPriorityLevel
currentPriorityLevel 会被模块内多处读写,顺序必须理清:flushWork 进入时保存 previousPriorityLevel = currentPriorityLevel(Scheduler.js:160)、退出时在 finally 恢复(183);workLoop 每执行一个 callback 前执行 currentPriorityLevel = currentTask.priorityLevel(210)——这行至关重要,它让“正在执行的那个任务的优先级”成为该 callback 内部的上下文,于是 callback 里若再 scheduleCallback 且不显式指定优先级,就会继承当前任务的档位;而 workLoop 执行完一个 callback 后并不还原它,而是在下一轮开头被新任务的 priorityLevel 覆盖,直到回到 flushWork 的 finally 才统一恢复。一句话概括:进入 flushWork 保存一次,workLoop 每轮按当前任务改写,退出 flushWork 恢复一次——中间任何嵌套的 runWithPriority/next/wrapCallback 都在这条主线上做局部保存/恢复。
12.6 React 19 还用这些 API 吗?与一个必踩的反模式
必须澄清一个常见误解:React 19 的主渲染路径已经不依赖 currentPriorityLevel 来决定更新优先级。原因是第 3 章的 Lane 体系已经用位掩码把优先级编码在 update.lane 上,requestUpdateLane(ReactFiberWorkLoop.js:812 起)直接从 context 与 transition 推导 Lane,而不再问 Scheduler“当前是什么优先级”。在 19.3.1 里:packages/react-reconciler/src/Scheduler.js:21-22 仍重导出 unstable_getCurrentPriorityLevel,但 unstable_runWithPriority 在 packages/react-dom/src/ReactDOMFB.js:28 这类入口被标注为 DO NOT USE(“Temporarily exposed to migrate off of Scheduler.runWithPriority.”)。所以它们现在的定位是向后兼容 + 供外部库使用,而非 React 内部核心机制。
再点一个必踩的反模式:runWithPriority 是同步作用域语义(finally 里同步恢复),不能跨越 await / setTimeout 这类异步边界——例如 unstable_runWithPriority(UserBlockingPriority, async () => { await fetch('/api'); /* 此处 currentPriorityLevel 已不是 UserBlocking */ }):eventHandler 一旦返回(对 async 函数而言是在第一个 await 处“返回”一个 Promise),finally 就恢复了旧值。要跨越异步边界传播优先级,必须用 wrapCallback——它在包装时捕获、在将来每次被调用时重新写入。一句话记住:优先级上下文是有作用域的,不是有生命周期的。
13. 时间切片与 React 渲染的对接:从 setState 到 shouldYield
前 12 节讲的是 Scheduler 这台“引擎”自身的构造。但引擎要驱动的是 React 的渲染——谁把 root 的渲染排进 Scheduler?谁决定用哪个优先级?谁在 Fiber 单元之间问 shouldYield? 这些都在 reconciler 侧。本节把 packages/react-reconciler/src/ReactFiberRootScheduler.js、ReactFiberWorkLoop.js、ReactEventPriorities.js 与 Scheduler 的连接点逐一钉死。
13.1 桥接层:packages/react-reconciler/src/Scheduler.js
reconciler 不直接 import scheduler,而是经过一层极薄的 ESM 包装(packages/react-reconciler/src/Scheduler.js:14-34):import * as Scheduler from 'scheduler' 之后,把 unstable_scheduleCallback/unstable_cancelCallback/unstable_shouldYield/unstable_requestPaint/unstable_now/unstable_getCurrentPriorityLevel 与五个优先级常量逐一 export const xxx = Scheduler.unstable_xxx。
它存在的原因,注释写得很清楚:“…we're intentionally not using named imports because Rollup would use dynamic dispatch for CommonJS interop named imports.”——故意不用具名导入,因为 Rollup 在处理 CJS 互操作时为具名导入生成动态派发(每次访问走一层属性查找),而 import * as Scheduler + 手动解构是静态绑定,更快。
记住这几个绑定:shouldYield、requestPaint、now 全部从这里进 reconciler。Scheduler 的时钟、让出、绘制请求,就是通过这三个常量被注入 React 的。
13.2 从 setState 到 scheduleCallback:三跳
一次 setState 最终触发调度,链路是:
flowchart LR
A["setState → requestUpdateLane"] --> B["enqueueConcurrentHookUpdate"]
B --> C["scheduleUpdateOnFiber"]
C --> D["ensureRootIsScheduled(root)"]
D --> E["ensureScheduleIsScheduled()"]
E --> F["scheduleImmediateRootScheduleTask()"]
F --> G["scheduleMicrotask(processRootScheduleInMicrotask)"]
G --> H["scheduleTaskForRootDuringMicrotask(root)"]
H --> I["Scheduler.unstable_scheduleCallback(pri, performWorkOnRootViaSchedulerTask)"]
第一跳,ensureRootIsScheduled(ReactFiberRootScheduler.js:116-152):若 root 已是 lastScheduledRoot 或 root.next !== null 则走 fast path(已在调度中);否则把它接到 lastScheduledRoot.next 尾部(或作为首个 root 同时赋给 firstScheduledRoot/lastScheduledRoot),然后置 mightHavePendingSyncWork = true 并调 ensureScheduleIsScheduled()。
注释点明:它只做两件事——把 root 加进“待调度 root 链表”,并保证有一个 microtask 待处理。真正“选优先级、调 scheduleCallback”的逻辑都不在这里,而在 scheduleTaskForRootDuringMicrotask。这是“登记”与“决策”刻意分离。
第二个关键点是那个链表:firstScheduledRoot / lastScheduledRoot(ReactFiberRootScheduler.js:99-100)是一条单向链表,用 root.next 串联。注释说:“In an idiomatic app, there's only a single root, but we do support multi root apps, hence this extra complexity. But this module is optimized for the single root case.”(惯常应用只有一个 root,但为支持多 root 才引入这份复杂度,代码为单 root 情况做了优化)。
第二跳,scheduleImmediateRootScheduleTask(ReactFiberRootScheduler.js:650-696)。它默认用微任务,而不是直接调 Scheduler:supportsMicrotasks 为真时调 scheduleMicrotask(() => { ... processRootScheduleInMicrotask(); });否则退回 Scheduler_scheduleCallback(ImmediateSchedulerPriority, processRootScheduleInImmediateTask)。这里有两个设计要点:
- 默认微任务、不用 Scheduler:调度决策本身不经过 Scheduler 的
NormalPriority任务,而是直接在 microtask 里跑。因为“决定该排什么任务”是个 O(root) 的纯计算,不需要时间切片,也不该占用 Scheduler 的优先级队列。 - Safari 兼容护栏:Safari 里 append 一个 iframe 会强制唤醒微任务,可能让这个微任务在 render/commit 中间运行。React 检测到
executionContext & (RenderContext | CommitContext)时,故意改用 Scheduler 的ImmediatePriority宏任务(注释坦言“This is wrong semantically but it prevents an infinite loop. The bug is Safari's, not ours.”)。这条“降级为宏任务”的兜底,是 Scheduler 在 React 里承担的第一个非调度职责:当微任务时机不可信时,用宏任务错开一次。
13.3 scheduleTaskForRootDuringMicrotask:按 lane 选 Scheduler 优先级
这是 §13 的核心函数(ReactFiberRootScheduler.js:384-509)。它做四件事:
(1) 先标记饥饿 lane:markStarvedLanesAsExpired(root, currentTime);(ReactFiberRootScheduler.js:397)。这与 §5.3 讲的“过期即不让出”哲学完全同构,但作用在 Lane 层面:markStarvedLanesAsExpired(ReactFiberLane.js:541-588)遍历 pendingLanes,若某 lane 的 expirationTime <= currentTime,就把它或进 root.expiredLanes(ReactFiberLane.js:581-584)。源码 TODO 坦言:“This gets called every time we yield.”——它每次让出都会跑一遍,是让出路径上的已知热函数。
(2) 取下一个要处理的 lane(ReactFiberRootScheduler.js:406-417),用 getNextLanes(ReactFiberLane.js:249)。
(3) 若没活就取消旧任务(ReactFiberRootScheduler.js:419-439,见 §11.5)。
(4) 若含同步 lane,则取消 Scheduler 任务、留给 microtask 末尾 flush(ReactFiberRootScheduler.js:442-456):includesSyncLane(nextLanes) && !checkIfRootIsPrerendering(root, nextLanes) 时,取消 existingCallbackNode、把 root.callbackPriority 设为 SyncLane、root.callbackNode = null 并 return SyncLane。同步工作(SyncLane)根本不进 Scheduler——它会在 processRootScheduleInMicrotask 末尾由 flushSyncWorkAcrossRoots_impl 直接同步跑完(ReactFiberRootScheduler.js:339-341)。这印证了 §7.4:React 19 里“同步”走微任务、“可中断并发”才走 Scheduler。
(5) 否则:把 nextLanes 折算成 Scheduler 优先级并排任务(ReactFiberRootScheduler.js:457-508)。源码用一个 switch (lanesToEventPriority(nextLanes)):DiscreteEventPriority/ContinuousEventPriority → UserBlockingSchedulerPriority,DefaultEventPriority → NormalSchedulerPriority,IdleEventPriority → IdleSchedulerPriority,default → NormalSchedulerPriority(注释明说 “Scheduler does have an ImmediatePriority, but now that we use microtasks for sync work we no longer use that.”)。随后 const newCallbackNode = scheduleCallback(schedulerPriorityLevel, performWorkOnRootViaSchedulerTask.bind(null, root)); 并写入 root.callbackPriority / root.callbackNode 后返回 newCallbackPriority。
注意几点:
- 映射是“事件优先级 → Scheduler 优先级”,不是“Lane → Scheduler 优先级”,中间隔了一层
lanesToEventPriority(§13.4)。 Discrete与Continuous都映射到UserBlockingSchedulerPriority:Scheduler 只有 5 档而事件层有 4 档,所以离散与连续输入在 Scheduler 层被合并为同一档(都是 250ms 过期)。Immediate现在只用于 Safari 兜底与act,正常并发渲染不再用——这是 React 19 与旧版 Scheduler 用法的显著差异。scheduleCallback用的是模块内的包装(ReactFiberRootScheduler.js:626-639),act环境下会走actQueue(§11.5)。- 优先级未变则复用旧任务(
ReactFiberRootScheduler.js:462-474):若newCallbackPriority === existingCallbackPriority且不是act特例,直接return,不重新排任务——避免“同一优先级反复排/取消”的关键优化。
13.4 lanesToEventPriority 折算与“压缩损耗”
lanesToEventPriority(ReactEventPriorities.js:55-67)取 getHighestPriorityLane(lanes)(ReactFiberLane.js:756)即最低位(最优先)的 lane,再自上而下判定落到 Discrete / Continuous / Default / Idle 四档之一。这是有损压缩:Lane 有 31 条而 Scheduler 优先级只有 5 档,expirationTime 只表达粗糙的时间压力(250/5000/10000ms),精细优先级仍由 Lane 决定;这也解释了为什么 getNextLanes 要调用两次(ReactFiberRootScheduler.js:413 与 575):Scheduler 会把多个 callback 合进同一个宏任务、中途不让出微任务,同一浏览器任务里可能已有更新的 lane 被排入(源码 TODO 见 561-570)。
13.5 performWorkOnRootViaSchedulerTask:Scheduler 任务的真实内容
这个函数是 Scheduler 执行的 callback,也是“续体(continuation)”机制的发出者(ReactFiberRootScheduler.js:513-606)。它的收尾是:const forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout; performWorkOnRoot(root, lanes, forceSync);,然后 scheduleTaskForRootDuringMicrotask(root, now()),最后 if (root.callbackNode != null && root.callbackNode === originalCallbackNode) return performWorkOnRootViaSchedulerTask.bind(null, root); return null;。
三个关键点:
forceSync = didTimeout:didTimeout就是 §8.2.3 里didUserCallbackTimeout = currentTask.expirationTime <= currentTime传进来的布尔值——Scheduler 把“你已过期”告诉 React,React 据此强制同步渲染(forceSync传给performWorkOnRoot,最终影响 §13.6 的shouldTimeSlice)。因为disableSchedulerTimeoutInWorkLoop = false(shared/ReactFeatureFlags.js:36),所以forceSync就等于didTimeout。这就是“过期任务不让出”从 Scheduler 传导到 Fiber 的通道。return performWorkOnRootViaSchedulerTask.bind(null, root):返回一个函数,正好命中 §8.2.4 的续体分支——Scheduler 会把它写回currentTask.callback并立即让出。React 用“返回续体”来表达“我这个 root 这一片干完了,但还有活”。return null:任务作废(root.callbackNode已换或已清),让 Scheduler 走“正常完成”分支把僵尸节点 pop 掉。
13.6 shouldYield 与 now 的注入:双层让出
Fiber 的并发渲染循环是 workLoopConcurrentByScheduler(ReactFiberWorkLoop.js:3072-3079)——while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); },注释即 “Perform work until Scheduler asks us to yield”。其中的 shouldYield 就是 Scheduler.unstable_shouldYield(即 Scheduler 的 shouldYieldToHost,§9.1)。这就是“Fiber 单元级让出”接入 Scheduler 的唯一接口:performUnitOfWork 每处理完一个 Fiber 单元(一个组件、一个 fragment 等)后,循环条件重新问一次 shouldYield();而 shouldYield() 内部比较 getCurrentTime() - startTime >= frameInterval(5ms)——这个 startTime 是 §7.2 里 performWorkUntilDeadline 进入时设的同一个时钟。
于是“时间切片”真正是双层的:任务级——Scheduler workLoop(§8.2.2),粒度是一个 Scheduler Task,条件 expirationTime > now && shouldYieldToHost();单元级——reconciler workLoopConcurrentByScheduler,粒度是一个 Fiber 单元,条件 shouldYield() 为真。
两者共享同一个 frameInterval 与 startTime,所以“5ms 不让出”在两层上一致。也正因如此,shouldYieldToHost 的“理由一(needsPaint 优先让出)”能让渲染中途就停下来把已完成的 DOM 变更画出去——requestPaint() 被调用的地方正是 commit 完成后(ReactFiberWorkLoop.js:4187-4189,注释即“Tell Scheduler to yield at the end of the frame, so the browser has an opportunity to paint.”)。
还有一个 now 的注入点:getCurrentTime(ReactFiberWorkLoop.js:808-810)直接 return now(),而 now 就是 Scheduler.unstable_now。这保证 React 与 Scheduler 用的是同一个单调时钟,不会因为 Date.now() 与 performance.now() 混用而产生比较误差。
13.7 “是否时间切片”:shouldTimeSlice
Scheduler 决定“要不要让出”,React 还有一个自己的开关决定“要不要走可中断的并发循环”(ReactFiberWorkLoop.js:1165-1183):shouldTimeSlice = (!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes)) || checkIfRootIsPrerendering(root, lanes),为真走 renderRootConcurrent,否则走 renderRootSync。四个否定项的含义:本次 Scheduler 任务已过期(didTimeout)不平分;lane 含需要尽快完成的阻塞型(includesBlockingLane,ReactFiberLane.js:684)不平分;root 已有过期 lane(includesExpiredLane,696)不平分以防饥饿;而预渲染即使同步也要用并发循环(“so that prerendering never blocks the main thread”)。结论:“时间切片”是 Scheduler 与 React 的双方共同决策——Scheduler 用 expirationTime 决定要不要让,React 用 shouldTimeSlice 决定要不要采用可中断循环,两者都对“过期”敏感,方向一致。
13.8 多 root 的串行调度
processRootScheduleInMicrotask(ReactFiberRootScheduler.js:259-348)遍历 firstScheduledRoot → root.next 这条链表,对每个 root 调 scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js:289-332):若返回 NoLane(该 root 无活了)就把 root.next 置 null 并从链表中摘除(prev.next = next,必要时更新 firstScheduledRoot / lastScheduledRoot);否则保留在链表中,并在含同步 lane 时置 mightHavePendingSyncWork = true。
关键不变式(注释原话“this microtask is the only place we do this”):
root 可以在任何地方被加入调度链表,但只能在
processRootScheduleInMicrotask这一个地方被移除。
这避免了并发修改链表导致的微妙重入 bug。而“多 root 如何被串行调度”的答案是:
- 每个 root 各自得到一个 Scheduler Task(
root.callbackNode),它们在同一优先级下按taskQueue的expirationTime+id排序(§4.2),以 FIFO 顺序串行执行; - 每个任务结束时,
performWorkOnRootViaSchedulerTask会再调一次scheduleTaskForRootDuringMicrotask(ReactFiberRootScheduler.js:599)重排自己这个 root——“多 root 之间的前进”是靠各自续排实现的,而非中央循环轮流推进; - 一个 root 让出时,Scheduler 的
workLoop会继续取堆顶的下一个任务——可能正是另一个 root 的任务,于是两个 root 的时间片被交错切分。这就是“多 root 在单线程上协作式并行”的真相。
13.9 完整对接链路与本章最核心的结论
全链路一句话:setState → ensureRootIsScheduled → scheduleMicrotask → processRootScheduleInMicrotask(标记饥饿 lane + 取 lane)→ lanesToEventPriority → scheduleCallback(UserBlocking) → MessageChannel → performWorkUntilDeadline → performWorkOnRootViaSchedulerTask(didTimeout=false) → performWorkOnRoot → 每个 Fiber 单元问 shouldYield() → 返回续体让出 → 浏览器处理输入/绘制 → 续体再进 → commit 后 requestPaint() → shouldYield() 因 needsPaint 为真 → 返回 null 结束。你应能回答本章最核心的问题:Scheduler 与 React 之间只有四个契约——scheduleCallback(排任务)、shouldYield(问让出)、now(共享时钟)、requestPaint(请求绘制);其余全部由 React 自己在 Lane 层完成。
14. 其它 fork、测试模拟与可观测性
14.1 SchedulerMock:把时间与让出交给测试控制
packages/scheduler/src/forks/SchedulerMock.js 是 scheduler/unstable_mock 的实现(见 packages/scheduler/package.json 的 files)。它保留与真实实现几乎相同的任务模型(同样的 Task 字段与 taskQueue/timerQueue、同样的 advanceTimers),但把三个“宿主”换成可编程桩:时间由 currentMockTime 提供、用 unstable_advanceTime(ms) 手动推进(480-482, 641-654),而非 performance.now()(Scheduler.js:59-71);唤醒只把回调存进 scheduledCallback(451-453),由 flush* 系列显式驱动,而非 MessageChannel/setImmediate/setTimeout;让出判据改成“期望让出次数 / shouldYieldForPaint && needsPaint”(465-478),而非 5ms 时钟(Scheduler.js:459-472)。另有 workLoop 签名多出 hasTimeRemaining 参数(193)、requestHostCallback 只存不跑、forceFrameRate 为 no-op(484-486)。
workLoop 的让出判据在 mock 里被彻底改写(SchedulerMock.js:198-204):
// packages/scheduler/src/forks/SchedulerMock.js:198-204
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
即:“没有剩余时间(!hasTimeRemaining)”或“达到期望让出次数” 就 break。测试用 flush* 系列传入 hasTimeRemaining,从而精确控制“在第几个 yield 处停下”。
面向测试的核心导出(SchedulerMock.js:504-687):
unstable_flushAllWithoutAsserting()(575-599):反复调用scheduledCallback直到hasMoreWork为假;返回false表示“本来就没活”、true表示“真干了活”——act/ReactNoop靠它判断“是否还有未 flush 的工作”。unstable_flushNumberOfYields(count)(504-526)与unstable_flushUntilNextPaint()(528-552):分别按“期望让出次数”和“模拟requestPaint()前的让出”精确停在某个 yield 点,配合unstable_clearLog()断言执行顺序。unstable_flushExpired()(558-573):用scheduledCallback(false, currentMockTime)调一次,hasTimeRemaining=false会让workLoop把未过期但已无剩余时间的任务也跑完,专门测“过期不让出”。unstable_advanceTime(ms)(641-654):推进虚拟时间,越过timeoutTime时触发scheduledTimeout(即handleTimeout),可复现地驱动延时任务迁移;unstable_setDisableYieldValue(99-101)则在 StrictMode 第二遍渲染里关闭 yield 记录,避免重复打点污染断言。
这套设计的价值在于:它把“时间”和“宿主”两个不确定源彻底参数化,让“5ms 让出”“延时任务到点”“过期不让出”这些行为在单元测试里 100% 可复现——这是 React 团队能对调度语义写大量精确测试的基础。
14.2 其它 fork:SchedulerPostTask 与 SchedulerNative
packages/scheduler/src/forks/SchedulerPostTask.js(unstable_post_task):改用 Chrome 实验性的scheduler.postTask()。它把 5 档优先级压成三档——Immediate/UserBlocking→'user-blocking'、Low/Normal→'user-visible'、Idle→'background'(SchedulerPostTask.js:79-95);用TaskController承载取消(unstable_cancelCallback直接controller.abort(),177-180);续体靠scheduler.yield()(若存在)或再postTask实现(144-153)。它的unstable_requestPaint是空实现(65-67),因为“反正每帧都让出”;yieldInterval固定为 5(55)。packages/scheduler/src/forks/SchedulerNative.js:面向 React Native 的 fork,宿主换成 RN 的定时/帧机制。- 三个 fork 与 DOM 主 fork 共享同一份
SchedulerMinHeap.js与SchedulerPriorities.js——任务模型与堆排序统一,只有宿主原语可替换,这正是 Scheduler 能移植到 test / RN / Chrome 实验环境的原因。
14.3 可观测性:profiling 事件日志与 React Performance Track
Scheduler 自己有可选的 profiling 埋点(packages/scheduler/src/SchedulerProfiling.js),由 enableProfiling 控制(默认 false,SchedulerFeatureFlags.js:10)。它把事件写进一个 Int32Array 日志(SchedulerProfiling.js:17-23, 34-57),事件类型是一组整型常量(SchedulerProfiling.js:25-32):TaskStartEvent=1、TaskCompleteEvent=2、TaskErrorEvent=3、TaskCancelEvent=4、TaskRunEvent=5、TaskYieldEvent=6、SchedulerSuspendEvent=7、SchedulerResumeEvent=8。
每次打点写入 [事件类型, 微秒时间, 任务 id, ...](例如 markTaskStart 写 [TaskStartEvent, ms * 1000, task.id, task.priorityLevel],SchedulerProfiling.js:89)。enableProfiling 为真时,unstable_Profiling 会暴露 startLoggingProfilingEvents / stopLoggingProfilingEvents(Scheduler.js:604-613),把日志 buffer 交给 React DevTools。这就是 DevTools “Scheduler” 面板能看到任务开始/运行/让出/完成时间线的数据来源。
React 侧还有更现代的时间线通道:enableSchedulingProfiler(shared/ReactFeatureFlags.js:257-258,仅 __PROFILE__ 且未启用新 track 时)打 performance.mark/measure;enableComponentPerformanceTrack 改用 console.timeStamp + performance.measure,把渲染画进 Chrome DevTools 的 Scheduler ⚛ 轨道组(ReactFiberPerformanceTrack.js:52-53 定义 COMPONENTS_TRACK = 'Components ⚛' 与 LANES_TRACK_GROUP = 'Scheduler ⚛'),并按 lane 分成 Blocking / Gesture / Transition / Suspense / Idle 五条轨道(61-110);让出时长有专门埋点 logYieldTime(startTime, endTime)(583)。另需纠正一个过时知识点:__interactionsRef / interactions 追踪 API 在 React 19 已从代码库移除(packages/ 下已搜不到该符号),其职责被上述 Performance Track 与 Profiler 组件取代。
15. 源码索引表
本章涉及的关键文件、函数与行号速查(行号基于 React 19.3.1 仓库)。行号用于定位阅读,引用时请以仓库当前内容为准。
| 文件 | 关键符号 | 行号 | 本章位置 |
|---|---|---|---|
packages/scheduler/src/forks/Scheduler.js | Task 类型(opaque)/ 两个堆 / 全局状态(currentTask/currentPriorityLevel/isPerformingWork 等) | 49-94 | §3 |
| 〃 | getCurrentTime / maxSigned31BitInt / frameInterval / startTime / taskTimeoutID / isMessageLoopRunning | 59-76 / 449-457 | §5.4, §9.2, §13.6 |
| 〃 | advanceTimers / handleTimeout | 103-143 | §10.1-10.2 |
| 〃 | flushWork / workLoop | 145-266 | §8 |
| 〃 | unstable_runWithPriority / unstable_next / unstable_wrapCallback / unstable_getCurrentPriorityLevel | 268-333 / 445-447 | §12 |
| 〃 | unstable_scheduleCallback / unstable_cancelCallback | 335-443 | §6, §11.1 |
| 〃 | shouldYieldToHost(导出 unstable_shouldYield)/ requestPaint / forceFrameRate / performWorkUntilDeadline | 459-528 | §9, §7.2 |
| 〃 | schedulePerformWorkUntilDeadline(三分支)/ requestHostCallback / requestHostTimeout / cancelHostTimeout / 导出表 / unstable_Profiling | 530-613 | §7.3, §10.3, §14.3 |
packages/scheduler/src/SchedulerMinHeap.js | push / peek / pop / siftUp / siftDown / compare | 17-95 | §4 |
packages/scheduler/src/SchedulerPriorities.js | 五个优先级常量 | 10-18 | §5.1 |
packages/scheduler/src/SchedulerFeatureFlags.js | frameYieldMs / 三个 timeout / enableRequestPaint / enableAlwaysYieldScheduler | 10-18 | §5.2, §5.5 |
packages/scheduler/src/forks/SchedulerMock.js | currentMockTime / workLoop(hasTimeRemaining,…) / flush* 系列 / advanceTime / reset | 81, 193-272, 504-654 | §14.1 |
packages/scheduler/src/SchedulerProfiling.js | 事件常量 / Int32Array 日志 / markTaskStart 等 | 17-32, 34-57, 75-187 | §14.3 |
packages/scheduler/src/forks/SchedulerPostTask.js | postTask fork / 优先级三档映射 / controller.abort() | 55-180 | §14.2 |
packages/react-reconciler/src/Scheduler.js | scheduleCallback/shouldYield/now/requestPaint 重导出 | 16-34 | §13.1 |
packages/react-reconciler/src/ReactFiberRootScheduler.js | firstScheduledRoot / lastScheduledRoot / ensureRootIsScheduled / ensureScheduleIsScheduled | 99-169 | §13.2, §13.8 |
| 〃 | flushSyncWorkAcrossRoots_impl / processRootScheduleInMicrotask / scheduleTaskForRootDuringMicrotask(lane→Scheduler 优先级) | 185-509 | §13.3, §13.8 |
| 〃 | performWorkOnRootViaSchedulerTask(forceSync / 续体)/ scheduleCallback / cancelCallback / fakeActCallbackNode / scheduleImmediateRootScheduleTask | 513-696 | §13.5, §11.5, §13.2 |
packages/react-reconciler/src/ReactFiberWorkLoop.js | getCurrentTime / shouldTimeSlice 判定 / workLoopConcurrent(ByScheduler) / commit 后 requestPaint() | 808-810, 1165-1183, 3055-3079, 4187-4189 | §13.6-13.7 |
packages/react-reconciler/src/ReactEventPriorities.js | lanesToEventPriority | 55-67 | §13.4 |
packages/react-reconciler/src/ReactFiberLane.js | getNextLanes / markStarvedLanesAsExpired / includesBlockingLane / includesExpiredLane / getHighestPriorityLane | 249 / 541-588 / 684 / 696 / 756 | §13.3, §13.7 |
packages/react-reconciler/src/ReactFiberPerformanceTrack.js | LANES_TRACK_GROUP / markAllLanesInOrder / logYieldTime | 53 / 61-110 / 583 | §14.3 |
16. 自测 FAQ(20 条)
Q1. Scheduler 到底解决什么问题?
把 CPU 密集的渲染拆成可中断的时间片,单线程里实现“协作式抢占”:两个最小堆管任务(§3.2)、MessageChannel 宏任务切片(§7.3)、shouldYieldToHost 决定让出(§9.1)。它不解决“渲染慢”,只解决“渲染不可中断”。
Q2. 为什么 taskQueue 和 timerQueue 要分成两个堆?
因为“谁最早到点”(按 startTime)与“谁最该先跑”(按 expirationTime)是两个排序维度,一个堆无法兼顾;迁堆时 advanceTimers 改写 sortIndex(§3.2、§10.1)。
Q3. sortIndex 为什么会被改写?
它在 timerQueue 里等于 startTime,进 taskQueue 时被 advanceTimers 改成 expirationTime(Scheduler.js:113),所以堆的排序键是“动态的”(§3.1、§10.1)。
Q4. compare 为什么是“先 sortIndex 再 id”的双键?
sortIndex 决定优先级顺序;id(自增序号)在 sortIndex 相等时决胜,保证同优先级 FIFO 稳定排序(§4.2)。
Q5. 为什么优先级用“过期时间”而不是优先级数字来排序?
优先级 → timeout → expirationTime,越早过期越靠前;“优先级”与“等得多久”被统一成一个可比较数字,天然饥饿保护(§5.3)。
Q6. maxSigned31BitInt = 1073741823 是干什么的?
Idle 的 timeout,使 expirationTime 大到“永不过期”,只在主线程真空闲时才跑(§5.4)。
Q7. 为什么延时任务用 setTimeout,到期任务用 MessageChannel?
延时任务本就等几百毫秒/几秒,不需绕开 4ms 钳制,后台节流反而省电;到期任务要“尽快回来继续干”,必须绕开 4ms 钳制(§7.3、§10.3)。
Q8. isHostCallbackScheduled 与 isPerformingWork 为什么要两个标志?
前者防“重复向宿主注册消息循环”,后者防“在工作循环内部重入启动新循环”;scheduleCallback 仅当两者都为假才 requestHostCallback(Scheduler.js:420-423,§6、§8.1)。
Q9. 一次 workLoop 里为什么能连续跑多个任务?
while (currentTask !== null) 反复 peek(taskQueue);同一批任务共享一个宏任务,只有“任务未过期且 shouldYieldToHost()”才 break 让出(§8.2)。
Q10. “已过期任务不让出”在代码里是哪一行?
currentTask.expirationTime > currentTime && shouldYieldToHost()(Scheduler.js:199);过期任务的左条件为假,永不 break(§8.2.2)。
Q11. 续体(continuation)是什么?为什么返回函数就让出?
callback 返回函数即“这片干完了但还有后续”;Scheduler 把它写回 currentTask.callback 并立即让出,不管时间片剩多少(Scheduler.js:220-231);React 用 performWorkOnRootViaSchedulerTask.bind(null, root) 触发(§13.5)。
Q12. unstable_cancelCallback 为什么只置 null?
数组二叉堆只能 O(1) 处理堆顶,删除任意中间节点要先 O(n) 扫描,故用惰性删除:标记 + 等它成为堆顶时丢弃(§11.1、§11.2)。
Q13. 取消能中止“正在执行”的任务吗?
不能。workLoop 在调用 callback 前已把原函数存进局部变量,外部再置 null 无效(§11.3);故 React 必须用 root.callbackNode 身份校验兜底(§11.5)。
Q14. 时间切片为什么是 5ms 而不是 16ms?
5ms ≈ 一帧预算的 1/3,给浏览器留 11ms+ 做样式/布局/绘制,又足够大以摊薄宏任务切换开销;它是“连续占用上限”而非“每帧配额”,可用 forceFrameRate 改(§9.2、§9.4)。
Q15. needsPaint / requestPaint 解决什么?
commit 后 React 调 requestPaint() 置位 needsPaint,让 shouldYieldToHost() 在时间片没到也立即让出,把已算好的 DOM 变更尽快画出去(§9.1、§9.3、§13.6)。
Q16. Scheduler 的时钟是哪个?React 用的是同一个吗?
getCurrentTime 优先 performance.now(),否则 Date.now() - initialTime(Scheduler.js:59-71);React 的 getCurrentTime 直接 return now()(ReactFiberWorkLoop.js:808-810),二者共享同一单调时钟(§13.6)。
Q17. React 侧怎么决定某个 root 用哪个 Scheduler 优先级?
getNextLanes 取 lane → lanesToEventPriority 折算事件优先级 → switch 映射:Discrete/Continuous→UserBlocking,Default→Normal,Idle→Idle(ReactFiberRootScheduler.js:480-498);同步 lane 不进 Scheduler(§13.3)。
Q18. 多个 root 是怎么被调度的?
加进 firstScheduledRoot 链表,由 processRootScheduleInMicrotask 逐个排任务;只有这个 microtask 能移除 root;各 root 任务按过期时间 + id 排序,串行但可交错切分(§13.8)。
Q19. SchedulerMock 和真实实现最大的差别?
时间与宿主被参数化:currentMockTime + unstable_advanceTime 控时间,flush* 手动驱动循环,让出判据改成“期望让出次数”,workLoop 多一个 hasTimeRemaining 参数(§14.1)。
Q20. 为什么 Scheduler 用宏任务而不是微任务串联工作? 微任务会在下一个宏任务前被一次性 flush 完,期间浏览器无法渲染/处理输入,等于放弃让出;宏任务之间才有绘制与输入的空隙(§7.4)。
17. 本章要点小结
- 两个堆 + 一条消息循环:
taskQueue(按expirationTime)与timerQueue(按startTime)承载全部状态;requestHostCallback → MessageChannel(或 setImmediate/setTimeout)→ performWorkUntilDeadline → flushWork → workLoop,靠isMessageLoopRunning保证唯一(§3、§7)。 - 5ms 切片与让出三条件:
startTime/frameInterval构成切片时钟;任务未过期、且(时间片耗尽 或 有绘制请求)才让出,过期任务永不让出(§9.2、§8.2.2、§9.1)。 - 优先级即过期时刻:优先级 → timeout →
expirationTime,队列按“谁先过期”排序,天然饥饿保护(§5.3)。 - 取消是惰性的:
cancelCallback只置null,等节点成为堆顶时回收;执行中的任务取消无效,故 React 必须用callbackNode身份校验兜底(§11)。 - React 只借四个契约,宿主可替换:
scheduleCallback/shouldYield/now/requestPaint;优先级语义与车道全在 reconciler 的 Lane 层(§13);DOM / test / RN / postTask 四个 fork 共享同一套任务模型与堆,只有宿主原语与让出判据不同(§14)。