Appearance
第 4.1 节:Scheduler 概览:时间分片的艺术
欢迎来到第四章。在前面的章节中,我们探讨了 React 如何创建更新、构建 Fiber 树以及提交变更。然而,这一切高效运作的背后,离不开一个关键角色——Scheduler(调度器)。
Scheduler 是 React 并发模式的基石。它独立于 React Reconciler 存在(位于 packages/scheduler),其核心职责是:决定何时执行何种任务。它通过一种被称为“时间分片”(Time Slicing)的艺术,确保了即使在进行大规模更新时,应用也能保持对用户输入的流畅响应。
1. 为什么需要 Scheduler?
在并发模式之前,React 的渲染是同步且不可中断的。如果一个组件树非常庞大,那么从 setState 到渲染完成的整个过程可能会耗时数百毫秒。在此期间,浏览器主线程被完全占用,无法响应任何用户交互(如点击、输入),导致页面卡顿。
Scheduler 的诞生就是为了解决这个问题。它将一个漫长的渲染任务分解成许多微小的“工作单元”,并在每个单元执行后,将控制权交还给主线程。这样,主线程就有机会去处理更高优先级的任务,从而实现了“可中断渲染”。
2. Scheduler 的核心数据结构
Scheduler 的实现依赖于两个核心的数据结构,它们都位于 packages/scheduler/src/forks/Scheduler.js 中,并且都是**最小堆(Min Heap)**的实现:
taskQueue:任务队列。存放着所有已经准备好、可以立即执行的任务。任务按照它们的过期时间 (expirationTime) 进行排序,过期时间越早的任务,优先级越高,越先被执行。timerQueue:计时器队列。存放着所有被延迟执行的任务(例如,通过setTimeout调度的任务)。任务按照它们的开始时间 (startTime) 进行排序。
javascript
// packages/scheduler/src/forks/Scheduler.js
// 任务以最小堆的形式存储
var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];
// Task 对象的结构
export opaque type Task = {
id: number,
callback: Callback | null, // 任务要执行的回调函数
priorityLevel: PriorityLevel, // 优先级
startTime: number, // 开始时间
expirationTime: number, // 过期时间
sortIndex: number, // 在堆中的排序索引
};设计思考:为什么使用最小堆?
Scheduler 需要一种能够快速找到“最高优先级”任务的数据结构。最小堆的特性是,堆顶永远是值最小的元素。通过将任务的 expirationTime 作为排序依据,Scheduler 可以在 O(1) 的时间复杂度内获取到最紧急的任务,并在 O(log n) 的时间复杂度内完成任务的插入和删除,这对于高性能调度至关重要。
3. 工作循环与时间分片
Scheduler 的工作循环(workLoop)是时间分片的核心体现。它并非一个永不停止的 while(true) 循环,而是一个“合作式”的循环。
javascript
// packages/scheduler/src/forks/Scheduler.js
function workLoop(initialTime: number) {
let currentTime = initialTime;
currentTask = peek(taskQueue); // 取出最高优先级的任务
while (currentTask !== null) {
// 检查任务是否过期,以及是否应该让出主线程
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// 任务未过期,且时间片已用完,中断循环
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
// ... 执行 callback ...
const continuationCallback = callback(didUserCallbackTimeout);
if (typeof continuationCallback === 'function') {
// 如果 callback 返回了一个新的函数(continuation),
// 说明工作还没做完,将其存回 task,然后中断循环,等待下一次调度
currentTask.callback = continuationCallback;
return true; // 表示还有工作
}
// ...
}
// ...
currentTask = peek(taskQueue); // 取下一个任务
}
if (currentTask !== null) {
return true; // 还有工作
} else {
return false; // 所有工作都完成了
}
}这个循环的关键在于 shouldYieldToHost() 函数。
shouldYieldToHost():时间分片的决策者
shouldYieldToHost 决定了 Scheduler 是否应该暂停工作,将控制权交还给浏览器。
javascript
// packages/scheduler/src/forks/Scheduler.js
// 默认的时间片长度是 5ms
let frameInterval: number = frameYieldMs; // frameYieldMs = 5
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// 时间片还没用完,继续工作
return false;
}
// 时间片已用完,让出主线程
return true;
}默认情况下,一个时间片的长度是 5 毫秒。这意味着 Scheduler 会埋头苦干 5 毫秒,然后无论任务是否完成,都会暂停,检查是否有更高优先级的事件需要处理。这种“走走停停”的模式,保证了主线程不会被长时间阻塞。
4. 宏任务调度:MessageChannel
那么,当 Scheduler 暂停后,它如何在未来的某个时刻恢复工作呢?它利用了浏览器的事件循环机制,特别是宏任务(Macrotask)。
Scheduler 优先使用 MessageChannel 来调度下一次工作。
javascript
// packages/scheduler/src/forks/Scheduler.js
const channel = new MessageChannel();
const port = channel.port2;
// 当 port1 收到消息时,执行 performWorkUntilDeadline
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
// 发送消息,将 performWorkUntilDeadline 添加到宏任务队列
port.postMessage(null);
};performWorkUntilDeadline 内部会调用 workLoop。当 workLoop 因为时间片用完而中断时,如果队列中还有任务,它会再次调用 schedulePerformWorkUntilDeadline,从而将“下半部分”的工作作为一个新的宏任务安排到事件队列的末尾。
设计思考:为什么选择 MessageChannel 而不是 setTimeout(0)?
- 无延迟:
setTimeout(0)在浏览器中存在最小 4ms 的延迟,而MessageChannel的postMessage是目前已知的、能最快将任务添加到宏任务队列的方式,几乎是 0 延迟。 - 优先级:
MessageChannel的回调作为宏任务,其执行时机在浏览器下一次重绘(Repaint)之前,这对于 UI 渲染相关的任务至关重要。
通过这种方式,Scheduler 实现了一个优雅的、与浏览器渲染节奏相协调的调度系统。它既能充分利用 CPU 的空闲时间,又能在需要时及时“让路”,确保了应用的极致性能和用户体验。