Scheduler调度原理
why this
在Vue中,有一个收集依赖的过程,即响应式的,在一次更新中,Vue通过依赖的收集能找到更新的范围,然后以组件粒度来更新渲染试图。但是在React中,每一次更新都会重新调用函数式组件,并不知道更新范围,所以都是从根节点开始diff的,然后来渲染更新的。
在React15之前,这种全量更新都是同步并且不可中断的,这样就会导致每一次更新(JS线程)都会阻塞页面渲染(GUI渲染线程),导致卡顿。
之所以有个调度中心,是为了解决同步不可中断渲染的更新。需要有个调度中心来对这些任务做一个调度处理,能及时的让出主线程,将长任务切分成一个个的短任务,给浏览器剩下一些时间在一帧内进行绘制操作。
如何知道浏览器有空闲时间很重要,浏览器提供了一个requestIdleCallback的api,能够在浏览器空闲时执行回调函数,但是当时由于兼容性的问题,所以react实现了一个更加精细化、兼容性更好的库Scheduler。
时间分片
浏览器一帧的工作:处理事件、执行JS、调rAF、布局Layout、绘制Paint,最后如果还有空闲时间就会执行requestIdleCallback的回调。
React在Scheduler设置了几个优先级:
Immediate -1优先级最高,需要立即执行UserBlocking超时时间250ms 。这种一般是一些用户交互Normal超时时间5000ms 。一般是网络请求等Low超时时间10000ms。Idle一些没有必要执行的任务,可能不会执行。
这个超时时间实际就是优先级的体现
时间分片就是将一大段的长任务,拆分成一个个小的短任务来执行,不至于长时间占用JS线程导致GUI渲染线程无法工作,通过时间分片可以提高渲染效率。
异步调度原理
太详细的函数名反而会沉溺在细节中看不清全局,这里就介绍一些重点
任务队列
通过小顶堆来存储的任务队列,会根据任务的优先级设置过期时间,优先级越高过期时间就越短,也是通过判断过期时间来确定优先级的。
在入口文件中,根据当前任务的优先级来进行不同的处理:
- 如果没有设置超时时间,就直接进入scheduler流程
- 如果有设置超时时间,就用setTimeout延后执行
这么处理的原因:为了确保高优先级的任务能够优先进入scheduler流程中
关于小顶堆的介绍,看一下这里
异步
Scheduler根据不同的环境选择了不同的函数。
if(typeof setImmediate === 'function') {
// nodejs ie旧版本 选择了setImmediate
}else if(typeof MessageChannel !== 'undefined') {
// DOM and Worker 选择了MessageChannel
// 不用setTimeout是因为有个4ms的误差
}else {
// 其他非浏览器环境 选择了setTimeout
}选择异步函数包裹,在下一次事件循环中进行异步调度。异步是为了模拟requestIdleCallback能将某个任务添加到下一帧中来执行。
因为直接执行会阻塞,所以只能通过异步来实现
工作循环
依次从任务队列(小顶堆)中取第一个值,判断当前任务是否需要中断,如果中断需要退出工作循环,让出主线程;否则就执行当前任务的回调。
因为任务队列中通过小顶堆来实现的,每次取堆顶的任务来执行,就保证了任务的优先级每次都是最高的。
优先级越高的数字越小,通过小顶堆可以实现优先级控制。
中断后保留上下文的能力并不是这里提供的,而是在构造时提供的,这里只会执行回调函数。所以在构造时如果被中断,需要保留上下文,等待恢复时使用。具体这里:
// callback是任务的回调
// 执行回调,如果返回值是函数,说明是上一次未执行完的,接着执行
const continuationCallback = callback()
if(typeof continuationCallback === 'function') {
// 上一次未执行完的,继续执行
currentTask.callback = continuationCcallback
}else {
// 清除掉当前正在执行的任务
}工作循环中的每一次循环就是一次
时间切片,在执行过程中可以随时退出循环让出主线程。
整体流程
创建调度任务
首先会根据传进来的回调和优先级来创建调度任务(unstable_scheduleCallback), 这里根据任务的优先级和过期时间关联上,然后根据这个过期时间将任务放到小顶堆中,然后请求消费调度任务。
消费调度任务
scheduler中通过模拟宿主环境的异步任务来实现的更新调度,比如MessageChannel,一端用来消费调度任务,另一端用来触发,触发后会等到合适的时机在另一端来消费调度任务,这个合适的时机一般是主线程空闲。
所以这里说的消费调度任务就是在一端postMessage,等到主线程空闲时另一端执行onmessage的回调,即进行任务调度。
执行任务调度
在创建调度任务时触发的消费有一个flushWork回调,是通过这个回调来驱动调度的,执行任务调度的函数是performWorkUntilDeadline, 在这个函数内部执行了flushWork回调。在flushWork中执行了workLoop循环,并将这个循环结果return。这个循环很重要,是从小顶堆中依次取任务来执行的,在每次执行前会判断一下是否超时,超时的话直接break,然后根据是否有剩余任务来返回true/false。这样会一直return到performWorkUntilDeadline中,再次触发postMessage。
超时的判断
创建调度任务时给任务分配了一个当前时间和根据任务优先级排出的超时时间。
拿出任务执行时也有一个当前时间(workLoop中)。
判断是否退出时的时间减去注册调度任务的时间,就是当前任务已经等待的时间。
最后判断超时时间是否大于当前执行任务时的时间,以及没有剩余时间或者当前任务等待的时间超过5ms,就会退出循环,让出主线程。
if(currentTask.expirationTime > currentTime &&(!hasTimeRemaining || shouldYieldToHost())) {
break;
}
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < 5) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}为什么是超时时间大于当前时间呢?(
currentTask.expirationTime > currentTime) 说明当前任务还没有到期,不着急执行 可以进一步判断是否需要中断。任务没有过期的情况下:
已经没有剩余时间了所以需要中断;
或者该任务等待超过了
5ms,一直阻塞主线程,也应该中断,让出线程。所以从这里的中断可以看出,每次调度大概都会隔
5ms中断一下,方便其他高优先级任务的插入。注意在React18中,没有了 !hasTimeRemaining 的判断,说明只需要在任务没过期时就会每隔5ms中断一次。
如何让出的主线程
这里通过异步来的,让出主线程即退出workLoop的循环,如果有其他任务会再次触发异步,等待主线程执行完当前任务后接着执行剩下的任务。
时间切片
在workLoop中每次循环都是一个时间切片,因为每次执行任务前会判断是否需要让出主线程,这样就将一个长时间的任务,分成了一个个的短时间切片。
可中断任务的恢复
看上面工作循环的一节内容。
这里会根据任务回调的返回值来判断,如果是函数的话就会继续上一次的执行。所以可中断任务并不是这里实现的,顶多算是支持了一下而已。真正的可中断任务恢复应该是在传入的回调函数中做的,中断时通过上下文变量保存了中断前的状态,这样才能在这里继续执行。
总结
scheduler中做了很多工作,大致流程是:
创建调度任务 -> 消费调度任务在消费调度任务中,通过循环实现了时间切片、超时判断、任务中断等工作,
并且通过异步实现了及时让出主线程执行其他高优先级任务。