前言
在React 16+ 的架构中,React团队没有直接选择requestIdleCallback api来做任务调度(Scheduler),原因大抵是该api的兼容性以及fps的限制(1秒中最多调用20次,即20fps),而选择了MessageChannel来polyfill。
React的调度过程
React更新时和Scheduler的交互流程如下:
- React 组件状态更新,向 Scheduler 中存入一个任务,该任务为 React 更新算法。
- Scheduler 调度该任务,执行 React 更新算法。
- React 在调和阶段(reconciliation)更新一个 Fiber 之后,会询问 Scheduler 是否需要暂停。如果不需要暂停,则重复步骤 3,继续更新下一个 Fiber。
- 如果 Scheduler 表示需要暂停,则 React 将返回一个函数,该函数用于告诉 Scheduler 任务还没有完成。Scheduler 将在未来某时刻调度该任务。
在这些步骤中,我们着重关注第3点,也就是需要判断Scheduler是否需要暂停
执行React任务的时机
知道了大概的调度过程,首先了解一下React任务是放在什么时机执行的
先来复习一下浏览器的eventloop
用代码来
/**
* 事件循环
*/
while(true) {
// 拿出宏任务执行
const queue = getNextQueue()
const task = queue.pop()
excute(task)
// 有微任务的话执行
while(microtaskQueue.hasTasks()){
doMicrotask()
}
if(isRepaintTime()) {
// 处理RAF(requestAnimationFrame)
animationTasks = animationQueue.copyTasks();
for(task in animationTasks) {
doAnimationTask(task);
}
// 渲染下一帧
repaint();
}
}
复制代码
那么Scheduler 需要满足以下功能点
- 暂停 JS 执行,将主线程还给浏览器,让浏览器有机会更新页面
- 在未来某个时刻继续调度任务,执行上次还没有完成的任务
虽说每轮Tick的开始都是宏任务,但在实际执行中,首次执行同步代码会作为一次宏任务,因此后续的顺序可以看作: 执行微任务队列
=> 渲染(若有渲染时间)
=> 下一个任务
也就是说我们需要一个宏任务,因为宏任务在渲染后的下一帧,不会阻塞本次循环
注:理想情况下每一帧都是一次eventloop,但如果因为微任务执行超出16ms(当前帧)甚至超出多帧,那么本次循环将超出一帧,即有可能在第n帧才完成微任务,然后才进行渲染,也就是所说的掉帧。
举个掉帧的例子
setTimeout(()=>{
console.log('第1次宏任务')
requestAnimationFrame(()=>{ console.log('RAF执行') });
const dom = document.getElementById('box')
let n = 0
while(n < 200){
dom.style.left = n + 'px'
n = n + 1
}
setTimeout(()=>{
console.log('第2次宏任务')
},0)
p.then(()=>{
let r = timeConsumingTask(40)
console.log('第1次微任务', r)
})
},2000)
打印顺序:
第1次宏任务
第1次微任务 102334155
RAF执行
第2次宏任务
执行顺序:
1. 2000ms后触发第1次宏任务,移动dom(还没渲染),将第2次宏任务和第1次微任务塞入队列
2. 执行微任务列表,这里模拟了一个耗时任务,大概花了10s
3. 过了10s后,微任务执行完毕,执行渲染,因此我们发现过了10s这个dom才完成移动
5. RAF的回调此时才执行,因为它一定是在渲染前才执行
6. 渲染重绘
7. 新的一轮,执行第2次宏任务
复制代码
如何暂停React任务
源码中shouldYield
就是用来判断在有限的时间片中React任务有没有完成,需不需要挂起。在源码中每个时间片时5ms,这个值会根据设备的fps调整。
判断是否应该暂停
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
复制代码
根据fps计算时间片
function forceFrameRate(fps) {
if (fps < 0 || fps > 125) {
console['error'](
'forceFrameRate takes a positive int between 0 and 125, ' +
'forcing frame rates higher than 125 fps is not supported',
);
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
yieldInterval = 5;//时间片默认5ms
}
}
复制代码
shouldYield
在函数中有一段,所以可以知道,如果当前时间大于任务开始的时间+yieldInterval,就打断了任务的进行。
function shouldYield
//deadline = currentTime + yieldInterval,deadline是在performWorkUntilDeadline函数中计算出来的
if (currentTime >= deadline) {
//...
return true
}
复制代码
MessageChannel
postMessage作用就是将一个任务塞到宏任务队列中
相关源码比较长篇大论
window.addEventListener('message', idleTick, false); // 接受 react 任务队列
idleTick
- 接受判断 react 任务
- 判断当前帧是否把时间用完了,帧时间用完了任务又过期了 didTimout 标志过期
- 没用完继续或调用动画,保存任务等它过期再调用
- 最后判断 callback 不为空,调用过期的 react 任务。
- 这个方法保证了动画最大限度的执行,react 更新任务只有到时间才会执行
const idleTick = function(event) {
...
}
复制代码
然后在requestHostCallback
和 animationTick
中调用postMessage
为什么不用setTimeout
上面说到我们需要一个宏任务,那么为什么不使用setTimeout呢,原因是setTimeout在递归调用下,塞入队列的最低延时会变为4ms,一帧一共就16ms,上面说到时间片默认也就5ms,浪费的这3~4ms是不可容忍的。
为什么不用requestAnimationFrame
从流程上看,RAF的执行时机是在渲染前,但其实浏览器并没有规定应该何时渲染页面,因此RAF是不稳定的。
- 有可能过了几次loop才调用一次RAF,React Task就会被搁置太久
- 将React Task放到RAF中,依然有可能会阻塞渲染