前言
React Fiber 架构下使用 Scheduler 调度器特性 -「时间切片」进行「任务调度」,即:每执行一段时间的任务,就把主线程交还给浏览器,避免长时间占用主线程。
试想,触发一次 React 更新动作,会经过 Reconciler 调和阶段进行 Diff 的比较以及组件重渲染,如果组件 render 耗时过长或参与调和的 Fiber 节点很多,JS 执行时间过长就会带来页面卡住现象(无响应)。
在 Fiber 架构下,每一个虚拟 DOM 都是一个「任务执行单元」,而不是整个 Fiber 树一次全进入调和阶段。这样,借助 Scheduler 时间切片特性去调用 workLoop 循环执行任务,在需要让出主线程的时候中断任务,等待下一次合适的时间继续执行。
Scheduler「时间切片」的本质是为了模拟 requestIdelCallback 实现 JS 任务执行和浏览器渲染合理分配的运行在每一帧上,它们的区别在于:
requestIdelCallback会在每一帧任务执行后存在剩余时间时,允许用户调用它来执行自己定义的代码,当没有剩余时间时,会将执行权交还给浏览器;React 时间切片则是通过宏任务 MessageChannel 实现高频短间隔 5ms执行 JS 逻辑,之后会将执行权交给浏览器去做渲染,并开启下一个异步请求,待浏览器工作完成后继续执行此任务。
任何 连续、可中断 的流程都可以使用 Scheduler 来调度。
扩展:除去“浏览器重排/重绘”,浏览器在一帧中可以用于执行 JS 的时机如下:
一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback
接下来,我们将从三种角度来看看「时间切片」的发展史:
requestIdleCallback,浏览器 API;requestAnimationFrame,React 旧方案;高频短间隔调度任务,React 新方案。
另一篇「React Scheduler - 优先级调度」可以看这里。
一、requestIdleCallback
requestIdleCallback(下文简称 rIC)是浏览器提供的一个原生 API,它会判断一帧的任务执行是否留有空闲时间,去执行特定任务(不重要且不紧急的任务),目的是解决执行任务长时间占用主线程,导致高优先级任务(动画、事件)无法及时响应,带来的页面丢帧(卡死)情况。
rIC 语法使用如下:
window.requestIdleCallback(callback[, options]);
- callback 为要执行的回调函数,它接收
deadline作为对象。deadline 包含两个属性:
type Deadline = {
timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
didTimeout: boolean // 是否超时。
}
-
options 可以指定超时时间 options.timeout,如果任务还没有被排上执行,则会强制执行。
-
使用示例:
let appendTotal = 10000;
let count = 0;
const workLoop = (deadline) => {
// 1、执行切片任务,当没有剩余时间时,终止执行
while (deadline.timeRemaining() > 0 && count < appendTotal) {
for (let i = 0; i < 10; i ++) {
const div = document.createElement('div');
div.innerHTML = 'box';
document.body.appendChild(div);
}
count ++;
}
// 2、存在未执行完成的任务,开启下次空闲调度执行
if (count < appendTotal) {
console.log('再次开启空闲调度', count);
requestIdleCallback(workLoop);
} else {
console.log('任务执行完成');
}
}
console.log('开启空闲调度');
requestIdleCallback(workLoop);
虽然浏览器提供了 API 实现「任务空闲调度」,但它存在一些缺陷使得 React 团队并未采用它。
- 一帧的执行时间存在偏差,导致留给工作执行的时间不确定(不稳定);
- 浏览器兼容不好,其中 safari 浏览器根本不支持它。
二、requestAnimationFrame
最初,React 采用 requestAnimationFrame(下文简称 rAF)+ MessageChannel 代替 rIC 使任务调度与帧对齐。
具体实现分为以下两步:
- 判断一帧是否有剩余时间;
- 存在剩余时间时,执行任务。
2.1、计算一帧的过期时间
rAF 会在每一帧任务的绘制之前执行,接收一个回调函数,回调函数的参数 rafatime 为执行当前帧的开始时间。
语法如下:
window.requestAnimationFrame(callback);
我们把一帧的执行时间控制在 16.67ms,可以推算出一帧的过期时间:
let deadlineTime;
requestAnimationFrame(rafTime => {
// 一帧的执行结束时间 = 一帧开始时间 + 用时 16.67ms
deadlineTime = rafTime + 16.67;
console.log(rafTime, deadlineTime);
});
2.2、执行任务
MessageChannel 接口允许我们创建一个新的消息通道,并通过该通道的两个 Port 进行通信。选择 MessageChannel 实现时间切片,目的就是为了产生宏任务。
React 为实现暂停 JS 任务执行,将主线程交还给浏览器,让浏览器有机会执行页面渲染,就需要借助事件循环的「宏任务」。
因为宏任务会在下次事件循环中执行,不会阻塞本次页面渲染更新。之所以不选择微任务,是因为「微任务是在本次页面更新前会全部执行」,这一点与同步执行无异,不会让出主线程。
我们结合这两步看看最初 React 时间切片的实现:
// 计算出当前帧 结束时间点
var deadlineTime;
// 保存任务
var callback;
// 建立通信
var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;
// 接收并执行宏任务
port2.onmessage = () => {
// 判断当前帧是否还有空闲,即返回的是剩下的时间
const timeRemaining = () => deadlineTime - performance.now();
const _timeRemain = timeRemaining();
// 有空闲时间 且 有回调任务
if (_timeRemain > 0 && callback) {
const deadline = {
timeRemaining, // 计算剩余时间
didTimeout: _timeRemain < 0 // 当前帧是否完成
}
// 执行回调
callback(deadline);
}
}
window.requestIdleCallback = function (cb) {
requestAnimationFrame(rafTime => {
// 结束时间点 = 开始时间点 + 一帧用时16.667ms
deadlineTime = rafTime + 16.667;
// 保存任务
callback = cb;
// 发送个宏任务
port1.postMessage(null);
})
}
三、requestHostCallback
由于 rAF 仰仗显示器的刷新频率,太过依赖设备本身运作流程,存在不稳定性。为了在每一帧尽可能多的执行任务,React 团队采用了 5ms 间隔的宏任务消息事件来发起任务调度。
在 Scheduler 包中,SchedulerHostConfig.default.js 文件提供了 requestHostCallback 核心实现。
在源码中会根据环境实现两套 API:在不支持 MessageChannel 的环境下使用 setTimeout 作为备选方案,否则使用 MessageChannel,我们重点看这部分实现。
3.1、相关变量
export let requestHostCallback; // 处理 taskQueue 任务
export let cancelHostCallback;
export let requestHostTimeout; // 处理 timerQueue 任务
export let cancelHostTimeout;
export let shouldYieldToHost; // 是否让出主线程(currentTime >= deadline)
export let getCurrentTime; // 获取当前时间
export let forceFrameRate; // 根据 FPS 计算每一帧时长
Scheduler 会将任务分为两种类型:taskQueue 和 timerQueue,
- taskQueue 队列中存放的是需要立即执行的任务(已就绪任务);
- timerQueue 队列中存放的是可以延期执行的任务(未就绪任务)。
所以分别提供了两种调度任务方式:requestHostCallback 和 requestHostTimeout。
shouldYieldToHost 会被用在外部 workLoop 循环执行任务时,确定是否需要中断执行,让出主线程。
3.2、具体实现
在源码中,通过 MessageChannel 创建一个消息通道,当用户执行 requestHostCallback 调度 callback 时,便会通过 postMessage 发起一个宏任务进入 performWorkUntilDeadline 方法。
if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
// 非浏览器环境,或不支持 MessageChannel,会使用 setTimeout 宏任务来实现
} else {
// 保存 api 引用,防止 polyfill 覆盖它们
const setTimeout = window.setTimeout;
const clearTimeout = window.clearTimeout;
getCurrentTime = () => performance.now(); // 页面加载后开始计算
let isMessageLoopRunning = false; // 标记 MessageChannel 正在运行
let scheduledHostCallback = null; // 要执行的处理函数
let taskTimeoutID = -1; // 用作终止 setTimeout 延迟任务
// 定义每一帧工作时间,默认时间为 5ms,React 会根据浏览器主机环境进行重新计算。
let yieldInterval = 5;
let deadline = 0; // 过期时间,让出主线程
// 让出主线程
shouldYieldToHost = function () {
return getCurrentTime() >= deadline;
};
// (可选方法)默认空闲执行时间是5ms,用户可通过该方法来根据不同用户主机的设备刷新率(FPS)来计算预留时间
forceFrameRate = function (fps) {
if (fps < 0 || fps > 125) {
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
yieldInterval = 5;
}
};
// 开启高频短间隔 5ms 执行工作
const performWorkUntilDeadline = () => {
...
};
// 定义宏任务,建立通信
const channel = new MessageChannel();
const port = channel.port2; // 用于发布任务
channel.port1.onmessage = performWorkUntilDeadline; // 处理任务
requestHostCallback = function (callback) {
scheduledHostCallback = callback; // 保存任务
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null); // 发起宏任务
}
};
cancelHostCallback = function () {
scheduledHostCallback = null;
};
requestHostTimeout = function (callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getCurrentTime());
}, ms);
};
cancelHostTimeout = function () {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
};
}
在 performWorkUntilDeadline 方法中,基于 yieldInterval 计算得到一个执行过期时间 deadline,也就是「高频短间隔 5ms」。
当用户在 callback 中通过调用 shouldYieldToHost() 发现执行时间过期且还存在未完成的任务,可在 callback 函数中返回 true,performWorkUntilDeadline 会先将主线程交给浏览器,再开启一个宏任务等待执行。
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime(); // 拿到当前时间
// 根据 yieldInterval(5ms)计算剩余时间(任务执行截止时间)。这种方式意味着 port.postMessage 开始后总有剩余时间
deadline = currentTime + yieldInterval;
// 标识还有时间,类似 requestIdleCallback deadline.didTimeout
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime,
);
// 执行完成,没有新任务,初始化工作环境
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// 如果任务截止时间过期(根据 shouldYieldToHost()),还有需要处理的工作,再发起一个异步宏任务
port.postMessage(null);
}
} catch (error) {
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
};
利用宏任务异步的机制,以高频(短间隔)5ms 的方式去对任务进行切片执行,每隔 5ms 让出执行权给浏览器看它是否有渲染工作要做,浏览器做完工作或没有工作要做时,根据 EventLoop 的运行机制会再次进入到下一个宏任务中,接着上次的任务继续执行。
之所以选择宏任务,不选择微任务,是因为微任务会在页面更新前全部执行完成,无法做到将主线程交还给浏览器,而宏任务可以。
此外,setTimeout 仅作为宏任务的备选方案,这是因为:
当递归执行 setTimeout(fn, 0) 时,间隔会由最初的 1ms 变成 4ms。如果使用它来实现 Scheduler,就会浪费 4 毫秒。因为 60 FPS 下要求每帧间隔不超过 16.66 ms,所以 4ms 存在浪费。
你可以将下面代码运行在浏览器查看运行结果:
var count = 0;
var startVal = +new Date();
console.log("start time", 0, 0);
function func() {
setTimeout(() => {
console.log("exec time", ++count, +new Date() - startVal);
if (count === 20) return;
func();
}, 0)
}
func();
四、使用
现在,我们通过一个简单 DOM 来验证一下 requestHostCallback 实现是否有用。
假设页面上有两个 DOM 节点,我们要去访问 DOM 属性(DOM 操作会影响程序执行效率),时间切片的任务数量为 5000 次。如果我们直接同步执行这 5000 次任务,会造成页面渲染卡顿大约 3s 时间(本机测试),在这期间无法对页面进行任何操作(如点击按钮)。代码如下:
<body>
<button id="btn1">按钮1</button>
<button id="btn2">按钮2</button>
<script>
let workIndex = 0;
let taskTotal = 5000; // 任务数量
const start = Date.now();
function handleWork() {
for (let j = 0; j < 4000; j ++) {
// DOM 操作严重影响程序执行效率
const btn1Attr = document.getElementById('btn1').attributes;
const btn2Attr = document.getElementById('btn2').attributes;
const btn3Attr = document.getElementById('btn1').attributes;
const btn4Attr = document.getElementById('btn2').attributes;
}
workIndex ++;
if (workIndex >= taskTotal) {
console.log(`任务调度完成,用时:`, Date.now() - start, 'ms!');
}
}
while (workIndex < taskTotal) {
handleWork();
}
</script>
</body>
现在我们改用 requestHostCallback 高频 5ms 宏任务方式去执行,页面渲染不会出现卡顿,点击按钮能够正常响应。代码如下:
<script>
let workIndex = 0;
let taskTotal = 5000; // 任务数量
const start = Date.now();
function handleWork() {
for (let j = 0; j < 4000; j ++) {
// DOM 操作严重影响程序执行效率
const btn1Attr = document.getElementById('btn1').attributes;
const btn2Attr = document.getElementById('btn2').attributes;
const btn3Attr = document.getElementById('btn1').attributes;
const btn4Attr = document.getElementById('btn2').attributes;
}
workIndex ++;
if (workIndex >= taskTotal) {
console.log(`任务调度完成,用时:`, Date.now() - start, 'ms!');
}
}
// while (workIndex < taskTotal) {
// handleWork();
// }
function workLoop() {
// 执行 shouldYieldToHost 来判断本次宏任务的 高频(短间隔)5ms 时间切片是否用尽
while (!shouldYieldToHost() && workIndex < taskTotal) {
handleWork();
}
if (workIndex < taskTotal) {
console.log(`开启下一个宏任务继续执行剩余任务`);
return true;
} else {
return false;
}
}
requestHostCallback(workLoop);
</script>
最后
感谢阅读,如有不足之处,欢迎指正。
借鉴:
1. 字节教育 - 实现 React requestIdleCallback 调度能力
2. React Scheduler 为什么使用 MessageChannel 实现
3. 探索 React 的内在 —— postMessage & Scheduler
4. React 技术揭秘