1、什么是 requestIdleCallback
一句话说明
requestIdleCallback
是一个还在实验中的 api,可以让我们在浏览器空闲的时候做一些事情。
先来看下它的简单用法,大家可以直接复制下面的代码在控制台中执行:
function work(deadline) { // deadline 上面有一个 timeRemaining() 方法,能够获取当前浏览器的剩余空闲时间,单位 ms;有一个属性 didTimeout,表示是否超时
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
if (deadline.timeRemaining() > 1 || deadline.didTimeout) {
// 走到这里,说明时间有余,我们就可以在这里写自己的代码逻辑
}
// 走到这里,说明时间不够了,就让出控制权给主线程,下次空闲时继续调用
requestIdleCallback(work);
}
requestIdleCallback(work, { timeout: 1000 }); // 这边可以传一个回调函数(必传)和参数(目前就只有超时这一个参数)
如果你运行了并且页面没有什么操作的话,打印出来的时间大部分会是在 49.9ms 左右;如果你稍微晃几下鼠标,打印出来的时间大部分会小于 16ms,因为此时浏览器不空闲了。
什么是空闲时间?
我们知道页面是一帧一帧绘制出来的,一般情况下每秒 60 帧对我们来说就是流畅的,相对应的每帧时间大概是 16ms,所以如果每帧要执行的东西(task + render + ...)的时间小于 16ms,就说明有空闲时间可以利用,如下图所示(从 W3C 上面弄的😄):
另外还有一种空闲的情况就是页面长时间没操作的时候,这时候
requestIdleCallback
的剩余时间会尽可能延长,最多是 50ms,我们看下图(巧了,也是从 W3C 上面弄的😄):
所以我们可以看到一开始的实验中,不操作页面大概率会打印 49.9 ms,动动鼠标就会小于 16 ms。
那为啥是 50ms 呢?简单理解就是这是一个经验值或者统计值,因为如果空闲时间给的太长,期间如果有高优任务(如键盘事件)产生,就不能够很好的响应,可能会感觉到一丢丢延迟。
可以做什么事情?🤔
做一些非高优、可拆分、可控制的任务。(仿佛说了又仿佛没说,所以让我们来看看下面的具体例子吧)
数据的分析和上报
- 在用户有操作行为时(如点击按钮、滚动页面)进行数据分析并上报。
- 处理数据时往往会调用
JSON.stringify
,如果数据量较大,可能会有性能问题。 此时我们就可以使用requestIdleCallback
调度上报时机,避免上报阻塞页面渲染,下面是简单的代码示例(可跳过)。
const queues = [];
const btns = btns.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) schedule();
});
}
预加载
这个就比较好理解了,在空闲的时候加载些东西,可以看看 qiankun 的例子,用来预加载 js 和 css,如下图所示:
与之对应的还有个预渲染,道理类似🐱。
检测卡顿
一般检测的卡顿方法有两种:
- 测量 fps 值,如果连续出现几个 fps 值 ≤ 阈值,则认为是卡顿
- 开辟一个 worker 线程和主线程之间来个心跳检测,一段时间内没响应,则认为是卡顿
回过头来,如果
requestIdleCallback
长时间内没能得到执行,说明一直没有空闲时间,很有可能就是发生了卡顿,从而可以打点上报。它比较适用于行为卡顿,举个例子:点击某个按钮并同时添加我们的requestIdleCallback
回调,如果点击后的一段时间内这个回调没有得到执行,很大概率是这个点击操作造成了卡顿。
拆分耗时任务
这个思想在React 中的调度器 Scheduler里面展现的淋漓尽致,虽然 React 自己实现了一套调度逻辑(兼容性、稳定性和优先级等原因),不过不妨碍我们理解。
简单来说 React 把 diff 的过程从早前的递归变成了现在的迭代,对两个大对象进行递归 diff 就是个耗时的任务,如果能够拆解成小任务,那该有多好。但是递归又不能中途终止,所以 React 采用了 fiber 这种数据结构,把递归变成了链表迭代,迭代就可以中途停止,我们就不用一次性 diff 完。
ps:不懂链表的同学就简单理解成是数组吧,你想想如果我们要把数组进行遍历,我们可以一次性执行完,但是我们也可以拆成几次执行完,只要我们记录个 index,下次回来继续执行代码的时候就从 index 开始遍历就行,不知道大家 get 到木有。
2、简单模拟下 requestIdleCallback
目前大体有两种方法模拟:
用 setTimeout 实现
首选大家要知道一个前提,为什么能够 setTimeout
来模拟,所以我们先简单看下下面这两行代码:
// 某种程度上功能相似,写法也相似
requestIdleCallback(() => console.log(1));
setTimeout(() => console.log(2));
了解过 setTimeout
的同学应该知道这个东西它不准,上面那样写并不是立刻执行的意思,而是尽可能快的执行,就是等待主线程为空,微任务也执行完了,那么就可以轮到 setTimeout
执行了,所以 setTimeout(fn)
某种程度上讲也有空闲的意思,了解了这个点我们就可以用它来模拟啦,直接看下面的代码即可,就是在 setTimeout
里面多了个构造参数的步骤:
window.requestIdleCallback = function(cb) {
let start = Date.now();
return setTimeout(function () {
const deadline = { // 这边就是为了构造参数
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), // 剩余时间我们写死在 50ms 内,也就是前面提到的上限值,其实你也可以写成 40、30、16、10 等😂
didTimeout: false // 因为我们不推荐使用 timeout 参数,所以这里就直接写死 false
};
cb(deadline);
});
}
要注意的是,这个并不是 requestIdleCallback
的 polyfill ,因为实际上它们并不相同。setTimeout
并不算是真正的利用空闲时间,而是在条件允许的情况下尽可能快的执行你的代码。上面的代码并不会像真正的 requestIdleCallback
那样将自己限制在这一帧的空闲时间内,但是它达到了两个效果,一个是将任务分段,一个是控制每次执行的时间上限。一般满足这两个条件的就是宏任务了,所以除了 setTimout
外,postMessage
也是可以实现的。接下来我们来看看模拟的另一种方法。
用 requestAnimationFrame + MessageChannel 实现
let deadlineTime // 当前帧结束时间
let callback // 需要回调的任务
let channel = new MessageChannel(); // postMessage 的一种,该对象实例有且只有两个端口,并且可以相互收发事件,当做是发布订阅即可。
let port1 = channel.port1;
let port2 = channel.port2;
port2.onmessage = () => {
const timeRemaining = () => deadlineTime - performance.now();
if (timeRemaining() > 1 && callback) {
const deadline = { timeRemaining, didTimeout: false }; // 同样的这里也是构造个参数
callback(deadline);
}
}
window.requestIdleCallback = function(cb) {
requestAnimationFrame(rafStartTime => {
// 大概过期时间 = 默认这是一帧的开始时间 + 一帧大概耗时
deadlineTime = rafStartTime + 16
callback = cb
port1.postMessage(null);
});
}
上面这种方式会比 setTimeout
稍好一些,因为 MessageChannel
的执行在 setTimeout
之前,并且没有 4ms 的最小延时。
那为什么不用微任务模拟呢?因为如果你用微任务模拟的话,在代码执行完之后,所有的微任务就会继续全部执行,不能及时的让出主线程。
ps:这两种方法都不是 polyfill,只是尽可能靠近 requestIdleCallback
,并且剩余时间也是猜测的。
3、⚠️注意事项
避免在回调中更改 dom
- 因为我们本来就是利用渲染后的时间,期间操作 dom 或者读取某些元素的布局属性大概率会造成重新渲染。
- 操作 dom 所带来的时间影响是不确定的,可能会导致重排重绘,所以这类操作是不可控的。
requestIdleCallback
不会和帧对齐(不应该期望每帧都会调用此回调),所以涉及到 dom 操作的话最好放在requestAnimationFrame
中执行,我们拿渲染十万条数据举个例子,就像下面这样(可跳过):
<div><button id="btn1">渲染十万条</button><input></div>
<div><button id="btn2">requestIdleCallback 渲染十万条</button><input></div>
<ul id="list1"></ul>
<ul id="list2"></ul>
<script>
// 方案一:无脑添加
const NUM1 = 100000;
let list1 = document.getElementById("list1");
document.getElementById("btn1").addEventListener('click', bigInsert1);
function bigInsert1() {
let i = 0;
while (i < NUM1) {
let item = document.createElement("li");
item.innerText = `第${i++}条数据`;
list1.appendChild(item);
}
}
// 方案二:时间切片
const NUM2 = 100000
let list2 = document.getElementById("list2");
let f = document.createDocumentFragment();
let i = 0;
document.getElementById("btn2").addEventListener('click', () => {
requestIdleCallback(bigInsert2);
});
function bigInsert2(deadline) {
while (deadline.timeRemaining() > 1 && i < NUM2) {
console.log('空闲执行中');
let item = document.createElement("li");
item.innerText = `第${i++}条数据`;
f.appendChild(item);
if (f.children.length >= 100) break; // 每次渲染 100 条
}
f.children.length && requestAnimationFrame(() => {
list2.appendChild(f);
f = document.createDocumentFragment();
});
if (i < NUM2) requestIdleCallback(bigInsert2)
}
</script>
所以 requestIdleCallback
里面代码逻辑应该是可预期、可控制的。
避免在回调中使用 promise
因为 promise
的回调属于优先级较高的微任务,所以会在 requestIdleCallback
回调结束后立即执行,可能会给这一帧带来超时的风险。
在需要的时候才使用 timeout
- 使用 timeout 参数可以保证你的代码按时执行,但是我们想想
requestIdleCallback
本来就是让你在空闲时间调用的,使用 timeout 就会有种我没空闲时间了,你还强行让我执行,和requestIdleCallback
的初衷就会有点矛盾,所以最好是让浏览器自己决定何时调用。 - 另一方面检查超时也会产生一些额外开销,该 api 调用频率也会增加,大家可以复制下面的代码在控制台打印看看(可跳过):
// 无超时,一般打印值为 49/50 ms
function work(deadline) {
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
requestIdleCallback(work);
}
requestIdleCallback(work);
// =====================================================================
// 有超时,打印值就不怎么固定了
function work(deadline) {
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
requestIdleCallback(work, { timeout: 1500 });
}
requestIdleCallback(work, { timeout: 1500 });
争取更多的空闲时间
这个随大家自己发挥啦!
- 减少 render 耗时(比如读写分离、分层)
- 拆分 js 的复杂逻辑、减少 js 引发的 render
- ...
相关的测试代码可以点击这里查看👉🏻:requestIdleCallback 测试案例
参考文章(墙裂推荐):
- Using requestIdleCallback
- Cooperative Scheduling of Background Tasks 如果有什么问题,可以在评论区留言哦,哈哈😄