前言
在React 16+ 的架构中,React团队没有直接选择requestIdleCallback api来做任务调度(Scheduler),原因大抵是该api的兼容性以及fps的限制(1秒中最多调用20次,即20fps),而选择了MessageChannel来polyfill。
为什么要做调度
我们可以通过一个简单的例子来模拟diff发生时的状况。
假设我们有一个很复杂的应用,有10000甚至更多的Virtual DOM
节点,我们用循环+耗时任务来模拟这些节点的diff过程。
/**
* 耗时的递归
**/
function fibonacci(n) {
if (n === 0) return 0;
else if (n === 1) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
/**
* 耗时任务
**/
const timeConsumingTask = (n) => {
const res = fibonacci(n);
return res;
};
然后我们需要模拟一下浏览器的行为,这里就将遍历过的节点数插入html
中作为浏览器任务。
let count = 0;
const addCount = () => {
count += 1;
console.log("count=", count);
}
const $root = document.getElementById("root");
先来看没有任何调度的做法
function render(times) {
while (count < times) {
// 计算 25 的斐波那契数列大概耗时 1ms,所以这里选择 25
timeConsumingTask(25);
addCount();
$root.innerText = "当前计算个数:" + count;
}
}
render(5000)
效果:
再看看调度情况, 给到浏览器的时间不足1ms,这是非常糟糕的
可以看到html是在节点全部遍历完成后才渲染的,这当然是我们不愿意看到的
手动调度
上述的阻塞情况很明显是因为没有做任何调度,那么在介绍fiber的实现之前,我们先来试试手动调度
function render(times) {
setTimeout(() => {
let currentCount = 1;
while (count < times && currentCount < 10) {
currentCount++;
timeConsumingTask(25);
addCount();
$root.innerText = "当前计算个数:" + count;
}
render(times);
}, 15);
}
首先我们在循环中加了一个条件,限制每次render只执行10次耗时任务(diff),并且在每次render之前都留出15ms来给浏览器。
效果:
这下就流畅多了,看看调度情况
显然是留给了浏览器15ms的时间。
但问题又来了,可以看到在上图中浏览器做渲染的时间可能只有0.1ms时间,但我们却留给了它15ms,即有绝大部分的时间浏览器是不需要的。
总结: 这种手动调度的方式本质上是耗时任务(diff)做主导,不需要的时间才还给浏览器
Scheduler
在上述手动调度的基础上,我们需要再优化一下,将主导的角色还给浏览器,浏览器有空闲的时间再做diff
问题在于我们如何知道浏览器空闲了呢?其实已经有了现成的api
详细的使用方式见文档,简单来说就是告诉浏览器,在你空闲的时候执行我的指定任务(diff)
function renderWithFiber(times) {
const run = (deadline) => {
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && count < times) {
fibonacciWithTime(25);
addCount()
$root.innerText = "当前计算个数:" + count;
}
if (count < times) {
requestIdleCallback(run);
}
};
requestIdleCallback(run);
}
看看调度情况
可以看到浏览器的空闲时间大大缩短了。
当然requestIdleCallback
有自身的设计缺陷
requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work。
也就是说 requestIdleCallback 的 FPS 只有 20 (一般 FPS 为 60 时对用户来说是感觉流程的, 即一帧时间为 16.7 ms)。在加上api兼容性的考虑,React 团队自己 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();
}
}
虽说每轮Tick的开始都是宏任务,但在实际执行中,首次执行同步代码会作为一次宏任务,因此后续的顺序可以看作:
执行微任务队列
=> 执行RAF回调(若要执行渲染)
=>渲染(若要执行渲染)
=> 下一个任务
Scheduler需要满足以下功能点
- 暂停 JS 执行,将主线程还给浏览器,让浏览器有机会更新页面
- 在未来某个时刻继续调度任务,执行上次还没有完成的任务
也就是说我们需要一个宏任务,因为宏任务在渲染后的下一帧,不会阻塞本次循环
理想情况下每一帧都是一次loop,但如果因为某些原因,如某微任务执行太久,时间超出当前帧(16ms)甚至超出多帧,那么本次循环将在该微任务执行完才结束,然后才进行渲染,也就是所说的掉帧。
举个掉帧的例子
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中,依然有可能会阻塞渲染
为什么不用requestIdleCallback
从流程上看
requestIdleCallback是在浏览器重绘重排之后,如果还有空闲就可以执行的时机,所以为了不影响重绘重排,可以在浏览器在requestIdleCallback中执行耗性能的计算,但是由于requestIdleCallback存在兼容和触发时机不稳定的问题,scheduler中采用MessageChannel来实现requestIdleCallback,当前环境不支持MessageChannel就采用setTimeout。