requestIdleCallback 全面解析:原理、应用与兼容性

571 阅读6分钟

现代 Web 应用功能越来越丰富,但页面流畅度和首屏渲染速度往往成为用户体验的瓶颈。尤其当我们在页面加载完毕后,还需要做一些“非紧急”工作,比如埋点上报、懒加载资源、离线数据同步等,如果一股脑儿地放到主线程执行,就会引起卡顿、掉帧,甚至影响用户点击体验。 为此,HTML5 标准引入了 requestIdleCallback API,让我们能够在浏览器「空闲时期」调度低优先级任务,优雅地利用每一块空闲窗口,避免打扰关键渲染与交互。
下面,我们将从业务背景切入,依次介绍它的 API 用法和从简单到复杂的典型使用场景,帮助你在实际项目中轻松上手喵 (。•̀ᴗ-)✧。

API速览

// 注册一个空闲回调
const id = requestIdleCallback(cb, options);
// 取消回调
cancelIdleCallback(id);
  • cb:当浏览器空闲时要执行的函数,接收一个IdleDeadline对象
  • options:一个对象,但目前只有一个属性,即timeout(毫秒),如果浏览器一直很忙,超过这个时间后,也会强制执行你的callback
  • 返回值id:表示此次回调任务的标识,用来取消调度

image.png 4是返回的id值,打印出来一个deadline参数,也就是IdleDeadline,其中didTimeout属性标识回调是否在超时时间前已经执行,值为false的时候代表未超时,回调会在浏览器空闲时正常执行;值为true的时候代表已超时,等待timeout指定的毫秒数后会强制执行。timeRemaining函数标识当前闲置周期的预估剩余毫秒,我们不妨把它的返回值打印出来看一看: image.png 6表示的是返回的id,50代表预估的剩余毫秒数。
不过好像有个问题,一帧不是才16ms吗?50ms已经远大于16ms了,这是怎么回事?为了解答这个问题,我们不得不进一步研究下requestIdleCallback的执行时机⭐

执行时机分析

我们看到的网页都是由浏览器一帧一帧地绘制出来的,一帧包含用户的交互、js的执行以及requestAnimationFrame的调用,布局计算以及页面的重绘等工作。FPS指每秒钟页面呈现的帧数,用于衡量页面的流畅程度,通常认为FPS为60的时候是比较流畅。

image.png 浏览器每秒 60 帧(60Hz),每帧理想只花 16.67ms ,如果某一帧里面要执行的任务不多,在16ms内就完成任务的话,这一帧的空闲时间就可以用来执行requestIdleCallback的回调。

image.png 程序栈为空页面屏幕不刷新时,浏览器处于空闲状态,Chrome 等浏览器会为每次 idle 回调分配最多 50ms 的预算时间。

image.png

👉 requestIdleCallback的调用时机是不确定的,每帧结束没有剩余时间就不会被执行,可能会间隔很久,也就是浏览器一直处于繁忙状态的情况,此时会导致回调一直无法执行,这种情况下我们就需要在调用时传入第二个参数timeout

requestIdleCallback和requestAnimationFrame有什么区别🤔

requestIdleCallbackrequestAnimationFrame 是两种浏览器提供的异步任务调度 API,它们在执行时机、优先级等地方存在本质区别。

1. 执行时机

  • requestAnimationFrame(简称 rAF)在下一帧渲染前执行,通常每 16.67ms(60Hz 屏幕)调用一次,用于执行与 UI 渲染密切相关的逻辑。
  • requestIdleCallback(简称 rIC)在主线程空闲时执行,不依赖帧率,空闲时段由浏览器调度器动态决定。

2. 优先级

  • rAF 是高优先级 API,适用于动画更新、页面布局、过渡效果等对帧率敏感的任务。
  • rIC 是低优先级 API,适用于后台任务,如数据清理、日志上报、预加载、非关键 DOM 操作等。

3. 参数形式

  • rAF 回调函数接收一个高精度时间戳(DOMHighResTimeStamp),表示当前帧开始的时间。

  • rIC 回调函数接收一个 IdleDeadline 对象,其中:

    • timeRemaining() 表示浏览器预估当前空闲期剩余的可用时间(单位:毫秒)
    • didTimeout 表示当前回调是否因为超时而被强制执行

4. 是否保证执行

  • rAF 会在每一帧触发(除非标签页处于后台或被限制)。
  • rIC 不保证一定会执行,若主线程持续繁忙,则可能长时间不被调度。开发者可通过设置 timeout 参数确保任务在一定时间后被强制执行。

应用场景

requestIdleCallBack适用于预处理、埋点日志、延迟执行等场景。

  • ✅预处理:当你需要处理一些无需立即展示的数据,就可以在空闲的时候预处理这些数据。

  • ✅埋点日志:可以在浏览器空闲时上报,避免阻塞交互,不影响用户体验。

  • ✅延迟执行:有一些并非需要立刻执行的代码可以用requestIdleCallback来推迟这些任务执行。

  • ❌操作dom/更新UI:requestIdleCallback不适合去操作dom或者是更新UI等和用户交互行为相关的任务,因为执行时机不确定会造成响应迟钝等问题,影响用户体验。

  • ❌耗时长任务:虽然是在浏览器空闲执行,但依然运行在主线程上。耗时的长任务同样会导致帧率降低,造成页面卡顿。

  • ❌避免在空闲回调中改变 DOM:空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。如果你的回调需要改变 DOM,它应该使用Window.requestAnimationFrame()来调度它。

队列任务处理

为了进一步理解requestIdleCallback,我们继续来看一下它是如何去处理队列任务的。

let taskHandle = null;
let taskList = [
  () => {
    console.log('task1')
  },
  () => {
    console.log('task2')
  },
  () => {
    console.log('task3')
  }
]

function runTaskQueue(deadline) {
  console.log(`deadline: ${deadline.timeRemaining()}`)
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
    let task = taskList.shift();
    task();
  }

  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
    taskHandle = 0;
  }
}

requestIdleCallback(runTaskQueue, { timeout: 1000 })

因为空余时间足够,三个任务会在同一帧执行。 执行结果如下: image.png 如果任务时间比较久,就会被放到下一个空闲时间来执行。我们通过sleep函数来模拟一下:

const sleep = delay => {
  for (let start = Date.now(); Date.now() - start <= delay;) {}
}

let taskHandle = null;
let taskList = [
  () => {
    console.log('task1')
    sleep(50)
  },
  () => {
    console.log('task2')
    sleep(50)
  },
  () => {
    console.log('task3')
    sleep(50)
  }
]

function runTaskQueue(deadline) {
  console.log(`deadline: ${deadline.timeRemaining()}`)
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
    let task = taskList.shift();
    task();
  }

  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
    taskHandle = 0;
  }
}

requestIdleCallback(runTaskQueue, { timeout: 1000 })

从执行结果中打印了三次的deadline可以看出,任务分别在三个空闲时间被执行。 image.png

兼容性

目前 requestIdleCallback 在 Safari(尤其是 iOS Safari)上的兼容性较差。 image.png 因此建议通过 polyfill 方式保证兼容性:

if (!window.requestIdleCallback) {
  window.requestIdleCallback = function (cb) {
    const start = Date.now();
    return setTimeout(() => {
      cb({
        didTimeout: false,
        timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
      });
    }, 1);
  };

  window.cancelIdleCallback = function (id) {
    clearTimeout(id);
  };
}

不过以上并不是一个好的替代实现,因为使用setTimeout不能像requestIdleCallback能实现在空闲时段执行代码,只能保证将每次传递的运行时间控制在50ms内。