写在前面:这篇内容,更多像是随笔,有些内容只是给了一个入口,具体调度是怎么实现的,还是需要非常详细的去看源码!!!
一帧中js执行顺序
宏任务 -> 微任务 -> requestAnimationFrame -> 重排/重绘 -> requestIdleCallback
requestIdleCallback
React中的调度算法和requestIdleCallback这个api息息相关。
- requestIdleCallback:利用浏览器一帧的剩余时间来执行优先级较低的任务
为什么重写requestIdleCallback
其实这里用重写这个词非常不严谨,严格来说根本没有用requestIdleCallback。应该是为什么不用requestIdleCallback才对
requestIdleCallback缺陷
这里就是要讨论reqeustIdleCallback的缺陷了
- 还是一个实现的API,
兼容性很差 - requestIdleCallback的FPS只有
20ms,正常情况下渲染一帧时长控制在16.67ms,该时间是高于页面流畅的需求的 - requestIdleCallback的定位是处理
不重要、不紧急的低优先级任务。和React可能不太符(React渲染内容,并非是不紧急不重要) 所以不仅API兼容一般,帧渲染能力一般,需求也不太符合。所以React团队自行实现。
如何重写
老版本:scheduler中采用了MessageChannel来实现requestIdleCallback, 当前环境下不支持MessageChannel就采用setTimeout新版本:优先使用setImmediate,如果没有,再使用MessageChannel- github、gitee
需要解决什么问题
想要实现requestIdleCallback的处理,需要解决两个问题:
- 如何判断一帧是否有空闲?
- 如果有了空闲,在一帧中哪里去执行任务?
- 到达deadline后如何暂停?
判断当前帧是否有空闲时间
如果有空闲,在一帧中哪里去执行任务?
我们已经知道,是生成一个宏任务来实现任务调度,实现代码如下
let schedulePerformWorkUntilDeadline;
if (typeof setImmediate === 'function') {
schedulePerformWorkUntilDeadline = () => {
setImmediate(performWorkUntilDeadline);
};
} else {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
}
如上:可以通过执行schedulePerformWorkUntilDeadline函数主动触发任务执行
如何暂停
上面我们已知是通过deadline来判断当前帧是否有时间,是否还需要继续执行的。那么暂停的依据:
// shouldYield函数
if (currentTime >= deadline) {
// 时间到,暂停让出执行权
return true;
}
如何继续(TODO)
TODO 暂时也没懂
在performConcurrentWorkOnRoot函数的结尾有这样一个判断,如果callbackNode等于originalCallbackNode那就恢复任务的执行
if (root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performConcurrentWorkOnRoot.bind(null, root);
}
调度优先级
在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()的回调并更新页面,否则就执行下一个宏任务