出处:掘金

原作者:某某某人


React 18 的并发模式背后就有 requestIdleCallback 的影子,但是因为一些原因导致 React 不得不自己实现了一版,所以今天来深入了解下这个 API 可以解决哪些问题

介绍

帧率:浏览器每秒渲染 60 帧,如果低于这个频率就会失帧,页面不会发生变化,造成的现象就是卡了。FPS 指每秒钟页面呈现的帧数,通常用于衡量页面的流畅程度

浏览器一帧:不断的执行 JS,在帧末会开始渲染流程绘制 UI

如果渲染结束,时间还有剩余,那么浏览器会调用 requestIdleCallback 这个 API 执行对应的回调函数

所以 requestIdleCallback 的调用时机是不确定的,每帧结束没有剩余时间就不会被执行,可能会间隔很久

requestIdleCallback 是一个 Web API,允许开发者安排在主线程空闲时执行的低优先级回调函数。这个函数的主要目的是使得开发者能够在不影响关键事件(如:动画、键盘输入)响应的情况下,执行后台或低优先级的任务

关键概念:

  1. 回调函数:回调函数是在主线程空闲时被调用的函数。每次调用时,都会传入一个 IdleDeadline 对象,该对象提供一个 timeRemaining() 方法,用来检测当前帧中剩余的空闲时间
  2. 空闲时间和截止时间(deadline):IdleDeadline 对象的 timeRemaining() 方法返回一个 DOMHighResTimeStamp,表示在执行回调函数时,在当前帧中剩余多少空闲时间(毫秒)。开发者可以使用这个时间来执行任务,并在时间耗尽前选择适当的时机终止任务,从而避免影响关键渲染或事件处理
  3. 调度和取消回调:requestIdleCallback 函数安排一个回调函数在主线程下一次空闲时被执行,并返回一个 ID,可以用这个 ID 通过 cancelIdleCallback 函数取消回调
  4. 超时:还可以给 requestIdleCallback 传递一个对象,其中一个属性是 timeout,用来指定最长时间(毫秒)。如果任务在指定的时间内尚未执行,即使主线程不空闲,浏览器也会尽量执行回调

使用示例:

requestIdleCallback((deadline) => {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
    // 执行任务
    performTask(tasks.shift());
  }
}, { timeout: 2000 });

应用场景

适合的场景

  1. 预处理:例如需要处理一些数据,但这些数据不需要立即展示给用户时,可以在空闲时预处理这些数据
  2. 埋点日志相关:对于跟踪和分析网站使用情况的代码,通常可以在空闲时执行,以减少影响用户体验的风险
  3. 延迟执行:当有一些非必须立刻执行的代码时,比如初始化某些非关键的 UI 组件,可以推迟这些任务的执行

使用 requestIdleCallback 的目的是确保关键任务(如处理用户输入、动画等)能够不受干扰地顺滑运行,而将非关键任务推迟到浏览器有足够资源处理它们的时候。这样既提高了页面性能,又优化了用户体验

不适合的场景

  1. 操作 DOM & 更新 UI:因为执行时机不确定可能导致视觉难以预测,而且 requestIdleCallback 是在渲染完成后才调用的,可能会引发回流重绘
  2. 做一些耗时的长任务:虽然是在浏览器空闲执行,但依然运行在主线程上,耗时的长任务同样会导致帧率降低,造成页面卡顿

兼容性

CanIUse

注意:safari 不支持

手动实现

需要解决以下几个问题:

  1. 如何估算浏览器当前是否空闲
  2. 如何让出主线程避免主线程被占用

思路:

浏览器一帧执行正常是 16.6ms,如果执行时间大于这个值可以任务浏览器处于繁忙状态,否则即代表空闲

因为 requestAnimationFrame 这个函数是和渲染保持同步的,可以通过函数获取帧的开始时间,然后使用帧率(开始时间+16.6ms)计算出帧的结束时间,然后开启一个宏任务,当宏任务被执行时比较当前的执行时间和帧结束的时间判断出当前帧是否还有空闲

因为是宏任务不会像微任务优先级那么高,可以被推迟到下一个事件循环中不会阻塞渲染。这里使用 MessageChannel 宏任务来实现

/**
 * polyfill requestIdleCallBack
 */
function idleExecute(callback: IdleCallBackFn, params?: Options) {
  const channel = new MessageChannel(); // 建立宏任务的消息通道
  const port1 = channel.port1;
  const port2 = channel.port2;
  const timeout = params?.timeout || -1;
  let cb: IdleCallBackFn | null = callback;
  let frameDeadlineTime = 0; // 当前帧结束的时间
  const begin = performance.now();
  let cancelFlag = 0;
 
  const runner = (timeStamp: number) => {
    // 获取当前帧结束的时间
    frameDeadlineTime = timeStamp + frameTime;
    if (cb) {
      port1.postMessage('task')
    }
  }
  port2.onmessage = () => {
    const timeRemaining = () => {
      const remain = frameDeadlineTime - performance.now();
      return remain > 0 ? remain : 0;
    };
    let didTimeout = false;
    if (timeout > 0) {
      didTimeout = performance.now() - begin > timeout;
    }
    // 没有可执行的回调:直接结束
    if (!cb) {
      return;
    }
    // 当前帧没有时间 & 没有超时:下次再执行
    if (timeRemaining() <= 1 && !didTimeout) {
      cancelFlag = requestAnimationFrame(runner);
      return;
    }
    // 有剩余时间或者超时:执行
    cb({
      didTimeout,
      timeRemaining,
    });
    cb = null;
  }
  cancelFlag = requestAnimationFrame(runner)
  return cancelFlag;
}