为了更好的理解 React Fiber,我们可以先看看 requestIdleCallback,它是 react fiber 中用到的一个核心 api
😇 在读完这篇文章后,你将知道下面问题的答案:
- 什么是 requestIdleCallback,它能做什么?
- 它的执行时机是什么?
- 它跟React fiber架构有什么关系?
在 MDN 中,它是这么描述 requestIdleCallback
window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
简单的说,就是判断一帧有空闲时间,则去执行某个任务。 目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务)无法及时响应,而带来的页面丢帧(卡死)的情况。
所以 requestIdleCallback 定位处理的是: 不重要且不紧急的任务
基本语法
js
// 返回值 一个ID,可以把它传入 Window.cancelIdleCallback() 方法来结束回调。
const handleId = requestIdleCallback(callback, options?)
// 取消回调
Window.cancelIdleCallback(handleId)
释义 | |
---|---|
callback | 一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。 |
options | - timeout:如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队(有可能对性能产生负面影响) |
⭐️ 强烈建议使用timeout选项进行必要的工作,否则可能会在触发回调之前经过几秒钟。
why ?
这是由于 requestIdleCallback 利用的是帧的空闲时间,所以当浏览器一直处于繁忙状态,导致回调一直无法执行,这其实也并不是我们期望的结果(如上报丢失),所以面对这种情况,我们就需要用 timeout 进行解决。
js
type Deadline = {
timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
didTimeout: boolean // 是否超时。
}
function callback(deadline: Deadline) {
// deadline 上面有一个 timeRemaining() 方法,能够获取当前浏览器的剩余空闲时间,单位 ms;有一个属性 didTimeout,表示是否超时
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
if (deadline.timeRemaining() > 1 || deadline.didTimeout) {
// 走到这里,说明时间有余,我们就可以在这里写自己的代码逻辑
}
// 走到这里,说明时间不够了,就让出控制权给主线程,下次空闲时继续调用
requestIdleCallback(work);
}
requestIdleCallback(callback, { timeout: 1000 });
当然你也会遇到一种情况
其中 4 表示返回的 ID,49.9 表示预估的剩余毫秒数。
我们都知道现在的广泛屏幕刷新率为 60hz,一般情况下渲染一帧时长应该控制在16.67ms (1s / 60 = 16.67ms),那么这 49.9 是怎么来的呢?
我们带着这个问题,继续往下看
执行时机
在 requestIdleCallback 的 W3C 规范中,他是这样定义的:
在输入处理、渲染和合成给定帧完成后,用户代理的主线程通常会变得空闲,直到下一帧开始;另一个挂起的任务有资格运行,或者收到用户输入。requestIdleCallback 定义的回调就在空闲时间执行。
帧间空闲周期示例
这样的空闲时段将在活动动画和屏幕更新期间频繁地出现,但是通常将非常短(即,对于具有60Hz vsync周期的设备,小于16 ms)
另一个示例是当用户代理空闲而没有屏幕刷新发生的时候,这种情况下,因为没有任务出现限制空闲时期的时间,为了避免在不可预测的任务中引起用户可感知的延迟(例如用户输入),这些空闲时段的长度应被限制为 50ms 的最大值。当一个 50ms 空闲时期结束后,还是空闲状态,就会再开启另一个 50ms 的空闲时期:
没有挂起帧更新时的空闲周期示例
如果存在屏幕刷新,浏览器会计算当前帧剩余时间,如果有空闲时期,就会执行 requestIdleCallback 回调,如果不存在屏幕刷新,浏览器会安排连续的长度为 50ms 的空闲时期。
用户对性能延迟的感知
0 至 16 毫秒 | 用户非常擅长跟踪运动,如果动画不流畅,他们就会不喜欢。只要每秒渲染 60 帧,这类动画就会感觉很流畅。也就是每帧 16 毫秒(包括浏览器将新帧绘制到屏幕上所需的时间),让应用生成一帧大约 10 毫秒。 |
---|---|
0 至 100 毫秒 | 在此时间范围内响应用户操作,让用户感觉能够立竿见影。时间再长,操作与反应之间的连接就会中断。 |
100 至 1000 毫秒 | 在此窗口中,事情感觉像是任务自然和持续推进的一部分。对于网络上的大多数用户,加载页面或更改视图代表着一个任务。 |
1000 毫秒或以上 | 一旦超过 1,000 毫秒(1 秒),用户就会失去专注于他们正在执行的任务的注意力。 |
10000 毫秒或更长 | 一旦超过 10,000 毫秒(10 秒),用户就会感到沮丧,并可能放弃任务。他们以后不一定会回来。 |
为什么要 50ms 呢?
Google 在核心网页指标中提出了衡量互动(FID):为了提供良好的用户体验,页面的 FID 不应超过 100 毫秒。
那么为什么这里需要 50 ms 呢?RAIL 给出了答案
RAIL 中提出了最大限度地延长空闲时间的准则:
-
利用空闲时间完成推迟的工作。例如,对于初始网页加载,请尽可能少加载数据,然后使用空闲时间加载其余数据。
-
在空闲时间不超过 50 毫秒时执行工作。如果时间更长,则可能会干扰应用在 50 毫秒内响应用户输入的能力。
-
如果用户在空闲时间工作期间与页面交互,则用户互动应始终具有最高优先级,并中断空闲时间工作。
总结:timeRemaining 最大为 50ms,是有根据研究得出的,即是说人对用户输入的 100 毫秒以内的响应通常被认为是瞬时的,不会被人察觉到。将空闲时间限制在 50ms 内意味着即使在闲置任务开始后立即发生用户操作,用户代理仍然有剩余的 50ms 可以在其中响应用户输入而不会产生用户可察觉的滞后。
执行次数
在 react 的 [Umbrella] Releasing Suspense 中提到
requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work
我们都知道一般 FPS 为 60 hz 时对用户来说是感觉流畅的,即一帧时间为 16.7 ms,requestIdleCallback 的 FPS 只有 20,也就是 50ms 刷新一次,远远低于页面流畅度的要求
兼容性与 polyfill
mdn 提供的 requestIdleCallback polyfill
js
window.requestIdleCallback =
window.requestIdleCallback ||
function (handler) {
let startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
window.cancelIdleCallback =
window.cancelIdleCallback ||
function (id) {
clearTimeout(id);
};
这个并不是 polyfill ,因为它在功能上并不相同;setTimeout() 并不会让你利用空闲时段,而是使你的代码在情况允许时执行你的代码,以使我们可以尽可能地避免造成用户体验性能表现延迟的后果,但它至少将每次传递的运行时间限制为不超过 50 毫秒。
⚠️ 注意事项
- 避免在空闲回调中改变 DOM。
空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算 ,而从另一方面来看,这是不必要的。如果你的回调需要改变 DOM,它应该使用 Window.requestAnimationFrame()来调度它。
- 避免运行时间无法预测的任务。
你的空闲回调必须避免做任何占用时间不可预测的事情。比如说,应该避免做任何会影响页面布局的事情。你也必须避免 执行Promise(en-US) 的 resolve 和 reject,因为这会在你的回调函数返回后立即引用 Promise 对象对 resolve 和 reject 的处理程序。
- 在你需要的时候要用 timeout,但记得只在需要的时候才用。
使用 timeout 可以保证你的代码按时执行,但是在剩余时间不足以强制执行你的代码的同时保证浏览器的性能表现的情况下,timeout 就会造成延迟或者动画不流畅。
搭配 requestAnimationFrame
requestAnimationFrame 应该都是我们的老朋友了,如果不是很懂的可以看看这篇文档,传送门
上文提到 MDN 建议 requestIdleCallback 不应该在空闲回调中改变 DOM,而是使用 requestAnimationFrame 来调度它,那么如何去使用它们呢?
todo ....
🤔 使用场景
数据的分析和上报
- 在用户有操作行为时(如点击按钮、滚动页面)进行数据分析并上报。
- 处理数据时往往会调用 JSON.stringify ,如果数据量较大,可能会有性能问题。
此时我们就可以使用 requestIdleCallback 调度上报时机,避免上报阻塞页面渲染。
js
const queues = [];
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', e => {
// do something...
pushQueue({
type: 'click'
// ...
}));
schedule(); // 等到空闲再处理
});
});
function schedule() {
requestIdleCallback(deadline => {
while (deadline.timeRemaining() > 1) {
const data = queues.pop();
// 这里就可以处理数据、上传数据
}
if (queues.length !== 0) {
// 继续上传调用
schedule();
}
});
}
预加载
在空闲的时候加载些东西,可以看看 qiankun 的例子,用来预加载 js 和 css
js
/**
* prefetch assets, do nothing while in mobile network
* @param entry
* @param opts
*/
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
检测卡顿
-
测量 fps 值,如果连续出现几个 fps 值 ≤ 阈值,则认为是卡顿
-
开辟一个 worker 线程和主线程之间来个心跳检测,一段时间内没响应,则认为是卡顿
与 React fiber 的关系
这里,我直接下一个定论,react 并没有使用了 requestIdleCallback 来解决 stack 的问题,但 react 自主实现的调度算法与 requestIdleCallback 息息相关,那么为什么要放弃它而选择自主实现呢?
- 浏览器兼容性,目前并不是所有浏览器都支持这个 API
- 触发频率不稳定
- FPS 只有 20, 这远远低于页面流畅度的要求(主要原因)