Libon

从性能优化的角度看 JS 后台任务调度

#JavaScript#event
从性能优化切入,了解如何通过 JS 后台任务协作调度 API 的方式来优化项目

ToC

Background Tasks API

借用 MDN 上对这一 API 的描述:

The Cooperative Scheduling of Background Tasks API (also referred to as the Background Tasks API or the requestIdleCallback() API) provides the ability to queue tasks to be executed automatically by the user agent when it determines that there is free time to do so.

后台任务协作调度 API(Cooperative Scheduling of Background Tasks API,也叫后台任务 API,或者简单称为 requestIdleCallback() API)提供了由用户代理决定的,在空闲时间自动执行队列任务的能力。

JS 的事件调度主要是以事件循环为中心。所有的代码都会根据其 API 的性质的不同而被注册到微任务或宏仁务中,如果在代码中不区分事件的优先级而一股脑全部注册新的事件到队列中,那就会导致某些真正优先级高的事件被延迟执行,而某些优先级低的事件反而被先执行了。这就是为什么我们需要一个 API 来协调这些事件的执行。

requestIdleCallback()

在以前,为了防止事件队列中出现卡顿,除了使用 Web Worker 来处理高耗时的任务以外,就只剩下使用 setTimeout 将任务注册为宏仁务,除此之外,几乎没有别的可靠的办法来做到这一点,直到 requestIdleCallback 出现。

requestIdleCallback 是一个由浏览器提供的 API,它的作用是在浏览器空闲的时候执行任务。它能充分利用空闲时间来执行回调,它接收一个参数作为回调参数,在系统空间的时候会执行这个回调,并传递一个 IdleDeadline 对象作为参数,这个对象包含了当前空闲时间的一些信息,包含了一个 didTimeout 的只读 boolean 类型的属性,用于表示回调是否是因为超过了设置的超时时间而被执行的,还有一个 timeRemaining() 方法,返回一个浮点数类型的数值,用于表示预估的闲置剩余剩余时间,如果没有剩余时间,返回 0,所以我们的回调函数中可以通过这个方法来判断当前是否还有剩余时间,如果有的话,就可以继续执行任务,如果没有的话,就可以将任务挂起,等待下一次空闲时间再继续执行。

它看起来非常强大,但并不是无所不能,它也有一些注意事项:

  1. 不要将所有的任务都注册为 requestIdleCallback 函数,因为它并不是一个万能的解决方案,它只能在浏览器空闲的时候执行任务,如果浏览器一直处于繁忙状态,那么这些任务就永远不会被执行。所以它比较适合于一些不是很重要的任务,比如一些统计上报,或者一些不是很重要的数据处理等。
  2. 不要在回调函数中执行太多的任务,因为 .timeRemaining() 方法返回的值是一个预估值,它并不是很准确,并且有一个大概 50ms 左右的上限时间,而实际上真正能用于处理任务的时间可能会更短。因为在复杂的页面中事件循环可能已经花费了其中的一部分,浏览器的扩展插件也需要处理时间,等等。
  3. 不要在这个回调中操作 DOM 元素,当回调执行那个的时候,当前的绘制帧已经结束了,所有的布局更新和计算都已经完成。如果你改变了布局,可能会导致浏览器重新计算布局,如果一定要操作 DOM 元素,可以使用 requestAnimationFrame 函数。
  4. 如果有一些任务是需要强制执行的,可以使用 requestIdleCallback 函数的第二个参数,这个参数是一个配置对象,可以设置 timeout 属性,这个属性表示最长等待时间,如果超过这个时间还没有空闲时间,那么这个任务就会被强制执行。但要记得不要给所有的任务都设置 timeout 属性,因为这样会导致一些任务被放进事件循环中排队执行这个任务,而这些任务可能并不是很重要,这样会浪费一些性能。

说完注意事项以后,我们来看一下 requestIdleCallback 函数的使用方法,这里以 Vue 代码为例:

<script setup>

let idleCbId = null

function registry() {
  // 注册一个回调函数,因为这个函数是每次在调用的时候都去获取浏览器的空闲状态并执行回调函数,所以需要在每次触发行为的时候都重新去调用这个函数
  idleCbId = requestIdleCallback((deadline) => {
    if (
      // 如果超时或者剩余时间大于 0 则执行
      deadline.didTimeout ||
      deadline.timeRemaining() > 0
    ) {
      // todo something...
      // e.g. async fetch data
      console.log('idle')
    }
  }, { timeout: 5000 })
}

