一、Scheduler概述
1、Scheduler
Scheduler是一个独立的包,它可以独自承担起任务调度的职责;它与React组成了一个任务调度系统;
React是任务产生的地方;Scheduler是任务的调度者;
Scheduler和React相互配合的模式可以让React的任务执行具备异步可中断的特点;
2、Scheduler的优先级及时间片
(1)优先级
Scheduler拥有属于自己的优先级,并且会根据这些优先级设置超时时间timeout;
按照任务自身的紧急程度排序,保证高优先级的任务优先执行;
JavaScript
export const NoPriority = 0; // 没有优先级
export const ImmediatePriority = 1; // 立即执行的优先级,级别最高
export const UserBlockingPriority = 2; // 用户阻塞级别的优先级
export const NormalPriority = 3; // 正常优先级
export const LowPriority = 4; // 较低优先级
export const IdlePriority = 5; // 优先级最低,表示任务闲置
(2)timeout设置
Scheduler会根据startTime与timeout设置任务的过期时间expirationTime;
JavaScript
var maxSigned31BitInt = 1073741823;
var IMMEDIATE_PRIORITY_TIMEOUT = -1; // 立即执行的任务
var USER_BLOCKING_PRIORITY_TIMEOUT = 250; // 用户操作的任务
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 空闲任务
(3)时间切片
时间片规定的是单个任务在这一帧内最大的执行时间,任务执行时间一旦超出时间片的限制,就会被中断;可以保证页面不会因为任务连续执行导致时间过长产生卡顿;
具体做法是:
将VDOM的执行过程拆分成一个个独立的宏任务,将每个宏任务的执行时间限制在一定范围内,初始为5ms;即:将一个会造成掉帧的长任务拆解为多个不会掉帧的短宏任务,以减少掉帧的可能性,这一技术被称为时间切片。
3、Scheduler调度任务的模式
在浏览器中,JS引擎线程与UI渲染线程是互斥的,Scheduler不会一直占用线程去执行任务,而是执行一会,中断一下,继续执行,避免一直占用资源执行任务,解决用户操作时页面卡顿的问题;
Scheduler在执行多个任务时,会按照任务的优先级去执行,保证高优先级的任务优先执行;
4、Scheduler的职责
Scheduler主要负责管理任务、安排任务的执行;不过Scheduler只是去做任务的中断和恢复,任务是否完成依赖于React;
Scheduler执行任务具备的特点:根据时间片去中止任务,并判断任务是否完成,未完成则继续调用任务函数。
Scheduler的核心任务就是回调,回调函数由react-reconciler提供,回调函数名称:performConcurrentWorkOnRoot
concurrent模式才支持时间分片;React v18默认开启了该模式;
5、Scheduler中任务的管理
(1)任务管理队列
- timerQueue:存放未过期的任务
- taskQueue:存放已过期的任务
(2)如何判断任务是否过期?
用任务的开始时间startTime和当前时间currentTime对比:
- 开始时间 > 当前时间,说明未过期,放入到timerQueue队列;
- 开始时间 <= 当前时间,说明已过期,放入到taskQueue队列;
(3)不同队列中的任务如何排序?
依据任务的紧急程度排序,只不过依据的参数不一样;
- timerQueue:
- startTime:开始时间
- 开始时间越早,说明任务越早开始,开始时间小的排在前边;
- 任务进来时,默认是当前时间,如果进入调度时,传入延迟时间,开始时间 = 当前时间 + 延迟时间;
- startTime:开始时间
- taskQueue:
- expirationTime:任务过期时间
- 过期时间越早,任务越紧急,排在前边;
- 过期时间根据任务优先级计算得出,优先级越高,过期时间越早;【任务优先级是计算任务过期时间的重要依据,事关过期任务在taskQueue中的排序】
- expirationTime:任务过期时间
(4)任务的处理
- taskQueue队列
- 会立即调度一个函数去循环taskQueue,挨个执行里边的任务;
- timerQueue队列
- 该任务不会立即执行,需要调用handleTimeout检查该队列中的第一个任务是否过期
- 过期,任务从timerQueue中删除,添加到taskQueue中;
- 不过期,继续检查等待第一个任务过期,循环以上操作;
- 该任务不会立即执行,需要调用handleTimeout检查该队列中的第一个任务是否过期
6、Scheduler中的角色分配
- 调度入口:scheduleCallback
- 该函数的调用参数:
scheduleCallback( schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root), );
performConcurrentWorkOnRoot
函数就是我们需要根据优先级分类的任务
- 该函数的调用参数:
- 开始调度的函数:requestHostCallback
- 执行任务的函数:workloop(关键逻辑所在)「功能:循环taskQueue执行任务 和 任务状态的判断」
- 调度者:
- 非浏览器环境是setTimeout;
- 浏览器环境是:MessageChannel实例的port.postMessage;
- 执行者:调用时期的任务执行函数;
- 执行者:performWorkUntilDeadline
- 作用:按照时间片的限制去中断任务,并通知调度者再次调度一个新的执行者去继续执行任务;
7、Scheduler工作流程小结
Scheduler管理着taskQueue与timerQueue两个队列,他会定期调用advanceTimers
将timerQueue中的过期任务放到taskQueue中,然后让调度者通知执行者循环taskQueue执行掉每一个任务。
执行者控制着每个任务的执行,一旦某个任务的执行时间超出时间片的限制,就会被中断,然后当前的执行者退场,退场之前会通知调度者再去调度一个新的执行者继续完成这个任务,新的执行者在执行任务时依旧会根据时间片中断任务,然后退场,重复这一过程,直到当前这个任务彻底执行完成后,将任务从taskQueue中出队。
taskQueue中的每一个任务都这样被处理,最终完成所有任务。
二、任务的中断及恢复
1、单个任务的中断及恢复:
在循环taskQueue执行每一个任务时,如果某个任务的执行时间过长,达到了时间片限制的时间,该任务必须中断,以便于让位给更重要的事情,像浏览器的绘制等,等事情完成,再恢复任务的执行。
单个任务的完成只能依赖任务函数的返回值去判断;
2、任务是否完成的判断
开始调度后,调度者调度执行者去执行任务;如果执行者判断callback返回值为一个function,说明未完成,会将function再次赋值给callback;
当前任务不会从taskQueue中删除,因为任务还没有执行结束,下次循环时,取到这个任务,继续执行;
3、任务的中止
currentTask表示当前正在执行的任务,它中止的判断条件是:任务并未过期,但已经没有剩余时间了,需要让出执行权给主线程;
由于它只是被中止,currentTask的取值不是null,return一个true表示任务还没执行结束(这是恢复任务的关键),否则说明全部的任务已经执行完成,taskQueue队列被清空,return一个false表示终止本次调度。
flushWork实际上是scheduledHostCallback,当performWorkUntilDeadline检测到scheduledHostCallback的返回值为false,就会停止调度;
JavaScript
// 任务的中止判断条件:任务并未过期,但是已经没有剩余时间;或者需要让出执行权给主线程(时间片的限制)
if (
currentTask.expirationTime > currentTime &&
( !hasTimeRemaining || shouldYieldToHost() )
) {
// 退出本次循环,后边的逻辑无法执行
// 这是任务中断的关键
/* ----- 任务只是被中止,还没执行结束 ------ */
break;
}
三、Scheduler源码分析
1、unstable_scheduleCallback
(1)函数作用
- 处理任务过期时间;
- 根据过期时间给任务分类;
- 进入任务调度流程;
(2)任务过期时间计算
JavaScript
// 获取当前时间:它是计算任务开始时间、过期时间和判断任务是否过期的依据
var currentTime = getCurrentTime();
// 计算任务开始时间
var startTime;
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;
}
// 根据优先级计算一个延迟时间
var timeout;
switch (priotiryLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
// 计算任务过期时间
var expirationTime = startTime + timeout;
(3)Scheduler中的任务结构
JavaScript
var newTask = {
id: taskIdCounter++, callback, // 任务函数:真正的任务函数,外部传入【执行结果会影响到任务完成状态的判断】
priorityLevel, // 任务优先级:参与计算任务过期时间
startTime, // 任务开始时间:影响timerQueue中的排序
expirationTime, // 任务过期时间:影响taskQueue中的排序
sortIndex: -1 // 在小顶堆中排序的依据:在区分好任务是过期还是非过期之后,sortIndex会被赋值为expirationTime或startTime
};
(4)任务分组:根据是否过期【startTime > currentTime】
JavaScript
if (startTime > currentTime) { // 任务未过期,以开始时间作为timerQueue排序的依据
newTask.sortIndex = startTime;
push(timerQueue, newTask); // 任务未过期,将newTask放入timerQueue
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
/**
* 如果taskQueue中没有任务,且当前任务是timerQueue中排名最靠前的那一个
* 需要检查timerQueue中有没有需要放到taskQueue中的任务,
* 这一步通过调用requestHostTimeout实现
*/
if (isHostTimeoutScheduled) { // 即将调度一个requestHostTimeout。如果已经调度,则取消掉
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// 调用requestHostTimeout实现任务的转移,开启调度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// 任务已经过期,以过期时间作为taskQueue排序的依据
newTask.sortIndex = expirationTime;
push(taskQueue, newTask); // 将任务添加到taskQueue队列中
// 开始执行任务,使用flushWork去执行taskQueue
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true; // 进入任务调度流程
// 任务的执行者本质上调用的就是flushWork
// flushWork:真正执行任务的函数,会循环taskQueue
requestHostCallback();
}
}
2、任务的调度
(1)requestHostCallback
进入任务调度流程的函数,接收一个参数:flushWork;
flushWork:任务的执行者本质上调用的就是flushWork,它是真正执行任务的函数;
- 实现:该函数的实现区分浏览器环境和非浏览器环境;
- 非浏览器环境不存在帧的概念,也就不会有时间片,可以使用setTimeout实现;
- 浏览器环境下,任务的执行会有时间片的限制,使用MessageChannel实现;
JavaScript
function requestHostCallback( // 该处的callback实际上就是传入的flushWork函数
callback: (hasTimeRemaining: boolean, initialTime: number) => boolean
) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
(2)flushWork
JavaScript
function flushWork(hasTimeRemaining: boolean, initialTime: number) {
isHostCallbackScheduled = false;
if (isHostTimeoutScheduled) {
isHostTimeoutScheduled = false;
cancelHostTimeout();
}
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
// 循环
try {
return workLoop(hasTimeRemaining, initialTime);
} finally {
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}
}
(3)workLoop
该函数是任务执行的核心内容:实现任务的中断与恢复;
该函数的执行结果会被flushWork函数return出去;
JavaScript
// 做了什么:循环taskQueue执行任务 任务状态的判断
// 单个任务完成状态的判断:workLoop依赖任务函数的返回值去判断任务是否完成
// 如任务返回值为函数,说明任务尚未完成,需要继续调用任务函数,否则任务完成
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime; // 开始执行前,检查timerQueue中的过期任务;并放入taskQueue中
advanceTimers(currentTime);
currentTask = peek(taskQueue); // 获取taskQueue中第一个要执行(最紧急)的任务
// 循环taskQueue,执行任务
while (
currentTask !== null &&
!( enableSchedulerDebugging && isSchedulerPaused )
) {
// 任务的中止判断条件:任务并未过期,但是已经没有剩余时间;或者需要让出执行权给主线程(时间片的限制)
if (
currentTask.expirationTime > currentTime
&& ( !hasTimeRemaining || shouldYieldToHost() )
) {
// 退出本次循环,后边的逻辑无法执行
// 这是任务中断的关键
/* ----- 任务只是被中止,还没执行结束 ------ */
break;
}
/* -----------------------执行任务------------------------- */
const callback = currentTask.callback; // 获取任务的执行函数
if (typeof callback === 'function') {
// 如果callback是函数类型,表示当前有任务,可以执行
currentTask.callback = null;
// 获取任务的优先级
currentPriorityLevel = currentTask.priorityLevel;
// 判断任务是否过期
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 获取任务函数的执行结果
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 判断continuationCallback是否为函数,为函数,则将函数作为当前任务新的回调;
currentTask.callback = continuationCallback;
// 检查timerQueue中的过期任务,并放入taskQueue
advanceTimers(currentTime);
// 恢复任务的关键
return true;
} else {
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
// callback为null,任务出队
// 取消调度的关键是:将当前任务的callback设置为null;
// 因为:在workLoop中,callback为null就会被移出taskQueue,当前任务就不再被执行
pop(taskQueue);
}
// 从taskQueue中继续获取任务,如果上一个任务未完成,该任务不会从taskQueue中删除
// 获取的currentTask还是该任务,继续执行该任务
currentTask = peek(taskQueue);
}
// return的结果会作为performWorkUntilDeadline中判断是否还需要再次发起调取的依据
if (currentTask !== null) {
return true;
} else {
// 任务完成,去timerQueue中找优先级最高的任务调度requestHostTimeout
// 目的:将其放入taskQueue中等待调度
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
// 让外部终止本次调度
return false;
}
}
(4)advanceTimers
JavaScript
function advanceTimers(currentTime: number) {
// timerQueue队列中的任务不会执行,只是会每隔一段时间判断其是否过期
let timer = peek(timerQueue); // 获取timerQueue中优先级最高的任务
while(timer !== null) {
if (timer.callback === null) {
// callback:真正要执行的任务
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// 任务开始时间小于当前时间,表示任务已经过期
pop(timerQueue); // 从timerQueue中删除该任务
timer.sortIndex = timer.expirationTime; // 设置排序依据
push(taskQueue, timer); // 将任务添加到taskQueue队列中,等待执行
} else {
return;
}
timer = peek(timerQueue);
}
}
(5)requestHostTimeout
JavaScript
function requestHostTimeout(
callback: (currentTime: number) => void,
ms: number
) {
taskTimeoutID = localSetTimeout(() => {
callback(getCurrentTime());
}, ms);
}
(6)handleTimeout
- 作用:检查timerQueue队列中的过期任务,并且添加到taskQueue中并执行;
- 实现:
- 调用advanceTimers,检查timerQueue队列中的过期任务,添加到taskQueue中;
- 检查是否已经开始调度,若未调度,则检查taskQueue中是否已经有任务
- 如果有,且空闲,立即开始调度,并执行任务;
- 如果没有,且空闲,调用requestHostTime检查是否有过期任务
JavaScript
function handleTimeout(currentTime: number) {
isHostTimeoutScheduled = false;
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
(7)performWorkUntilDeadline
JavaScript
// 任务的执行:执行者
// 按照时间片的限制去中断任务,并通知调度者再次调度一个新的执行者去继续任务
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
// 表示任务是否还有剩余时间,与时间片一起限制任务的执行;
// 如果没有时间,或者任务执行时间超出时间片限制,就中断任务
// postMessage的执行很靠前,中断任务就只看时间片
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
// scheduledHostCallback:执行任务
// 任务因为时间片被打断,返回true;表示还有任务执行,调度者会再调度一个执行者继续执行任务
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
// 有任务,调度者调度执行者,继续完成任务
schedulePerformWorkUntilDeadline();
} else {
// 没有任务,则停止调度
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
needPaint = false;
}
(8)时间片的实现
MessageChannel在浏览器事件循环中属于宏任务,在调度中心属于异步调度;
JavaScript
// 时间分片浏览器实现
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
(9)unstable_cancelCallback
JavaScript
// 取消任务调度
// 在workLoop中,callback设置为null,就会被移出taskQueue,那么当前任务就不再被执行;
// 它只是取消当前任务的执行,while循环还会继续执行下一个任务
function unstable_cancelCallback(task) {
task.callback = null;
}