写在前面:这篇内容,更多像是随笔,有些内容只是给了一个入口,具体调度是怎么实现的,还是需要非常详细的去看源码!!!
先看下requestIdleCallback是什么
React中的调度算法
和requestIdleCallback这个api息息相关。是利用浏览器一帧的剩余时间来执行优先级较低的任务
React为什么要重写requestIdleCallback
这里就是要讨论reqeustIdleCallback的缺陷了
- 还是一个实现的API,
兼容性很差
- requestIdleCallback的FPS只有
20ms
,正常情况下渲染一帧时长控制在16.67ms,该时间是高于页面流畅的需求的 - requestIdleCallback的定位是处理
不重要
、不紧急
的任务。和React可能不太符(React渲染内容,并非是不紧急不重要)
所以不仅API兼容一般,帧渲染能力一般,需求也不太符合。所以React团队自行实现。
React如何重写
老版本
:scheduler中采用了MessageChannel
来实现requestIdleCallback,
当前环境下不支持MessageChannel就采用setTimeout
需要解决什么问题
想要实现requestIdleCallback的处理,需要解决两个问题:
- 如何判断一帧是否有空闲?
- 如果有了空闲,在一帧中哪里去执行任务?
调度优先级
在Scheduler中有两个函数可以创建具有优先级的任务
runWithPriority
scheduleCallback
- 以一个优先级注册callback,在适当的时机执行
- 优先级意味着过期时间,优先级越高priorityLevel就越小,过期时间离当前时间就越近(如上图)
var expirationTime = startTime + timeout
复制代码
如立即执行:IMMEDIATE_PRIORITY_TIMEOUT = -1
var expirationTime = startTime + (-1).就小于当前时间了,所以要立即执行
- 调度的过程中用到了小顶堆,所以我们在O(1)的复杂度找到优先级最高的task
- 未过期的任务:timerQueue
- 过期的任务:taskQueue
单个任务数据结构
/** 任务对象 */
var newTask = {
id: taskIdCounter++, // id,表示任务数
callback, // 当前任务真正需要做的事情
priorityLevel, // 优先级
startTime, // 开始时间
expirationTime, // 过期时间,用于比较
sortIndex: -1, // 用于比较任务优先级的关键地方,后面回去更新
};
复制代码
任务暂停和继续
相关常量和函数
【常量】yieldInterval – 时间片
- 每一帧的
时间片
长度,默认是5ms,会通过当前浏览器的fps来计算时间片。由forceFrameRate修改
【常量】deadline – 截止时间
- 任务的
截止时间
:deadline = currentTime + yieldInterval。在performWorkUntilDeadline函数中计算得来
【函数】requestHostCallback
- 类似于 requestIdleCallback
- 通过执行
schedulePerformWorkUntilDeadline
函数,来实现对函数performWorkUntilDeadline
的执行触发,更新当前帧下一帧的结束时间,也就是deadline
常量
再梳理下流程:
requestHostCallback
-> schedulePerformWorkUntilDeadline
-> performWorkUntilDeadline
【函数】shouldYieldToHost
- 主要作用是判断当前时间是否已经超过deadline。如果超过了,返回true,其他地方就可以中断任务
【文件】SchedulerPriorities
任务调度是按照任务优先级调用执行,这里就是定义的任务优先级文件
【常量】currentPriorityLevel – 当前任务优先级
在runWithPriority方法中会修改,同时这个函数执行接收到的回调函数时,会拿到当前的currentPriorityLevel
【常量】taskQueue – 过期任务
【常量】timerQueue – 未过期任务
Q & A
为什么是MessageChannel?
其实这个问题,更严谨来说,为什么是宏任务
(MessageChannel、setImmediate、setTimeout)
Scheduler需要满足以下功能点:
- 暂停JS执行,将主线程交还给浏览器,让浏览器有机会更新页面。也就是
中断
- 在未来某个时刻继续调度任务,执行上次还没有完成的任务
要满足上面亮点就需要调度一个宏任务,因为宏任务是在下次事件循环中执行,不会阻塞本次页面更新。而微任务是在本次页面更新前执行
,与同步执行无异,不会让出主线程
为什么不是setTimeout(fn, 0)
因为递归执行setTimeout(fn, 0)时,最后间隔时间会变成4ms
(自己试验,甚至不止4ms),而不是最初的1ms
var count = 0
var startVal = +new Date()
console.log("start time", 0, 0)
function func() {
setTimeout(() => {
console.log("exec time", ++count, +new Date() - startVal)
if (count === 50) {
return
}
func()
}, 0)
}
func()
复制代码
为什么不用rAF()
- 如果上次任务调度不是rAF()触发的(scheduler.scheduleTask()),将导致在当前更新前进行两次任务调度(两次的原因:如果在rAF()的回调中再调用rAF(),会将第二次的rAF()的回调放到
下一帧
前执行,而不是当前帧) - 页面
更新的时间不确定
,如果浏览器间隔10ms才更新页面,那么这10ms就浪费了
现有WEB技术中并没有规定浏览器应该什么时候更新页面,通常认为是在一次宏任务完成之后,浏览器自行判断当前是否应该更新页面。如果需要更新页面,则执行rAF()的回调并更新页面,否则就执行下一个宏任务