跳到主要内容

第 5 章:调度器 Scheduler——谁在安排 React 干活

第 4 章地图的第 ② 站,是"Scheduler 调度"。本章把这一站放大看。

先想一个问题:为什么 React 不自己决定"什么时候开始渲染"?因为渲染的开始时机,跟 React 的组件逻辑无关,它是个纯调度问题:手上有一堆任务,优先级有高有低,主线程只有一个,怎么排?React 把这个问题外包给了独立的 scheduler 包react-reconciler 从它 import scheduleCallback,第 4 章见过)。这个包不依赖 React,别的库也能用。

先记住这一句:Scheduler 就是一个通用的任务调度器,核心三件事:按优先级排队、到时间片就让出、超时了强制执行

一图流:一个任务怎么被调度

排队:五级优先级,算出超时时间

每个任务进来,先算一个过期时间(expirationTime)。unstable_scheduleCallbackScheduler.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

优先级典型场景超时行为
Immediate1同步渲染、onClick立刻(-1)
UserBlocking2输入、滚动配置超时(约 250ms 级)
Normal3普通更新配置超时(约 5s 级)
Low4低优先更新更长超时
Idle5预加载等永不超时

中间三个的超时值是配置好的常量,细节不用记,记住两端就行:Immediate 立刻办,Idle 永远不急

存储:一个小顶堆

任务按 expirationTime 排序,塞进一个最小堆taskQueue)。堆顶永远是"最该先执行"的任务。React 不自己写堆,用的是 scheduler 包里的 SchedulerMinHeap

唤醒:为什么是 MessageChannel

任务入队后,requestHostCallbackScheduler.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 内部是切不开的原子

动手实验

  1. 看任务排队:在 unstable_scheduleCallback(Scheduler.js:327)打断点,点一下按钮。每次 setState 都会在这里排一个任务,观察 priorityLevelexpirationTime 是怎么算出来的。
  2. 感受让出:在 workLoopbreak 那行打断点,渲染一棵很大的树。浏览器空闲时它会让出,主线程的 requestAnimationFrame 仍然能跑,页面不卡。
  3. 对比优先级:同一帧里触发一个 onClick(UserBlocking)和一个普通 setState(Normal),看堆顶谁先执行。

小结

  • Scheduler 是独立于 React 的通用调度器,负责"什么时候干活"。
  • 五级优先级映射成超时时间,Immediate 立刻、Idle 永不
  • 任务按 expirationTime 进小顶堆,堆顶最急。
  • MessageChannel 驱动消息循环,避开 setTimeout 的 4ms 钳制。
  • workLoop 到点让出,callback 返回 continuation 接着干,这是并发渲染的地基。
  • 第一部分到此收尾。第 22 章会看到 React 的 lane 怎么换算到这五级优先级上。