上一篇
上次讲述了任务的优先级,以及如何根据优先级(过期时间)加入任务链表,今天来分析一下如何在一个合适的时机去执行任务。
1 requestIdleCallback pollyfill
上文讲到要用requetAnimationFrame
去模拟requestIdleCallback
,但requestIdleCallback
有个缺点,就是当前tab
如果处于不激活状态的话,requestAnimationFrame是不工作的,所以需要requestAnimationFrame
和setTimeout
联合起来保证任务的执行。这就是上文末讲到的requestAnimationFrameWithTimeout
的作用,当前tab处于激活状态时,相当于requestAnimationFrame
在调度任务,当前tab切到未激活时setTimeout
接管任务执行。为了理解方便,下文我们就用requestAnimationFrame
来表示requestAnimationFrameWithTimeout
。
0.流程
我们先来描述一下整个的执行流程,在每一帧开始的rAF的回调里记录每一帧的开始时间,并计算每一帧的过期时间,然后通过messageChannel发送消息。在帧末messageChannel的回调里接收消息,根据当前帧的过期时间和当前时间进行比对来决定当前帧能否执行任务,如果能的话会依次从任务链表里拿出队首任务来执行,执行尽可能多的任务后如果还有任务,下一帧再重新调度。
1.声明变量
var scheduledHostCallback = null; //代表任务链表的执行器
var timeoutTime = -1; //代表最高优先级任务firstCallbackNode的过期时间
var activeFrameTime = 33; // 一帧的渲染时间33ms,这里假设 1s 30帧
var frameDeadline = 0; //代表一帧的过期时间,通过rAF回调入参t加上activeFrameTime来计算
复制代码
2.计算每一帧的截止时间
首先我们先利用requestAnimationFrame
来计算每一帧的截止时间
// rAF的回调是每一帧开始的时候,所以适合做一些轻量任务,不然会阻塞渲染。
function animationTick(rafTime) {
// 有任务再进行递归,没任务的话不需要工作
if (scheduledHostCallback !== null) {
requestAnimationFrame(animationTick)
}
//计算当前帧的截止时间,用开始时间加上每一帧的渲染时间
frameDeadline = rafTime + activeFrameTime;
}
//某个地方会调用
requestAnimationFrame(animationTick)
复制代码
源码里有对每一帧渲染时间的一个优化过程,会在渲染过程中不断压缩每一帧的渲染时间,达到系统的刷新频率(60hz为16.6ms)。因为不是重点就先略过了,这里假设就是33ms。
3.创建一个消息信道
var channel = new MessageChannel();
var port = channel.port2; //port2用来发消息
channel.port1.onmessage = function(event) {
//port1监听消息的回调来做任务调度的具体工作,后面再说
//onmessage的回调函数的调用时机是在一帧的paint完成之后,所以适合做一些重型任务,也能保证页面流畅不卡顿
}
复制代码
4.执行任务
下面就在animationTick
里向channel
发消息,然后在port1
的回调里去决定当前帧要不要执行任务,执行多少任务等问题。
function animationTick(rafTime) {
// 有任务再进行递归,没任务的话不需要工作
if (scheduledHostCallback !== null) {
requestAnimationFrame(animationTick)
}
//计算当前帧的截止时间,用开始时间加上每一帧的渲染时间
frameDeadline = rafTime + activeFrameTime;
//新加的代码,在当前帧结束去搞一些事情
port.postMessage(undefined);
}
//仔细看这段注释
//下面的代码逻辑决定当前帧要不要执行任务
// 1、如果当前帧没过期,说明当前帧有富余时间,可以执行任务
// 2、如果当前帧过期了,说明当前帧没有时间了,这里再看一下当前任务firstCallbackNode是否过期,如果过期了也要执行任务;如果当前任务没过期,说明不着急,那就先不执行去下一帧再说。
channel.port1.onmessage = function(event) {
var currentTime = getCurrentTime(); //获取当前时间,
var didTimeout = false; //是否过期
if (frameDeadline - currentTime <= 0) { // 当前帧过期
if (timeoutTime <= currentTime) {
// 当前任务过期
// timeoutTime 为当前任务的过期时间,会有个地方赋值。
didTimeout = true;
} else {
//当前帧由于浏览器渲染等原因过期了,那就去下一帧再处理
return;
}
}
// 到了这里有两种情况,1是当前帧没过期;2是当前帧过期且当前任务过期,也就是上面第二个if里的逻辑。下面就是要调用执行器,依次执行链表里的任务
scheduledHostCallback(didTimeout)
}
复制代码
5.执行器
上文提到的执行器 scheduledHostCallback
也就是下面的flushWork
,flushWork
根据didTimeout
参数有两种处理逻辑,如果为true
,就会把任务链表里的过期任务全都给执行一遍;如果为false
则在当前帧到期之前尽可能多的去执行任务。
function flushWork(didTimeout) {
if (didTimeout) { //任务过期
while (firstCallbackNode !== null) {
var currentTime = getCurrentTime(); //获取当前时间
if (firstCallbackNode.expirationTime <= currentTime) {//如果队首任务时间比当前时间小,说明过期了
do {
flushFirstCallback(); //执行队首任务,把队首任务从链表移除,并把第二个任务置为队首任务。执行任务可能产生新的任务,再把新任务插入到任务链表
} while (
firstCallbackNode !== null &&
firstCallbackNode.expirationTime <= currentTime
);
continue;
}
break;
}
}else{
//下面再说
}
}
复制代码
注意,上面有两重while
循环,外层的while
循环每次都会获取当前时间,内层循环根据这个当前时间去判断任务是否过期并执行。这样当内层执行了若干任务后,当前时间又会向前推进一块。外层循环再重新获取当前时间,直到没有任务过期或者没有任务为止。
下面看一下没有过期的处理情况
function flushWork(didTimeout) {
if (didTimeout) { //任务过期
...
}else{
//当前帧有富余时间,while的逻辑是只要有任务且当前帧没过期就去执行任务。
if (firstCallbackNode !== null) {
do {
flushFirstCallback();//执行队首任务,把队首任务从链表移除,并把第二个任务置为队首任务。执行任务可能产生新的任务,再把新任务插入到任务链表
} while (firstCallbackNode !== null && !shouldYieldToHost());
}
}
}
复制代码
上面的shouldYieldToHost
代表当前帧过期了,取反的话就是没过期。每次while
都会执行这个判断。
shouldYieldToHost = function() {
// 当前帧的截止时间比当前时间小则为true,代表当前帧过期了
return frameDeadline <= getCurrentTime();
};
复制代码
下面继续看flushWork
function flushWork(didTimeout) {
if (didTimeout) { //任务过期
...
}else{ //当前帧有富余时间
...
}
//最后,如果还有任务的话,再启动一轮新的任务执行调度
if (firstCallbackNode !== null) {
ensureHostCallbackIsScheduled();
}
//最最后,如果还有任务且有最高优先级的任务,就都执行一遍。
flushImmediateWork();
}
复制代码
本文讲的比较简略,源码中有大量flag
,用来做防止重入、防御判断等,并考虑了任务执行过程中有新的任务不断加入等场景的逻辑。这一块需要感兴趣的读者自行去体会了。
2 总结
最后在描述一下整体的任务调度流程
- 1、任务根据优先级和加入时的当前时间来确定过期时间
- 2、任务根据过期时间加入任务链表
- 3、任务链表有两种情况会启动任务的调度,1是任务链表从无到有时,2是任务链表加入了新的最高优先级任务时。
- 4、任务调度指的是在合适的时机去执行任务,这里通过
requestAnimationFrame
和messageChannel
来模拟 - 5、
requestAnimationFrame
回调在帧首执行,用来计算当前帧的截止时间并开启递归,messageChannel
的回调在帧末执行,根据当前帧的截止时间、当前时间、任务链表第一个任务的过期时间来决定当前帧是否执行任务(或是到下一帧执行) - 6、如果执行任务,则根据任务是否过期来确定如何执行任务。任务过期的话就会把任务链表内过期的任务都执行一遍直到没有过期任务或者没有任务;任务没过期的话,则会在当前帧过期之前尽可能多的执行任务。最后如果还有任务,则回到第5步,放到下一帧再重新走流程。