# React 中 requestIdleCallback 的实现
# 背景知识
当前大多数屏幕的刷新频率都是 60hz,也就是每秒屏幕刷新 60 次,低于 60hz 人眼就会感知卡顿掉帧等情况,同样我们前端浏览器所说的 FPS(frame per second)是浏览器每秒刷新的次数,理论上 FPS 越高人眼觉得界面越流畅,在两次屏幕硬件刷新之间,浏览器正好进行一次刷新(重绘),网页也会很流畅,当然这种是理想模式, 如果两次硬件刷新之间浏览器重绘多次是没意义的,只会消耗资源,如果浏览器重绘一次的时间是硬件多次刷新的时间,那么人眼将感知卡顿掉帧等, 所以浏览器对一次重绘的渲染工作需要在 16ms(1000ms/60)之内完成,也就是说每一次重绘小于 16ms 才不会卡顿掉帧。
浏览器的一帧说的就是一次完整的重绘。一次 重绘 需要做 如下:

# 帧时间的计算
要实现 requestIdleCallback 最主要的就是 deadline.timeRemaining(),主要看下面两点描述
当前帧结束时间 我们知道 requestAnimationFrame 的回调被执行的时机是当前帧开始绘制之前。也就是说 rafTime 是当前帧开始时候的时间,如果按照每一帧执行的时间是 16.66ms。那么我们就能算出当前帧结束的时间, frameDeadline = rafTime + 16.66
当前帧剩余时间 当前帧剩余时间 = 当前帧结束时间(frameDeadline) - 当前帧花费的时间。关键是我们怎么知道 '当前帧花费的时间',这个是怎么算的,这里就涉及到 JS 事件循环的知识。react 中是用 MessageChannel (opens new window)实现的
# requestIdleCallback 的实现
下面我们使用 requestAnimationFrame 和 MessageChannel 实现一个简单的 requestIdleCallback
let frameDeadline; // 当前帧的结束时间
let penddingCallback; // requestIdleCallback的回调方法 也就是 react 执行 performSyncWorkOnRoot
let channel = new MessageChannel();
// 当执行此方法时,说明requestAnimationFrame的回调已经执行完毕,此时就能算出当前帧的剩余时间了,直接调用timeRemaining()即可。
// 因为MessageChannel是宏任务,需要等主线程任务执行完后才会执行。我们可以理解requestAnimationFrame的回调执行是在当前的主线程中,只有回调执行完毕onmessage这个方法才会执行。
channel.port2.onmessage = function() {
// 判断当前帧是否结束
// timeRemaining()计算的是当前帧的剩余时间 如果大于0 说明当前帧还有剩余时间
let timeRema = timeRemaining();
if (timeRema > 0) {
// 执行回调并把参数传给回调
penddingCallback &&
penddingCallback({
// 当前帧是否完成
didTimeout: timeRema < 0,
// 计算剩余时间的方法
timeRemaining,
});
}
};
// 计算当前帧的剩余时间
function timeRemaining() {
// 当前帧结束时间 - 当前时间
// 如果结果 > 0 说明当前帧还有剩余时间
return frameDeadline - performance.now();
}
window.requestIdleCallback = function(callback) {
requestAnimationFrame((rafTime) => {
// 算出当前帧的结束时间 这里就先按照16.66ms一帧来计算
frameDeadline = rafTime + 16.66;
// 存储回调
penddingCallback = callback;
// 这里发送消息,MessageChannel是一个宏任务,也就是说上面onmessage方法会在当前帧执行完成后才执行
channel.port1.postMessage(null);
});
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
在前面提到了 requestAnimationFrame的回调函数会被传入 DOMHighResTimeStamp 参数,该参数与 performance.now() (opens new window) 的返回值相同,它表示 requestAnimationFrame() 开始去执行回调函数的时刻
performance.now() 是什么呢?
我的理解就是从页面打开开始 performance.now() 就会从 0 开始一直计时,随着在当前页面停留的时间增长这个时间不断累加的一个值,其实就类似一个计时器。官网的说法是 performance.timing.navigationStart + performance.now() 约等于 Date.now()。
# React 中 requestIdleCallback 的 polyfill 实现
在 chrome (opens new window) 文档上,可以看到原生提供的 requestIdleCallback 方法的 timeRemaining() 最大返回是 50ms,也就是 20fps,达不到页面流畅度的要求,并且该 API 兼容性也比较差。
在 react/packages/scheduler/src/forks/SchedulerHostConfig.default.js 下,React 实现了对 requestIdleCallback 的 polyfill。
针对 DOM 下和非 DOM 环境下实现了两套方案,我们先来看下该文件暴露出去的方法。
export let requestHostCallback; // requestIdleCallback的polyfill方法
export let cancelHostCallback; // 用于取消requestHostCallback
export let requestHostTimeout;
export let cancelHostTimeout; // 用于取消requestHostTimeout
export let shouldYieldToHost;
export let requestPaint;
export let getCurrentTime; // 获取当前触发事件
export let forceFrameRate; // 设置渲染的fps
2
3
4
5
6
7
8
我们这里只看在 DOM 环境下的实现
if (typeof console !== "undefined") {
const requestAnimationFrame = window.requestAnimationFrame;
const cancelAnimationFrame = window.cancelAnimationFrame;
// ....
}
2
3
4
5
首先是对 requestAnimationFrame 和 cancelAnimationFrame 两方法做了是否有的判断,并做了 error 的提示,实际上,在本文件下是没有使用这两个方法的,注释是说以防未来可能有使用
# getCurrentTime
if (typeof performance === "object" && typeof performance.now === "function") {
getCurrentTime = () => performance.now();
} else {
const initialTime = Date.now();
getCurrentTime = () => Date.now() - initialTime;
}
2
3
4
5
6
在 DMOM 环境下优先使用 performance.now() 不支持再实现 Date
# forceFrameRate
forceFrameRate = function(fps) {
if (fps < 0 || fps > 125) {
// message
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
yieldInterval = 5;
}
};
2
3
4
5
6
7
8
9
10
11
12
该方法用于设置渲染的 fps 仅支持 0-125fps的,超出的做 error 提示。然后根据 fps 计算 yieldInterval 每帧的时间。
# requestHostCallback
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
// 及时的任务
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
2
3
4
5
6
7
8
9
10
11
requestHostCallback 在非 DOM 环境下用 setTimeout 实现的,在 DOM 是使用 MessageChannel 实现的,通过 MessageChannel 双通道来处理任务,messageChannel 属于宏认为,异步执行
port.postMessage, 触发 performWorkUntilDeadline 函数
// 1. 执行flushwork
// 2. 判断有没有更多的任务,有更多的任务,在下一个事件循环里再继续调用performWorkUntilDeadline(异步的递归)
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// 设置截止时间,刚开始为5ms,后面渐渐动态调整
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
// scheduledHostCallback为传入的callback,此处为 flushWork
// 执行flushwork——递归执行taskQuene里的callBack,也就是 workLoop
const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
if (!hasMoreWork) {
// 没有更多任务, 重置消息循环状态, 清空回调函数
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// 有更多任务,在下一个循环里继续调度
port.postMessage(null);
}
} catch (error) {
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
我们可以在源码 scheduler/src/flushWork 中看到如下代码:
...
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
// 1. 执行workLoop
// 2. 把每一个taskQuene的任务,调用 performSyncWorkOnRoot
function flushWork(hasTimeRemaining, initialTime) {
...
try {
if (enableProfiling) {
try {
return workLoop(hasTimeRemaining, initialTime);
} catch (error) {
if (currentTask !== null) {
const currentTime = getCurrentTime();
markTaskErrored(currentTask, currentTime);
currentTask.isQueued = false;
}
throw error;
}
} else {
// No catch in prod codepath.
return workLoop(hasTimeRemaining, initialTime);
}
} finally {
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
这样一来 就和我们的 React 里面的代码流程对应上了,和我们之前用 requestIdleCallback 模拟 workLoop 流程一致了
# cancelHostCallback
cancelHostCallback = function() {
scheduledHostCallback = null;
};
2
3
置空回调,这样在 performWorkUntilDeadline 中就不会执行对应的函数了,相当于取消了 requestHostCallback。
# requestHostTimeout
requestHostTimeout = function(callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getCurrentTime());
}, ms);
};
2
3
4
5
基于 setTimeout,向回调传入当前时间作为参数。
# cancelHostTimeout
cancelHostTimeout = function() {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
};
2
3
4
requestHostTimeout 的取消方法,即 clearTimeout。
# shouldYieldToHost
shouldYieldToHost = function() {
const currentTime = getCurrentTime();
if (currentTime >= deadline) {
if (needsPaint || scheduling.isInputPending()) {
return true;
}
return currentTime >= maxYieldInterval;
} else {
return false;
}
};
2
3
4
5
6
7
8
9
10
11
shouldYieldToHost 根据当前时间是否大于最后的预期时间