在之前的代码实现中,使用 requestIdleCallback 实现了 scheduleCallback 函数。达成了在浏览器空闲时间执行回掉的效果。但是 requestIdleCallback 兼容性不好,不能兼容一些浏览器,所以需要使用 MessageChannel 来模拟 requestIdleCallback。
前置知识
在现代浏览器中,一般为 60 hz,即一秒 60 帧,平均到每一帧的时间大约为 16.6 ms。在一帧的时间内,浏览器需要完成上图中的所有工作,否则就会卡顿。scheduleCallback 是浏览器提供的接口,用于在浏览器每一帧的空闲时间执行任务。由于兼容性问题,需要使用 MessageChannel 来模拟 requestIdleCallback,实现类似效果。
MessageChannel 是一个异步宏任务,作用则是建立消息通道,并且在接收到消息的时候执行回掉。
在浏览器中,js 线程和渲染线程是互斥的,页面渲染会在 js 执行完成后进行。
目标
根据前置知识,可以知道要使浏览器打开页面时不卡顿,只需要让浏览器在 16 ms 内完成一帧的所有工作,并将页面渲染出来。
要完成这个目标,有两个方向,开源节流。
开源即增加 js 执行时间,但是这个时间是固定的,一帧一共 16 ms,无法增加,所以需要考虑如何节流。
节流的思路就是压缩 js 执行时间,让 js 在规定时间内将控制权让给渲染线程,开始渲染页面。
但是在实际执行中,由于应用的复杂度,通常很难做到在规定时间内交还控制权,于是 react 设计了调度模式,来控制 js 任务执行时间。
具体思路为将大任务分解为小任务,在每一帧执行,保证不会阻塞渲染。
实现
对任务的分解在之前的文章中已经讲过了,代码如下:
function ensureRootIsScheduled(root) {
if (workInProgressRoot) return;
workInProgressRoot = root;
// 告诉浏览器执行 performConcurrentWorkOnRoot
scheduleCallback(NormalSchedulerPriority, performConcurrentWorkOnRoot.bind(null, root));
}
/**
* 根据虚拟DOM 构建 fiber 树,创建真实 DOM 节点,插入容器
* @param {*} root
*/
function performConcurrentWorkOnRoot(root, timeout) {
// 以同步方式渲染,第一次渲染都是同步
renderRootSync(root);
// 开始进入提交阶段,就是执行副作用,修改真实DOM
const finishedWork = root.current.alternate;
root.finishedWork = finishedWork;
commitRoot(root);
workInProgressRoot = null;
}
这里的任务就是 performConcurrentWorkOnRoot 回掉函数。
对于大任务切分成小任务执行,还需要讨论的是他的执行顺序。在 react 中引入了优先级的概念,利用最小堆的数据结构存储,每次执行堆顶任务。
这里有一种场景,如果一个低优先级任务,还没执行,有高优先级任务一直进入最小堆,导致低优先级任务总是不能执行,这种情况明显也是不行的。所以在最小堆的比较条件上,使用过期时间进行比较,过期时间越小优先级越高。这里的过期时间也可以叫做最大等待时间,就是一个任务在这个时间之前,都可以被插队,在这个时间之后,就不能被插队了。就好像在生活中排队,这个时间就是你怒气值攒满的时间,过了这个时间还有人想插队,那就不行了。
/**
* 按优先级执行任务
* @param {*} priorityLevel
* @param {*} callback
*/
export function scheduleCallback(priorityLevel, callback) {
const currentTime = getCurrentTime();
const startTime = currentTime;
// 超时时间
let timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
// 计算此任务的过期时间
const expirationTime = startTime + timeout;
const newTask = {
id: taskIdCounter++,
callback, // 回掉函数
priorityLevel, // 优先级
startTime, // 任务开始时间
expirationTime, // 任务过期时间
sortIndex: expirationTime, // 排序依据
};
// 向最小堆里添加任务,排序依据是过期时间
push(taskQueue, newTask);
// flushWork 执行任务
requestHostCallback(workLoop);
return newTask;
}
scheduleCallback 函数做了以下几件事:
- 根据优先级分配过期时间
- 创建任务对象
- 将任务推入最小堆
- 执行任务回掉
⚠️逻辑在执行任务回调中。
function requestHostCallback(workLoop) {
// 缓存回掉函数
scheduleHostCallback = workLoop;
// 执行工作直到截止时间
schedulePerformWorkUntilDeadline();
}
function schedulePerformWorkUntilDeadline() {
port2.postMessage(null);
}
requestHostCallback 函数中接收一个 workLoop 回掉函数,缓存后执行 schedulePerformWorkUntilDeadline 函数,schedulePerformWorkUntilDeadline 函数则是发送一个消息,根据 MessageChannel 相关知识,会有一个接收端接收消息并执行回掉函数。
const channel = new MessageChannel();
var port2 = channel.port2;
var port1 = channel.port1;
port1.onmessage = performWorkUntilDeadline;
function schedulePerformWorkUntilDeadline() {
port2.postMessage(null);
}
function performWorkUntilDeadline() {
if (scheduleHostCallback) {
// 获取开始执行任务的时间
startTime = getCurrentTime();
// 是否有更多工作要做
let hasMoreWork = true;
try {
// 执行 flushWork
hasMoreWork = scheduleHostCallback(startTime);
} finally {
// 如果为 true ,表明还有更多工作要做
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
// 否则,将 scheduleHostCallback 设置为 null
scheduleHostCallback = null;
}
}
}
}
从上面代码可以看出,这里面存在一个递归,联合之前的代码可以看出,本质上是递归执行 workLoop 函数。
function workLoop(startTime) {
let currentTime = startTime;
// 取出优先级最高的任务
currentTask = peek(taskQueue);
while (currentTask !== null) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
break;
}
const callback = currentTask.callback;
if (typeof callback === "function") {
currentTask.callback = null;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 如果返回新的函数,表示当前工作没有完成
const conditionalCallback = callback(didUserCallbackTimeout);
if (typeof conditionalCallback === "function") {
currentTask.callback = conditionalCallback;
// 表示还有任务需要执行
return true;
}
// 如果此任务已经完成,则不需要再继续执行,弹出此任务
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
if (currentTask !== null) {
return true;
}
return false;
}
workLoop 函数忽略一些判断逻辑,可以看出主要作用就是遍历最小堆,判断是否还有任务需要执行。这里面有一个条件判断:
const frameInterval = 5;
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
break;
}
其中第一个针对过期时间的判断表示当前任务没过期,第二个判断表示是否应该把控制权交还给浏览器,这里的判断依据是时间尺度,在 react 中为 5 ms。
这个判断表示的意思就是,如果当前任务没有过期,且应该交还控制权,则交还控制权,如果已经过期,则直接执行完。
// 计算此任务的过期时间
const expirationTime = startTime + timeout;
在上面计算超时时间的公式中,隐藏了一个逻辑,那就是超时时间会越来越大,即新入队的任务的优先级会越来越低,因为 startTime 会越来越大。