前言
我们知道GUI 渲染线程与 JS 引擎线程是互斥的。当JS程序执行时间过长,会阻塞页面渲染和事件响应。那么针对一些优先级较低的任务,我们可以利用分时函数在主线程空闲时执行,减少卡顿。其本质是将一个长任务拆分成数个短任务,在其可执行时执行。
示例
这里我们使用官方的一个示例来说明:向页面中插入万级节点数据,可以看到,普通的循环插入卡顿感很明显(动画停止)。原因很简单:本该绘制的时候,因有任务要执行,所以浏览器渲染延后了。
之前在《Flip动画》一文中提到过“像素管道”这一概念:在每一个标准帧(约16.7ms)中,会执行JS代码、样式计算、布局、绘制等任务。假设某一帧里执行的任务较少,那么这一帧就有一定的空闲时间来执行requestIdleCallback回调。
requestIdleCallback
window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使得开发者能够在主事件循环上执行后台和低优先级任务,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
语法
requestIdleCallback(callback)
requestIdleCallback(callback, options)
参数
callback
一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为IdleDeadline的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。
options(可选)
如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。
返回值
一个ID,可以把它传入 window.cancelIdleCallback() 方法来结束回调。
充分利用空闲回调
因为 idle callback 旨在为代码提供一种与事件循环协作的方式,以确保充分利用系统资源,不会过度分配任务,从而导致延迟或其他性能问题。因此需要注意以下几点:
- 对非高优先级的任务使用空闲回调
已经创建了多少回调,用户系统的繁忙程度,回调多久会执行一次(除非你指定了 timeout),这些都是未知的。不能保证每次事件循环后都能执行空闲回调;如果事件循环用尽了所有可用时间,那么回调将永远不会执行(除非设置 timeout)。
- 空闲回调应尽可能不超支分配到的时间
尽管即使你超出了规定的时间上限,通常来说浏览器、代码、网页也能继续正常运行,这里的时间限制是用来保证系统能留有足够的时间去完成当前的事件循环然后进入下一个循环,而不会导致其他代码卡顿或动画效果延迟。目前,timeRemaining() 有一个 50 ms 的上限时间,但实际上你能用的时间比这个少,因为在复杂的页面中事件循环可能已经花费了其中的一部分,浏览器的扩展插件也需要处理时间(以防出现不可预测的任务, 避免无法及时响应使用户感知到延迟)。
- 避免在空闲回调中改变 DOM
空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算,这是不必要的。如果回调需要改变 DOM,应该使用 window.requestAnimationFrame() 来调度
- 避免运行时间无法预测的任务
你的空闲回调必须避免做任何占用时间不可预测的事情。比如会影响页面布局的操作和执行Promise等,因为Promise的回调会在 idle 的回调执行完成后立即执行, 拉长当前帧的耗时。Promise的回调属于优先级较高的微任务,所以会在 requestIdleCallback 回调结束后立即执行,可能会给这一帧带来超时的风险。
- 只在需要的时候才设置 timeout
使用 timeout 可以保证代码按时执行,但是在剩余时间不足以强制执行这部分代码,且保证浏览器性能的情况下,timeout 就会造成延迟或者动画不流畅。
注意点
在一些不支持此API的浏览器中,我们可以通过setTimeout()来达到近似的效果。当然,setTimeout() 并不能利用空闲时段,而是在情况允许时执行相关代码,从而尽可能避免造成性能问题或表现延迟。
// 定义
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);
};
适用场景
以上内容为官方示例和相关解释,那么requestIdleCallback可以用于哪些实际场景中呢?
数据上报
记录用户操作行为并定时上报,如按钮点击、页面停留时长统计或异常快照图上传等。当处理数据量较大,或遇到网络异常需要中断恢复上报等情况时,可采用此API来调度上报时机,避免阻塞页面渲染(分时切片+IndexedDB是做数据上报的一个很好的解决方案)。
资源预加载
如一些资源不需要在系统初始化时加载,或者资源优先级较低,可以在后台慢慢加载。如微前端场景下,会优先加载当前激活的子应用的资源,而其它子应用相关资源就可以使用此API进行预加载(如qiankun的prefetch)。
检测卡顿
使用requestIdleCallback来执行记录,如果在一段时间内回调没执行的话,就可以认为是卡顿的。
如何封装一个分时函数
以第一个功能(循环插入DOM)为例,我们可以做如下实现
function performChunk(datas) {if (datas.length == 0) {return}let i = 0;function _run() {if (i == datas.length) {return}requestIdleCallback(idle => {while (idle.timeRemaining() > 0) {const item = datas[i];const div = document.createElement('div');div.textContent = item;document.body.appendChild(div);i++;}_run();})}_run();
}
// 执行
const datas = [// ...
]
performChunk(datas);
优化
-
在一些不支持requestIdleCallback的浏览器或者其它环境中,需要兼容
-
把业务逻辑与调度函数解耦
-
调度函数兼容
// 定义 requestIdleCallback 的兼容处理,不执行则用setTimeout模拟实现
window.requestIdleCallback = window.requestIdleCallback || function (handler) {// 闭包,创建的时候记录一下时间let startTime = Date.now();return setTimeout(()=> {handler({didTimeout: false,timeRemaining: ()=> {// 理论上系统给你空闲的时间会低于50ms,所以你的任务最好不要超过50ms,否则还是会卡顿return Math.max(0, 50 - (Date.now() - startTime));}});}, 1);
};
// 取消任务
window.cancelIdleCallback = window.cancelIdleCallback || function (id) {clearTimeout(id);
};
// 定义功能逻辑
const consumer = (item) => {const div = document.createElement('div');div.textContent = item;document.body.appendChild(div);
}
// 定义自定义执行时机
const chunkSplitor = (task) => {setTimeout(()=>{task((time)=>time<16)},1000)
}
// 定义分时函数
function performChunk(datas, consumer, chunkSplitor) {// 此处可以做入参归一化处理if(Object.prototype.toString.call(datas) !== '[object Array]'){return}if (datas.length == 0) {return}// 默认执行时机if(!chunkSplitor){chunkSplitor = (task)=>{requestIdleCallback(idle=>{task(()=>idle.timeRemaining()>0)})}}let i = 0;function _run() {if (i == datas.length) {return}chunkSplitor((hasTime) => {// 每一帧还有剩余时间,则执行const now = Date.now();while (hasTime(Date.now() - now) && i < datas.length) {const item = datas[i];consumer(item)i++;}_run();})}_run();
}
// 执行
const datas = [// ...
]
performChunk(datas, consumer, chunkSplitor)
兼容性
后记
- 在非及时场景需求中可以使用此API来避免计算和渲染线程冲突,增加响应流畅度
- 此API无法在 Web Worker 中使用
- 需要考虑API兼容性
- 需要了解此API与requestAnimationFrame的区别和使用时机
- 推荐去查阅React的Scheduler相关源码,看一下如何手动实现各个任务之间的调度
参考
MDN-requestIdleCallback
requestIdleCallback