来深入了解下 requestIdleCallback 呗 ?

·  阅读 3164
来深入了解下 requestIdleCallback 呗 ?

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 上面弄的😄): image.png 另外还有一种空闲的情况就是页面长时间没操作的时候,这时候 requestIdleCallback 的剩余时间会尽可能延长,最多是 50ms,我们看下图(巧了,也是从 W3C 上面弄的😄): image.png 所以我们可以看到一开始的实验中,不操作页面大概率会打印 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 测试案例
参考文章(墙裂推荐):

分类:
前端
收藏成功!
已添加到「」, 点击更改