第 5 章:调度器 Scheduler——谁在安排 React 干活
第 4 章地图的第 ② 站,是"Scheduler 调度"。本章把这一站放大看。
先想一个问题:为什么 React 不自己决定"什么时候开始渲染"?因为渲染的开始时机,跟 React 的组件逻辑无关,它是个纯调度问题:手上有一堆任务,优先级有高有低,主线程只有一个,怎么排?React 把这个问题外包给了独立的 scheduler 包(react-reconciler 从它 import scheduleCallback,第 4 章见过)。这个包不依赖 React,别的库也能用。
先记住这一句:Scheduler 就是一个通用的任务调度器,核心三件事:按优先级排队、到时间片就让出、超时了强制执行。
一图流:一个任务怎么被调度
排队:五级优先级,算出超时时间
每个任务进来,先算一个过期时间(expirationTime)。unstable_scheduleCallback(Scheduler.js:327)按优先级定超时:
var timeout;
switch (priorityLevel) {
case ImmediatePriority: timeout = -1; // 立刻过期,马上执行
case UserBlockingPriority: timeout = userBlockingPriorityTimeout; // 用户操作
case IdlePriority: timeout = maxSigned31BitInt; // 永不超时
case LowPriority: timeout = lowPriorityTimeout;
case NormalPriority: default: timeout = normalPriorityTimeout;
}
var expirationTime = startTime + timeout;
优先级一共五级,在 SchedulerPriorities.js:14:
| 优先级 | 值 | 典型场景 | 超时行为 |
|---|---|---|---|
| Immediate | 1 | 同步渲染、onClick | 立刻(-1) |
| UserBlocking | 2 | 输入、滚动 | 配置超时(约 250ms 级) |
| Normal | 3 | 普通更新 | 配置超时(约 5s 级) |
| Low | 4 | 低优先更新 | 更长超时 |
| Idle | 5 | 预加载等 | 永不超时 |
中间三个的超时值是配置好的常量,细节不用记,记住两端就行:Immediate 立刻办,Idle 永远不急。
存储:一个小顶堆
任务按 expirationTime 排序,塞进一个最小堆(taskQueue)。堆顶永远是"最该先执行"的任务。React 不自己写堆,用的是 scheduler 包里的 SchedulerMinHeap。
唤醒:为什么是 MessageChannel
任务入队后,requestHostCallback(Scheduler.js:549)启动消息循环。浏览器环境里,循环的驱动是 MessageChannel,源码注释讲了原因(Scheduler.js:532):
We prefer MessageChannel because of the 4ms setTimeout clamping.
setTimeout 在浏览器里被钳制到最小 4ms,而且它会让出得不够及时;MessageChannel 是宏任务,能在每帧空闲时立刻回馈,调度更顺滑。Node 环境退化为 setImmediate,再退化为 setTimeout。
执行:workLoop 到点就让
循环体是 workLoop。它盯着堆顶任务,问一个关键问题:
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
break; // 没到期 + 该让出了,先把主线程还给浏览器
}
两个条件同时满足才让出。这里最容易反直觉,拆开讲。
expirationTime 是"最迟开工的死线",不是"收工时间"。 它定的是"这条任务再晚就不能拖了",不是"干到这儿就停"。所以"没超时"的意思其实是:这条任务还等得起,现在停一下不会让它迟到。
让出是响应性的让步。 shouldYieldToHost 表示主线程该喘口气了(一帧时间片用完,浏览器要处理输入、画帧)。只要任务还等得起,让步就无害,停一下、下一帧接着干,死线照样赶得上。
反过来就通了:一旦任务超时(expirationTime <= currentTime),条件不成立,就不再让出,硬着头皮跑完。因为已经晚了,再让就真的饿死、永远干不完。宁可牺牲这一刻的响应性,也要把债还上。
一句话:有余量才让得,欠了债就得闷头还。
然后执行堆顶的 callback:
const continuationCallback = callback(didUserCallbackTimeout);
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback; // 返回 continuation = 还没干完
return true; // 立刻让出,下次接着干
}
这里出现了本章最妙的机制:callback 可以返回一个 continuation。React 的 performWorkOnRoot 就是这么干的,干不完就返回"接着干",Scheduler 立刻让出主线程,下一帧接着算。这就是第 4 章"render 可中断"在 Scheduler 这边的落地。
"停下还能接着算"靠两层配合:Scheduler 负责时间片和唤醒,让出后 MessageChannel 在下一帧再次触发消息循环;React 负责可恢复,渲染进度存在 workInProgress 指针里(第 4 章),中途停下不丢现场,continuation 从上次停下的地方继续。React 管"能接着算",Scheduler 管"到点让、下帧来"。
React 怎么对接
React 不直接用这五级优先级,它自己有一套 31 位的 Lane 模型(第 22 章)。中间有个转换:React 把 lane 换算成 Scheduler 的优先级,再用 scheduleCallback 排进去,排到的回调就是第 4 章的 performWorkOnRoot。所以你看到的现象是:React 管"算什么、算什么优先级",Scheduler 管"什么时候算、算多久"。
任务到底是什么:一次渲染,不是一个 fiber
"Scheduler 的任务"在 React 里指什么?一句话:一个被调度的 callback。React 用 scheduleCallback(priority, callback) 排队,渲染时这个 callback 是 performWorkOnRoot,也就是"把整棵树 render 一遍"这件事。
所以任务不是某个 fiber 的副作用。副作用(改 DOM、跑 useEffect)发生在 commit 阶段,commit 一旦开始就同步跑完、不可中断,不走 Scheduler 的让出逻辑。
那"一次渲染"内部呢?它被切成很多工作单元,每个单元恰好是一个 fiber。performUnitOfWork 一次只处理一个 fiber(第 4 章),Scheduler 的时间片检查(now() < yieldAfter)就插在两个 fiber 之间。
万一单个 fiber 很耗时怎么办? 这是可中断渲染的真实边界。JS 是单线程,一个组件函数没跑完,谁也切不走它。所以"让出"只在 fiber 边界生效:单个超慢的组件会一口气吃满时间片、甚至超时,造成卡顿。这调度解决不了,只能靠拆小组件、useMemo、React Compiler 自动 memo。记住这个粒度:可中断的单位是 fiber,fiber 内部是切不开的原子。
动手实验
- 看任务排队:在
unstable_scheduleCallback(Scheduler.js:327)打断点,点一下按钮。每次setState都会在这里排一个任务,观察priorityLevel和expirationTime是怎么算出来的。 - 感受让出:在
workLoop的break那行打断点,渲染一棵很大的树。浏览器空闲时它会让出,主线程的requestAnimationFrame仍然能跑,页面不卡。 - 对比优先级:同一帧里触发一个
onClick(UserBlocking)和一个普通setState(Normal),看堆顶谁先执行。
小结
- Scheduler 是独立于 React 的通用调度器,负责"什么时候干活"。
- 五级优先级映射成超时时间,Immediate 立刻、Idle 永不。
- 任务按 expirationTime 进小顶堆,堆顶最急。
- MessageChannel 驱动消息循环,避开 setTimeout 的 4ms 钳制。
- workLoop 到点让出,callback 返回 continuation 接着干,这是并发渲染的地基。
- 第一部分到此收尾。第 22 章会看到 React 的 lane 怎么换算到这五级优先级上。