2022年了,还不懂requestIdleCallback么?

4,805 阅读11分钟

前序

大家新年好,新年开工之际,让我们来学习个经常出现,很重要,但是不经常使用的知识点 requestIdleCallback

本文会解决大家的疑问:

  • requestIdleCallback到底是什么呢?
  • requestIdleCallback 跟React fiber架构有什么关系呢?
  • requestIdleCallback 对于性能优化有什么帮助呢?

如果本文对你有帮助,请加作者微信mokinzhao,入群深入交流哦

requestIdleCallback 是什么?

requestIdleCallback 是一个还在实验中的 api,可以让我们在浏览器空闲的时候做一些事情

RequestIdleCallback 简单的说,判断一帧有空闲时间,则去执行某个任务。 目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。 故RequestIdleCallback 定位处理的是: 不重要且不紧急的任务

基本语法

var handle = window.requestIdleCallback(callback[, options])
  • 返回值 一个ID,可以把它传入 Window.cancelIdleCallback() 方法来结束回调。

  • callback 一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。

  • options 可选 包括可选的配置参数。具有如下属性: timeout: 如果指定了timeout,并且有一个正值,而回调在timeout毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。

  • 基本应用

type Deadline = {
  timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
  didTimeout: boolean // 是否超时。
}

function work(deadline:Deadline) { // deadline 上面有一个 timeRemaining() 方法,能够获取当前浏览器的剩余空闲时间,
单位 ms;有一个属性 didTimeout,表示是否超时
  console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`);
  if (deadline.timeRemaining() > 1 || deadline.didTimeout) {
     // 走到这里,说明时间有余,我们就可以在这里写自己的代码逻辑
  }
  // 走到这里,说明时间不够了,就让出控制权给主线程,下次空闲时继续调用
  requestIdleCallback(work);
}
requestIdleCallback(work, { timeout: 1000 }); // 这边可以传一个回调函数(必传)和参数(目前就只有超时这一个参数)

缺点

  • 这是一个实验中的功能 此功能某些浏览器尚在开发中,请参考浏览器兼容性表格以得到在不同浏览器中适合使用的前缀。由于该功能对应的标准文档可能被重新修订,所以在未来版本的浏览器中该功能的语法和行为可能随之改变。

  • 实验过程 实验结论: requestIdleCallback FPS只有20ms,正常情况下渲染一帧时长控制在16.67ms (1s / 60 = 16.67ms)。该时间是高于页面流畅的诉求。

  • 有人认为 RequestIdleCallback 主要用来处理不重要且不紧急的任务,因为React渲染内容,并非是不重要且不紧急。不仅该api兼容一般,帧渲染能力一般,也不太符合渲染诉求,故React 团队自行实现

requestIdleCallback 和 requestAnimationFrame 的区别

requestAnimationFrame 的回调会在每一帧确认执行, 属于高优先级任务. 而 requestIdleCallback 的回调不一定, 属于低优先级任务. 我们看到的页面是浏览器一帧一帧绘制出来的, 通常 FPS 在 60 的时候是比较流畅的, 而 FPS 比较低的时候就会感觉到卡顿. 那么在每一帧里浏览器会做哪些事情呢, 如下图所示:

图中一帧包括了用户的交互, JavaScript 脚本执行; 以及 ***requestAnimationFrame(rAF)***的调用, 布局计算以及页面重绘等. 假如某一帧里执行的任务不多, 在不到 16.66ms(1000/60)内就完成了上述任务, 那么这一帧就会有一定空闲时间来执行 requestIdleCallback 的回调, 如图所示:

当程序栈为空页面无需更新的时候, 浏览器其实是处于空闲状态, 这时候留给requestIdleCallback执行的时间就可以适当拉长, 最长达到 50ms, 以防出现不可预测的任务(如用户输入), 避免无法及时响应使用户感知到延迟.

由于requestIdleCallback利用的是帧的空闲时间, 所以有可能出现浏览器一直处于繁忙状态, 导致回调一直无法执行, 那这时候就需要在调用requestIdleCallback的时候传递第二个配置参数timeout了.

requestIdleCallback(myNonEssentialWork, { timeout: 2000 });

function myNonEssentialWork(deadline) {
  // 当回调函数是由于超时才得以执行的话,deadline.didTimeout为true
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    tasks.length > 0
  ) {
    doWorkIfNeeded();
  }
  if (tasks.length > 0) {
    requestIdleCallback(myNonEssentialWork);
  }
}

如果是因为timeout回调才得以执行的话, 用户就有可能感受到卡顿, 因为一帧的时间已经超过 16ms 了.

requestIdleCallback 在空闲时间里有哪些能做和不能做

不要做什么

  1. 不要在requestIdleCallback 里执行修改DOM操作

强烈建议不要在requestIdleCallback里面执行修改 DOM 的操作. 从上面一帧的构成中可以看到, requestIdleCallback回调执行之前, 样式变更以及布局计算等都已经完成. 如果在callback中修改 DOM 的话, 之前所作的布局计算都会失效. 并且如果下一帧里有获取布局相关的操作, 浏览器就需要强制进行重排, 极大的影响性能. 另外由于修改 DOM 的时间是不可预测的, 因此容易超过当前帧空闲的阈值.

推荐的做法是在 requestAnimationFrame里面做 DOM 的修改.

  1. 除了不推荐 DOM 修改操作外, **Promise的resolve(reject)**操作也不建议放在里面, 因为Promise的回调会在 idle 的回调执行完成后立即执行, 拉长当前帧的耗时. promise 的回调属于优先级较高的微任务,所以会在 requestIdleCallback 回调结束后立即执行,可能会给这一帧带来超时的风险。

能做什么

  1. 数据的分析和上报
  • 在用户有操作行为时(如点击按钮、滚动页面)进行数据分析并上报。
  • 处理数据时往往会调用 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();
    });
}
  1. 预加载

在空闲的时候加载些东西,可以看看 qiankun 的例子,用来预加载 js 和 css

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);
  });
}
  1. 检测卡顿
  • 一般检测的卡顿方法有两种:
    • 测量 fps 值,如果连续出现几个 fps 值 ≤ 阈值,则认为是卡顿
    • 开辟一个 worker 线程和主线程之间来个心跳检测,一段时间内没响应,则认为是卡顿

回过头来,如果 requestIdleCallback 长时间内没能得到执行,说明一直没有空闲时间,很有可能就是发生了卡顿,从而可以打点上报。它比较适用于行为卡顿,举个例子:点击某个按钮并同时添加我们的 requestIdleCallback 回调,如果点击后的一段时间内这个回调没有得到执行,很大概率是这个点击操作造成了卡顿。

  1. 拆分耗时任务

这个思想在 React 中的调度器 Scheduler里面展现的淋漓尽致,虽然 React 自己实现了一套调度逻辑(兼容性、稳定性和优先级等原因),不过不妨碍我们理解。 简单来说 React 把 diff 的过程从早前的递归变成了现在的迭代,对两个大对象进行递归 diff 就是个耗时的任务,如果能够拆解成小任务,那该有多好。但是递归又不能中途终止,所以 React 采用了 fiber 这种数据结构,把递归变成了链表迭代,迭代就可以中途停止,我们就不用一次性 diff 完。 ps:不懂链表的同学就简单理解成是数组吧,你想想如果我们要把数组进行遍历,我们可以一次性执行完,但是我们也可以拆成几次执行完,只要我们记录个 index,下次回来继续执行代码的时候就从 index 开始遍历就行,不知道大家 get 到木有。

模拟实现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,并且剩余时间也是猜测的。

requestIdleCallback与React中的时间切片有什么关系

RequestIdleCallback 实验案例

  • 结论:
    • requestIdleCallback 是利用帧之间空闲时间来执行JS
    • requestIdleCallback 是在 layout 和 paint 之后, 意味着requestIdleCallback 是可以js计算并改变DOM的,也就是说会 触发重新 layout 和 paint
    • requestAnimationFrame 是在 layout 和 paint 之前,因此更适合变更DOM操作。
    • 因此React内部对调度策略的实现也是基于requestAnimationFrame的
  • 故:
    • RequestIdleCallback 定位为处理不重要且不紧急的事物。因为React渲染内容,并非是不重要且不紧急。不仅该api兼容一般,帧渲染能力一般,也不太符合渲染诉求,故React 团队自行实现

React源码中的requestHostCallback

  • SchedulerHostConfig.js

  • 执行宏任务(回调任务)

    • requestHostCallback: 触发一个宏任务 performWorkUntilDeadline。
    • performWorkUntilDeadline: 宏任务处理。
      • 是否有富裕时间, 有则执行。
      • 执行该回调任务后,是否还有下一个回调任务, 即判断 hasMoreWork。
      • 有则继续执行 port.postMessage(null);
  let scheduledHostCallback = null;

  let isMessageLoopRunning = false;

  const channel = new MessageChannel();

  // port2 发送
  const port = channel.port2;
  // port1 接收
  channel.port1.onmessage = performWorkUntilDeadline;
  const performWorkUntilDeadline = () => {
    // 有执行任务
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // Yield after `yieldInterval` ms, regardless of where we are in the vsync

      // cycle. This means there's always time remaining at the beginning of
      // the message event.

      // 计算一帧的过期时间点
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        // 执行完该回调后, 判断后续是否还有其他任务
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );

        if (!hasMoreWork) {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;

        } else {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          // 还有其他任务, 推进进入下一个宏任务队列中
          port.postMessage(null);
        }

      } catch (error) {
        // If a scheduler task throws, exit the current browser task so the
        // error can be observed.
        port.postMessage(null);
        throw error;
      }

    } else {
      isMessageLoopRunning = false;
    }
    // Yielding to the browser will give it a chance to paint, so we can
    // reset this.
    needsPaint = false;

  };

  // requestHostCallback 一帧中执行任务
  requestHostCallback = function(callback) {
    // 回调注册
    scheduledHostCallback = callback;

    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      // 进入宏任务队列
      port.postMessage(null);
    }

  };

  cancelHostCallback = function() {
    scheduledHostCallback = null;

  };

作者建立了一个全栈大前端交流群,喜欢讨论热点技术,入群请加微信:mokinzhao

参考

requestIdleCallback-MDN

来深入了解下requestIdleCallback

实现 React requestIdleCallback 调度能力