// 取消注册的回调函数
function cancel() {
  cancelIdleCallback(idleCbId)
}
</script>

<template>
  <button @click="registry">注册事件</button>
</template>

使用方法其实和 setTimeout 差不多,但是如果你在 MDN 或 caniuse 网站上看过这个 API 的兼容性的话,你就会发现 Safari 和 IE 浏览器并不支持这个 API (Safari 浏览器又何尝不是另一种形式的 IE 呢😂),所以我们在使用的时候还是需要注意以下兼容性问题,我们可以简单写一个 polyfill 来解决这个问题:

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 || clearTimeout;

以上。 才怪,这个问题没有这个简单,不会这么快结束。之前的 polyfill 的方案过于简单,虽然能实现任务异步化,但是它依然会作为主要任务来执行,那么下面我们就来解决这个问题。

更完善的 polyfill

除了 setTimeout 之外,我们还可以通过 requestAnimationFrame + MessageChannel 来实现一个更完善的 polyfill,因为 MessageChannel 本身就是一个异步的任务队列机制,同时它还允许在两个通道中相互发送消息,这使得它比 setTimeout 更加可控。以下代码实现来自于:@huangxin

const genId = (function () {
  let id = 0
  return function () {
    return ++id
  }
})()

const idMap: {
  [key: number]: number
} = {}

const _requestIdleCallback: (
  cb: (idleDeadline: IdleDeadline) => void,
  options?: { timeout: number }
) => number = function (cb, options) {
  const channel = new MessageChannel()
  const port1 = channel.port1
  const port2 = channel.port2
  let deadlineTime: number // 超时时间
  let frameDeadlineTime: number // 当前帧的截止时间
  let callback: (idleDeadline: IdleDeadline) => void

  const id = genId()

  port2.onmessage = () => {
    const frameTimeRemaining = () => frameDeadlineTime - performance.now() // 获取当前帧剩余时间
    const didTimeout = performance.now() >= deadlineTime // 是否超时

    if (didTimeout || frameTimeRemaining() > 0) {
      const idleDeadline = {
        timeRemaining: frameTimeRemaining,
        didTimeout
      }
      callback && callback(idleDeadline)
    } else {
      idMap[id] = requestAnimationFrame((timeStamp) => {
        frameDeadlineTime = timeStamp + 16.7
        port1.postMessage(null)
      })
    }
  }

  idMap[id] = window.requestAnimationFrame((timeStamp) => {
    frameDeadlineTime = timeStamp + 16.7 // 当前帧截止时间,按照 60fps 计算
    deadlineTime = options?.timeout ? timeStamp + options.timeout : Infinity // 超时时间
    callback = cb
    port1.postMessage(null)
  })

  return id
}

const _cancelIdleCallback = function (id: number) {
  if (!idMap[id]) return
  window.cancelAnimationFrame(idMap[id])
  delete idMap[id]
}

export const requestIdleCallback = window.requestIdleCallback || _requestIdleCallback
export const cancelIdleCallback = window.cancelIdleCallback || _cancelIdleCallback

还有其他方案吗?

当然有,mdn 上还有一个真正关于 js 内部任务调度的 api,它允许你指定事件的优先级,js 内部会根据传入任务的优先级来重新编排任务队列,实现真正的任务优先级调度控制,它就是: scheduler.postTask

scheduler.postTask 的使用方式也很简单,在使用的时候第一个参数传入一个要执行的回调函数,在第二个参数中传入一个包含优先级字段(priority)的配置项即可:

function myTask() {
  return "Task 1: user-visible";
}

scheduler.postTask(myTask, {
  priority: "user-blocking",
})

因为它返回的是一个 Promise,所以你也可以使用 .then 链式调用:

scheduler.postTask(myTask, {
  priority: "user-blocking",
}).then((value) => {
  console.log(value)
})

又或者是使用 await,而 priority 支持的优先级有:

它们执行的优先级顺序也正如上面列出的一样,"user-blocking" 最高,"background" 最低。

但因为这个 API 的兼容性其实并不是特别好,FireFox 和 Safari 都是不支持的,所以在使用的时候还是要根据业务系统的场景来决定是否使用,不用于 requestIdleCallbackscheduler.postTask 注册的事件只要你的任务的优先级规划得好,即便你的系统一直很忙它也一定会执行,但 requestIdleCallback 就不一样了,它会在空闲的时候去执行,所以如果你的系统很忙,那它可能永远都不会执行到。

参考

以上。(这次真结束了)


CD ..
接下来阅读
奋笔疾书中...