调度与时间片
info
React 的每次更新都需要从 root 节点开始,向下 diff,那么页面越大就会越卡顿。既然更新过程阻塞了浏览器的绘制,那么把 React 的更新,交给浏览器自己控制不就可以了吗,如果浏览器有绘制任务那么执行绘制任务,在空闲时间执行更新任务,就能解决卡顿问题了。
时间分片
React 如何让浏览器控制 React 更新呢,首先浏览器每次执行一次事件循环(一帧)都会做如下事情:处理事件,执行 js ,调用 requestAnimation ,布局 Layout ,绘制 Paint ,在一帧执行后,如果没有其他事件,那么浏览器会进入休息时间,那么有的一些不是特别紧急 React 更新,就可以执行了。
如何知道浏览器有空闲时间?
requestIdleCallback 在浏览器有空余的时间,浏览器就会调用 requestIdleCallback 的回调。首先看一下 requestIdleCallback 的基本用法。
React 的异步更新任务就是通过类似 requestIdleCallback 去向浏览器做一帧一帧请求,等到浏览器有空余时间,去执行 React 的异步更新任务,这样保证页面的流畅。
模拟 requestIdleCallback
需要具备两个条件:
- 可以主动让出主线程,让浏览器去渲染视图。
- 一次事件循环只执行一次,因为执行一个以后,还会请求下一次的时间片。
能够满足上述条件的,就只有宏任务,宏任务是在下次事件循环中执行,不会阻塞浏览器更新。而且浏览器一次只会执行一个宏任务。
setTimeout(fn, 0)
setTimeout(fn, 0) 可以满足创建宏任务,让出主线程,为什么 React 没选择用它实现 Scheduler 呢?原因是递归执行 setTimeout(fn, 0) 时,最后间隔时间会变成 4 毫秒左右,而不是最初的 1 毫秒。所以 React 优先选择的并不是 setTimeout 实现方案。
异步调度原理
React 发生一次更新,会统一走 ensureRootIsScheduled
- 对于正常更新会走 performSyncWorkOnRoot 逻辑,最后会走 workLoopSync 。
- 对于低优先级的异步更新会走 performConcurrentWorkOnRoot 逻辑,最后会走 workLoopConcurrent 。
function workLoopSync() {
while (workInProgress !== null) {
workInProgress = performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
在一次更新调度过程中,workLoop 会更新执行每一个待更新的 fiber 。他们的区别就是异步模式会调用一个 shouldYield() ,如果当前浏览器没有空余时间, shouldYield 会中止循环,直到浏览器有空闲时间后再继续遍历,从而达到终止渲染的目的。这样就解决了一次性遍历大量的 fiber ,导致浏览器没有时间执行一些渲染任务,导致了页面卡顿。
scheduleCallback
无论是上述正常更新任务 workLoopSync 还是低优先级的任务 workLoopConcurrent ,都是由调度器 scheduleCallback 统一调度的,那么两者在进入调度器时候有什么区别呢
对于正常更新任务,最后会变成类似如下结构:
scheduleCallback(Immediate, workLoopSync);
对于异步任务:
/* 计算超时等级,就是如上那五个等级 */
var priorityLevel = inferPriorityFromExpirationTime(
currentTime,
expirationTime
);
scheduleCallback(priorityLevel, workLoopConcurrent);
scheduleCallback 到底做了些什么呢?
function scheduleCallback(){
/* 计算过期时间:超时时间 = 开始时间(现在时间) + 任务超时的时间(上述设置那五个等级) */
const expirationTime = startTime + timeout;
/* 创建一个新任务 */
const newTask = { ... }
if (startTime > currentTime) {
/* 通过开始时间排序 */
newTask.sortIndex = startTime;
/* 把任务放在timerQueue中 */
push(timerQueue, newTask);
/* 执行setTimeout , */
requestHostTimeout(handleTimeout, startTime - currentTime);
}else{
/* 通过 expirationTime 排序 */
newTask.sortIndex = expirationTime;
/* 把任务放入taskQueue */
push(taskQueue, newTask);
/*没有处于调度中的任务, 然后向浏览器请求一帧,浏览器空闲执行 flushWork */
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork)
}
}
}
- taskQueue,里面存的都是过期的任务,依据任务的过期时间( expirationTime ) 排序,需要在调度的 workLoop 中循环执行完这些任务。
- timerQueue 里面存的都是没有过期的任务,依据任务的开始时间( startTime )排序,在调度 workLoop 中 会用 advanceTimers 检查任务是否过期,如果过期了,放入 taskQueue 队列。
scheduleCallback 流程如下。
- 创建一个新的任务 newTask。
- 通过任务的开始时间( startTime ) 和 当前时间( currentTime ) 比较:当 startTime > currentTime, 说明未过期, 存到 timerQueue,当 startTime <= currentTime, 说明已过期, 存到 taskQueue。
- 如果任务过期,并且没有调度中的任务,那么调度 requestHostCallback。本质上调度的是 flushWork。
- 如果任务没有过期,用 requestHostTimeout 延时执行 handleTimeout。