前言
在学习 Scheduler 之前,首先要明白 Scheduler 到底做了什么事:多个任务管理、单个任务执行控制(中断和恢复)
学习过程中应该带着这两个疑问去发现、去查找,看源码更是如此,如果只专注细节,不先理清整体脉络,很容易就会迷失在源码的汪洋大海中
概念
首先,先来认识下几个重要概念,React 中的各种概念和数据结构实在是太多了,更要命的是有些函数名和数据字段还重名,这就导致了读着读着容易读岔,进而是 emo、自我怀疑 -- 诶?我真的适合学前端吗?那该死的热情哪去了?没关系,vo50%,保管教会你各个重要函数名,的拼写
一上来需要先知道几个重要主体:
-
React 中的任务:performConcurrentWorkOnRoot,其中包括了我们熟悉的 render 阶段和 commit 阶段,因此该任务也是 React 用于构建和渲染 Fiber 的任务
-
发起调度的入口:scheduleCallback
-
两个任务队列:
- taskQueue:任务队列
- timerQueue:延时任务队列
这里插一嘴,在 Scheduler 中是以 task 作为执行单位,而 task 涉及到两个重要概念:优先级(任务执行顺序)与时间片(任务执行时间)
我们都知道,React 有自己的优先级系统 lane,而 Scheduler 由于被拆分出来独立发包,因此也有自己独自的优先级体系。所以在发起调度时,React 会先进行优先级的转换,也就是将 React 的优先级 Lane 转换为 Scheduler 优先级(lane 和 Scheduler 的优先级这里就不详细介绍了,大家可以自己去学学 React 中的三种优先级:lane、事件优先级和 Scheduler 优先级)
好的,简单了解了优先级之后,我们来看看任务队列中的任务 task 到底是怎样的,以及优先级到底是怎样去决定任务的执行顺序和执行时间的
// 优先级需要转换,同样的,任务也需要转换,React 任务进入调度时会被转换为 task // React 中的任务 performConcurrentWorkOnRoot 会被转换为 Scheduler 中的任务 Task var newTask: Task = { id: taskIdCounter++, // callback 函数即 performConcurrentWorkOnRoot 函数 callback, // 调度优先级 priorityLevel, // 开始时间 startTime = currentTime + delay || currentTime startTime, // 过期时间 expirationTime = startTime + timeout expirationTime, // 在任务队列中排序的依据,由开始时间和过期时间决定 // 而 timeout 又根据优先级生成,这也就是为什么说优先级决定了任务的执行顺序和执行时间 sortIndex: startTime || expirationTime, }; -
调度者:requestHostCallback(本质是 schedulePerformWorkUntilDeadline),通过 MessageChannel(重点!!!是一个宏任务)调用 port.postMessage 来安排调度
-
调度者和执行者之间的中介:否则进行双方间的消息传送
-
执行者:flushWork,调用 workLoop 去执行完 taskQueue 中的所有任务
好了,主角悉数登场,上面有一个重点:调度者发起的调度并不是一个同步任务
举一个例子来说明调度的本质:
浏览器同样是以任务队列的方式来执行事件,假设有一个耗时 10s 的计算事件,首先这个事件会先进入队列,然后浏览器再把他取出来执行,如果用户同时间触发了滚动事件,该滚动事件也会进入队列,但由于前面已经有任务在执行了,所以这个滚动事件不会立马执行
问题来了,当用户触发滚动事件后,不可能等待 10s 之后再得到响应,所以为了优化用户体验,解决掉帧问题,需要进行调度
这时就有了时间片的概念
比如有一个 1s 的时间片(实际上时间片的时间是不固定的,由设备 fps 决定),当 React 的调度器在经过 1 秒之后,发现当前这个计算任务还未完成,说明他已经超出了时间片限制了,此时就会把剩余的 9s 的计算内容再作为一个宏任务添加进任务队列末尾,然后先执行任务队列中的首个任务也就是用户的滚动事件(滚动任务的优先级比计算任务高,所以才会在时间片到期时让计算任务中断,如果把滚动任务换成一个无关紧要的任务,此时即使时间片到期,React 的调度器会重新分配时间片,然后继续执行计算任务,所以这是优先级的另一个作用:实现任务排序,也就是任务插队)
这就是调度的本质:控制任务的中断与恢复,防止单个任务执行一直占据那仅有的一个线程
这里可能会有疑问:怎么实现的任务中断与恢复?恢复时怎么只去执行这个任务中还未执行的那部分?
别急,我们先跑下整体流程,再在源码中去找答案
流程
-
首先,触发事件产生了React 任务,此时会通过调度入口发起调度,并做了两件事:
-
将「React 任务」转换为「task 任务」
-
将 React 任务的优先级「Lane」转换为 「task 任务的优先级」
-
-
之后,将该任务添加进任务队列中
-
如果当前时间 >= 任务开始时间,说明过期,放入 taskQueue(放入之后,根据过期时间重新排序 taskQueue);否则放入 timerQueue(放入之后,根据开始时间重新排序 timerQueue)
-
两个任务队列本质都是一个数组形式的小顶堆
-
-
接着,通过调度者发起调度
调度者会通过 postMessage 去通知中介,再让中介通知执行者开始循环执行 taskQueue 中的所有任务
-
然后执行者会开始执行 taskQueue 中的所有任务
-
每一个任务都有对应的时间片,当某个任务超过时间片限制还未执行完成时,则会中断该任务,并且返回true,中介检测到执行者返回true,说明中断的原因不是因为所有任务都执行完成,而是由于任务中断
这里说一下:返回true说明是任务中断,返回false说明是任务执行完成
-
此时中介会告知调度者当前任务中断,调度者收到该消息后,会再次告知中介去调度一个新的执行者继续执行未完成的部分
继续执行未完成的部分是指作为一个宏任务去执行,可以联系上文说的调度的本质
-
-
最后,当某一个任务执行完成时,这个任务就会被从 taskQueue 中弹出
以上就是主要流程,也许你还会有很多疑问,没关系,有疑问才是好事
下面就带着疑问,以及对主要流程的了解,让我们手写一个 Sceduler 吧
问题
任务的中断与恢复是如何实现的
你可能会有以下疑问:任务是如何中断与恢复的呢?多个任务的场景下是如何进行的?一个大任务又是怎么处理划分成多个小任务的?任务中又产生了新任务该怎么办?
没关系,我们直接来手写一个调度器,其中就会有问题的答案
// 创建一个 React 任务,由 dispatchEvent 函数触发产生
const performConcurrentWorkOnRoot = () => {
// render 阶段,包含了 beginWork、completeWork 阶段
renderRootConcurrent();
// commit 阶段,包含了 beforeMutation、Mutation 以及 Layout 等阶段
finishConcurrentRender();
}
// 创建两个任务队列
const taskQueue = [];
const timerQueue = [];
// 初始化开始时间
let startTime;
// 1.首先,发起调度,将 React 任务和优先级转换为 Scheduler 中的任务和优先级
const newCallbackNode = scheduleCallback(
// 优先级
schedulerPriorityLevel,
// React 任务,即构建 Fiber 和渲染:render 阶段和 commit 阶段
performConcurrentWorkOnRoot.bind(null, root),
);
// 该函数用于检查 timerQueue 中第一个任务是否过期
// 过期则加入到 taskQueue 并发起调度
function advanceTimers(currentTime) {
// 取出 timerQueue 中的第一个任务,判断是否过期
let timer = timerQueue[0];
while(timer !== null) {
if(timer.startTime <= currentTime) {
// 过期则放入 taskQueue
const timerTask = timerQueue.shift();
taskQueue.push(timerTask);
// 排序
taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);
timerQueue.sort((a, b) => a.startTime - b.startTime);
// 发起调度
requestHostCallback();
} else {
// 未过期则继续观察
return;
}
timer = timerQueue[0];
}
}
// 调度的入口
function scheduleCallback(priorityLevel, callback) {
// 记录当前时间
const currentTime = performance.now();
// 记录调度开始时间,该开始时间也是任务的开始时间
const startTime = currentTime
// 在 scheduleCallback 转换优先级和任务
var task = {
// 详细细节看上文,这里主要是将 performConcurrentWorkOnRoot 挂载到 callback 上
callback,
startTime,
// ...
}
// 2.将任务放入队列中
if (startTime > currentTime) {
// 未过期任务,先将其放入 timerQueue 并根据开始时间排序
timerQueue.push(task);
// 在一定时间后检查是否过期
setTimeout(() => {
advanceTimers(currentTime);
}, startTime - currentTime);
} else {
// 任务已过期,放入 taskQueue 并开启调度
taskQueue.push(task);
// 发起调度
requestHostCallback();
}
}
// 3.开始调度
const channel = new MessageChannel();
const port = channel.port2;
// 注册「中介 performWorkUntilDeadline」,通过「中介」去通知「执行者」
channel.port1.onmessage = performWorkUntilDeadline;
// 注册「调度者 schedulePerformWorkUntilDeadline」
let schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
// 调度循环的状态记录,防止重复开启调度循环
let isMessageLoopRunning = false;
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
// 开始调度
schedulePerformWorkUntilDeadline();
}
}
// 4.「中介」通知「执行者」执行
// 调用 schedulePerformWorkUntilDeadline 会触发 port.postMessage
// 使 channel.port1.onmessage 上挂载的函数执行
// 所以此时会执行 performWorkUntilDeadline
const performWorkUntilDeadline = () => {
if (isMessageLoopRunning) {
const currentTime = performance.now();
// 更新开始时间
startTime = currentTime;
// 判断 taskQueue 是否还有任务未执行
let hasMoreWork = true;
try {
// 开始执行任务
hasMoreWork = flshWork(currentTime);
} finally {
// 「执行者 flshWork」返回 true,说明是由于任务中断终止执行
// 这时会继续调度任务
// 也就是通知「调度者」,「调度者」会通知 「中介 performWorkUntilDeadline」
// 「中介」重新执行 flshWork
// 所以本质上来说,当有任务中断时,会重新在下一个宏任务中执行未完成的部分
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
// 否则说明任务都已经完成,停止调度
isMessageLoopRunning = false;
}
}
}
};
// 5.开始执行任务
function flshWork(initialTime) {
// 核心是执行 workLoop,其他细节这里可以不用考虑
return workLoop(initialTime);
}
function workLoop(initialTime) {
let currentTime = initialTime;
// 检查 timerQueue 中是否有任务过期
advanceTimers(currentTime);
// 取出 taskQueue 中的任务
const currentTask = taskQueue[0];
while (currentTask !== null) {
// 如果任务还未过期但是当前时间片没有剩余时间,则中断任务执行
// 注意,这里的 5ms 是剩余时间,会根据设备分辨率动态决定
if (currentTask.expirationTime > currentTime && performance.now() - startTime < 5) {
break;
}
// 获取回调函数,也就是 「React 任务 performConcurrentWorkOnRoot」
const callback = currentTask.callback;
if (typeof callback === "function") {
// 获取之后将其置空,取消调度的关键
currentTask.callback = null;
// 判断任务是否过期
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 执行任务,也就是开始了 React 的 render 和 commit 阶段
const continuationCallback = callback(didUserCallbackTimeout);
// 任务执行完成之后再重新更新当前时间
currentTime = performance.now();
// 如果任务返回其本身,说明是由于任务中断造成的
// 此时重新将其赋值给 callback,之后在下一个宏任务中完成
if (typeof continuationCallback === "function") {
currentTask.callback = continuationCallback;
// 重新检查 timerQueue
advanceTimers(currentTime);
// 退出循环,返回 true 去告知「中介」当前任务未执行完成
return true;
} else {
// 如果任务不返回本身,说明任务执行完成,将任务出列
taskQueue.shift();
advanceTimers(currentTime);
}
} else {
// 说明 callback 不是函数,则将该任务出列
taskQueue.shift();
}
// 取出下一个任务
currentTask = taskQueue[0];
}
if (currentTask !== null) {
// 说明此时任务还未到期,但是由于时间片不足导致的任务中断
// 此时返回 true 告知「中介」继续执行任务
return true;
} else {
// currentTask 为空说明 taskTask 任务都已经执行完成
// 此时会去 timerQueue 中寻找是否有过期任务
const firstTimer = timerQueue[0];
setTimeout(() => {
advanceTimers(currentTime);
}, firstTimer.startTime - currentTime);
// 返回 false 告知结束当前调度
return false;
}
}
如果还未解答你的疑问,可能是嵌入了一个误区:怎么实现地从函数体中断部分继续执行呢?其实这是做不到的,中断与恢复并不是说从函数体中的某个部分恢复执行
-
首先需要明确一点,React 中的任务都是对 Fiber 对象的处理,在处理的过程中会打上对应的标记,在下一次执行时,还是会执行相同的任务函数,并不会省略函数体内容,但由于处理过的 Fiber 部分已经打上了标记,所以 React 可以直接跳过这部分,从没有标记的地方继续处理
-
同理,这个问题也说明了在「一个大任务」的场景下,React 可能会分段执行,也就是所谓的「将大任务划分为多个小任务」,原理就是借助了调度器的中断与恢复
-
还是同理,借助中断与恢复,在「任务中又产生新任务」的场景下,如果这个新任务「task2」的优先级比当前任务「task1」高,则会中断该任务「task1」,先去执行新任务「task2」,之后再恢复执行还未完成的任务「task1」
饥饿问题
什么是饥饿问题?
从上文得知,任务在任务队列里是以优先级作为依据来进行排序的,如果一个任务的优先级很低,且在执行前面任务的过程中又会源源不断的产生新的高优先级任务,那么这个低优先级任务可能就永远都不会执行,这就是饥饿问题
Scheduler 如何解决饥饿问题?
回顾一下一开始给的 task 的数据结构:
// 优先级需要转换,同样的,任务也需要转换,React 任务进入调度时会被转换为 task
// React 中的任务 performConcurrentWorkOnRoot 会被转换为 Scheduler 中的任务 Task
var newTask: Task = {
id: taskIdCounter++,
// callback 函数即 performConcurrentWorkOnRoot 函数
callback,
// 调度优先级
priorityLevel,
// 开始时间 startTime = currentTime + delay || currentTime
startTime,
// 过期时间 expirationTime = startTime + timeout
expirationTime,
// 在任务队列中排序的依据,由开始时间和过期时间决定
// 而 timeout 又根据优先级生成,这也就是为什么说优先级决定了任务的执行顺序和执行时间
sortIndex: startTime || expirationTime,
};
任务在小顶堆中实际上是根据task.sortIndex属性去排序的,而 sortIndex 其实就是由 expirationTime 赋值得到(taskQueue 中),而 expirationTime 又是由startTime + timeout得来的,关键就是这个 startTime,优先级只决定了 timeout,但排序的根据除了这个 timeout 还依据了 startTime,这也就是说,尽管某个任务的优先级很小,但是随着时间推移,startTime 会逐渐成为队列中最小的,因此这个任务的排列顺序会随着时间越来越靠前,饥饿问题就能解决啦
最后留下两个问题:
-
React 为什么要从 requestAnimationFrame 改用成 MessageChannel 呢?
-
React 的 Scheduler 和 Vue3 的 Scheduler 有什么区别?
最后声明,以上内容纯属个人瞎编,如有任何问题或编写错误,欢迎在评论区友好交流
参考: