后台任务 API

协作调度后台任务 API(也称为后台任务 API 或 requestIdleCallback() API)提供了一种能力,可以将任务排队,以便在用户代理确定有空闲时间时自动执行这些任务。

注意:此 API 在 Web Workers不可用

概念和用法

Web 浏览器的主线程围绕其事件循环展开。此代码绘制当前显示的 Document 的所有挂起更新,运行页面需要运行的任何 JavaScript 代码,接收来自输入设备的事件,并将这些事件分派给应该接收这些事件的元素。此外,事件循环处理与操作系统的交互、浏览器自身用户界面的更新等等。这是一段极其繁忙的代码,您的主要 JavaScript 代码可能与所有这些代码一起在此线程中运行。当然,大多数(如果不是全部)能够更改 DOM 的代码都运行在主线程中,因为用户界面更改通常只能由主线程访问。

由于事件处理和屏幕更新是用户注意到性能问题的两个最明显的方式,因此您的代码成为 Web 的良好公民并帮助防止事件循环执行停滞非常重要。过去,除了编写尽可能高效的代码以及尽可能多地将工作卸载到 workers 之外,没有可靠的方法可以做到这一点。 Window.requestIdleCallback() 使您可以积极参与帮助确保浏览器的事件循环平稳运行,方法是允许浏览器告诉您的代码它可以安全地使用多少时间,而不会导致系统滞后。如果您保持在给定的限制内,您可以使用户的体验变得更好。

充分利用空闲回调

由于空闲回调旨在为您的代码提供一种与事件循环协作的方法,以确保系统在不超过其任务的情况下得到充分利用,从而导致滞后或其他性能问题,因此您应该仔细考虑如何使用它们。

  • 将空闲回调用于不具有高优先级的任务。由于您不知道建立了多少个回调,也不知道用户的系统有多忙,因此您不知道您的回调将运行多少次(除非您指定了 timeout)。不能保证事件循环的每次传递(甚至每次屏幕更新周期)都会包含任何执行的空闲回调;如果事件循环使用所有可用时间,您就会很倒霉(同样,除非您使用了 timeout)。
  • 空闲回调应尽力避免超过分配的时间。虽然浏览器、您的代码以及整个 Web 在您超过指定时间限制(即使您远远超过它)的情况下将继续正常运行,但时间限制旨在确保您为系统留出足够的时间来完成事件循环的当前传递并进入下一个,而不会导致其他代码卡顿或动画效果滞后。目前,timeRemaining() 的上限为 50 毫秒,但实际上您通常会比这更少时间,因为事件循环可能已经在复杂的站点上侵占了这段时间,浏览器扩展需要处理器时间等等。
  • 避免在空闲回调中更改 DOM。在您的回调运行时,当前帧已经完成绘制,所有布局更新和计算都已完成。如果您进行了影响布局的更改,您可能会强制浏览器停止并执行本来不需要的重新计算。如果您的回调需要更改 DOM,它应该使用 Window.requestAnimationFrame() 来安排它。
  • 避免运行时间无法预测的任务。您的空闲回调应该避免做任何可能花费不可预测时间的事情。例如,应该避免任何可能影响布局的事情。您还应该避免解析或拒绝 Promise,因为这会导致该承诺解析或拒绝的处理程序在您的回调返回时立即调用。
  • 在需要时使用超时,但仅在需要时使用。使用超时可以确保您的代码及时运行,但它也可以通过强制浏览器在没有足够的时间让您运行而不会影响性能的情况下调用您,从而导致滞后或动画卡顿。

接口

后台任务 API 仅添加了一个新接口

IdleDeadline

此类型的一个对象传递给空闲回调,以提供对空闲期预计持续时间的估计,以及回调是否由于其超时期已过期而运行。

此 API 还增强了 Window 接口,以提供新的 requestIdleCallback()cancelIdleCallback() 方法。

示例

在此示例中,我们将看一下如何使用 requestIdleCallback() 在浏览器否则处于空闲状态时运行耗时、低优先级的任务。此外,此示例演示了如何使用 requestAnimationFrame() 安排文档内容的更新。

在下面,您将仅找到此示例的 HTML 和 JavaScript。CSS 未显示,因为它对理解此功能并不重要。

HTML

为了了解我们试图实现的目标,让我们看一下 HTML。这建立了一个框(id="container")用于显示操作的进度(毕竟,您永远不知道解码“量子细丝速子发射”需要多长时间),以及第二个主框(id="logBox"),用于显示文本输出。

html
<p>
  Demonstration of using cooperatively scheduled background tasks using the
  <code>requestIdleCallback()</code> method.
</p>

<div id="container">
  <div class="label">Decoding quantum filament tachyon emissions…</div>

  <progress id="progress" value="0"></progress>

  <button class="button" id="startButton">Start</button>

  <div class="label counter">
    Task <span id="currentTaskNumber">0</span> of
    <span id="totalTaskCount">0</span>
  </div>
</div>

<div id="logBox">
  <div class="logHeader">Log</div>
  <div id="log"></div>
</div>

进度框使用 <progress> 元素显示进度,以及一个带部分的标签,这些部分会发生变化以显示有关进度的数字信息。此外,还有一个“开始”按钮(巧妙地赋予了 ID“startButton”),用户将使用它来启动数据处理。

JavaScript

现在文档结构已定义,构建将完成工作的 JavaScript 代码。目标:能够将调用函数的请求添加到队列中,并使用空闲回调在系统空闲足够长的时间以取得进展时运行这些函数。

变量声明

js
const taskList = [];
let totalTaskCount = 0;
let currentTaskNumber = 0;
let taskHandle = null;

这些变量用于管理等待执行的任务列表,以及有关任务队列及其执行的状态信息

  • taskList 是一个 Array 对象,每个对象代表一个等待运行的任务。
  • totalTaskCount 是已添加到队列中的任务数量的计数器;它只会增加,永远不会减少。我们使用它来进行数学运算,以显示进度占总工作量的百分比。
  • currentTaskNumber 用于跟踪到目前为止已处理的任务数量。
  • taskHandle 是对当前正在处理的任务的引用。
js
const totalTaskCountElem = document.getElementById("totalTaskCount");
const currentTaskNumberElem = document.getElementById("currentTaskNumber");
const progressBarElem = document.getElementById("progress");
const startButtonElem = document.getElementById("startButton");
const logElem = document.getElementById("log");

接下来,我们有引用我们需要交互的 DOM 元素的变量。这些元素是

  • totalTaskCountElem 是我们用于将创建的总任务数量插入进度框中的状态显示的 <span>
  • currentTaskNumberElem 是用于显示到目前为止已处理的任务数量的元素。
  • progressBarElem 是显示到目前为止已处理的任务百分比的 <progress> 元素。
  • startButtonElem 是开始按钮。
  • logElem 是我们将在其中插入日志文本消息的 <div>
js
let logFragment = null;
let statusRefreshScheduled = false;

最后,我们为其他项目设置了几个变量

  • logFragment 将用于存储由我们的日志函数生成的 DocumentFragment,以在渲染下一动画帧时创建要附加到日志的内容。
  • statusRefreshScheduled 用于跟踪我们是否已为即将到来的帧安排了状态显示框的更新,以便我们每帧只执行一次

管理任务队列

接下来,让我们看看我们管理需要执行的任务的方式。我们将通过创建一个 FIFO 任务队列来执行此操作,我们将在空闲回调期间尽可能运行这些任务。

排队任务

首先,我们需要一个函数来将任务排队以供将来执行。该函数 enqueueTask() 如下所示

js
function enqueueTask(taskHandler, taskData) {
  taskList.push({
    handler: taskHandler,
    data: taskData,
  });

  totalTaskCount++;

  if (!taskHandle) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
  }

  scheduleStatusRefresh();
}

enqueueTask() 接受两个参数作为输入

  • taskHandler 是一个函数,将被调用来处理任务。
  • taskData 是一个对象,它作为输入参数传递给任务处理程序,以允许任务接收自定义数据。

要将任务排队,我们将一个对象 pushtaskList 数组中;该对象分别在 handlerdata 名称下包含 taskHandlertaskData 值,然后递增 totalTaskCount,它反映了曾经排队的任务总数(我们在任务从队列中删除时不会递减它)。

接下来,我们检查是否已经创建了空闲回调;如果taskHandle为 0,我们知道还没有空闲回调,因此我们调用requestIdleCallback()来创建一个。它被配置为调用一个名为runTaskQueue()的函数,我们将在稍后介绍这个函数,并且有一个timeout为 1 秒,这样即使没有实际的空闲时间可用,它也会至少每秒运行一次。

运行任务

我们的空闲回调处理程序runTaskQueue()在浏览器确定有足够的空闲时间让我们做一些工作或我们的 1 秒超时时间到期时被调用。此函数的职责是运行我们排队的任务。

js
function runTaskQueue(deadline) {
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    taskList.length
  ) {
    const task = taskList.shift();
    currentTaskNumber++;

    task.handler(task.data);
    scheduleStatusRefresh();
  }

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

runTaskQueue()的核心是一个循环,只要有剩余时间(通过检查deadline.timeRemaining以确保它大于 0 或超时限制已达到(deadline.didTimeout为 true)),并且任务列表中还有任务,循环就会继续。

对于队列中每个我们有时间执行的任务,我们执行以下操作:

  1. 我们从队列中删除任务对象
  2. 我们递增currentTaskNumber以跟踪我们已经执行了多少个任务。
  3. 我们调用任务的处理程序task.handler,并将任务的数据对象(task.data)传递给它。
  4. 我们调用一个函数scheduleStatusRefresh()来处理安排屏幕更新以反映我们进度的更改。

当时间用完时,如果列表中还有剩余任务,我们再次调用requestIdleCallback(),以便下次有空闲时间可用时我们可以继续处理这些任务。如果队列为空,我们将taskHandle设置为 0 以指示我们没有安排回调。这样,我们就会知道下次调用enqueueTask()时需要请求回调。

更新状态显示

我们想要做的一件事是用日志输出和进度信息更新我们的文档。但是,您不能从空闲回调中安全地更改 DOM。相反,我们将使用requestAnimationFrame()来请求浏览器在安全更新显示时调用我们。

安排显示更新

DOM 更改通过调用scheduleStatusRefresh()函数来安排。

js
function scheduleStatusRefresh() {
  if (!statusRefreshScheduled) {
    requestAnimationFrame(updateDisplay);
    statusRefreshScheduled = true;
  }
}

这是一个简单的函数。它检查我们是否已经通过检查statusRefreshScheduled的值来安排显示刷新。如果它是false,我们调用requestAnimationFrame()来安排刷新,并提供updateDisplay()函数来处理该工作。

更新显示

updateDisplay()函数负责绘制进度框和日志的内容。在渲染下一帧的过程中,当 DOM 处于安全状态以让我们应用更改时,浏览器会调用它。

js
function updateDisplay() {
  const scrolledToEnd =
    logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;

  if (totalTaskCount) {
    if (progressBarElem.max !== totalTaskCount) {
      totalTaskCountElem.textContent = totalTaskCount;
      progressBarElem.max = totalTaskCount;
    }

    if (progressBarElem.value !== currentTaskNumber) {
      currentTaskNumberElem.textContent = currentTaskNumber;
      progressBarElem.value = currentTaskNumber;
    }
  }

  if (logFragment) {
    logElem.appendChild(logFragment);
    logFragment = null;
  }

  if (scrolledToEnd) {
    logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
  }

  statusRefreshScheduled = false;
}

首先,如果日志中的文本滚动到底部,则scrolledToEnd设置为true;否则设置为false。我们将使用它来确定是否应该更新滚动位置以确保在完成向日志添加内容后日志仍然位于末尾。

接下来,如果排队了任何任务,我们将更新进度和状态信息。

  1. 如果进度条的当前最大值与当前排队任务的总数(totalTaskCount)不同,那么我们将更新显示的排队任务总数(totalTaskCountElem)和进度条的最大值,以便它按比例缩放。
  2. 我们对迄今为止处理的任务数量做同样的事情;如果progressBarElem.value与当前正在处理的任务编号(currentTaskNumber)不同,那么我们将更新当前正在处理的任务的显示值和进度条的当前值。

然后,如果有文本正在等待添加到日志(即,如果logFragment不是null),我们将使用Element.appendChild()将它追加到日志元素,并将logFragment设置为null,这样我们就不再添加它了。

如果在开始时日志滚动到底部,我们将确保它仍然是。然后,我们将statusRefreshScheduled设置为false以指示我们已处理完刷新,并且可以安全地请求新的刷新。

向日志添加文本

log()函数将指定的文本添加到日志中。由于我们在调用log()时不知道是否可以立即安全地接触 DOM,因此我们将缓存日志文本,直到可以安全地更新它。在上面的updateDisplay()代码中,您可以找到在更新动画帧时将记录的文本实际添加到日志元素的代码。

js
function log(text) {
  if (!logFragment) {
    logFragment = document.createDocumentFragment();
  }

  const el = document.createElement("div");
  el.textContent = text;
  logFragment.appendChild(el);
}

首先,如果当前不存在,我们将创建一个名为logFragmentDocumentFragment对象。此元素是一个伪 DOM,我们可以将元素插入其中,而不会立即更改主 DOM 本身。

然后,我们创建一个新的<div>元素,并将其内容设置为与输入text匹配。然后,我们将新元素追加到logFragment中伪 DOM 的末尾。logFragment将累积日志条目,直到下次调用updateDisplay(),因为 DOM 需要更改。

运行任务

现在我们已经完成了任务管理和显示维护代码,我们实际上可以开始设置代码来运行完成工作的任务。

任务处理程序

我们将用作任务处理程序的函数(即用作任务对象的handler属性值的函数)是logTaskHandler()。它是一个简单的函数,它为每个任务将大量内容输出到日志。在您自己的应用程序中,您将用您希望在空闲时间执行的任何任务替换此代码。请记住,您想做的任何更改 DOM 的事情都需要通过requestAnimationFrame()来处理。

js
function logTaskHandler(data) {
  log(`Running task #${currentTaskNumber}`);

  for (let i = 0; i < data.count; i += 1) {
    log(`${(i + 1).toString()}. ${data.text}`);
  }
}

主程序

当用户单击“开始”按钮时,所有操作都将被触发,这将导致调用decodeTechnoStuff()函数。

js
function decodeTechnoStuff() {
  totalTaskCount = 0;
  currentTaskNumber = 0;
  updateDisplay();

  const n = getRandomIntInclusive(100, 200);

  for (let i = 0; i < n; i++) {
    const taskData = {
      count: getRandomIntInclusive(75, 150),
      text: `This text is from task number ${i + 1} of ${n}`,
    };

    enqueueTask(logTaskHandler, taskData);
  }
}

document
  .getElementById("startButton")
  .addEventListener("click", decodeTechnoStuff, false);

decodeTechnoStuff()首先将totalTaskCount(迄今为止添加到队列中的任务数量)和currentTaskNumber(当前正在运行的任务)的值清零,然后调用updateDisplay()将显示重置为其“尚未发生任何事情”状态。

此示例将创建随机数量的任务(其中 100 到 200 个)。为此,我们使用getRandomIntInclusive()函数(它作为Math.random()文档中的示例提供),以获取要创建的任务数量。

然后,我们开始一个循环来创建实际的任务。对于每个任务,我们创建一个对象taskData,其中包含两个属性:

  • count是从任务中输出到日志中的字符串数量。
  • text是输出到日志的文本,输出次数由count指定。

然后通过调用enqueueTask()将每个任务排队,并将logTaskHandler()作为处理程序函数,并将taskData对象作为在调用该函数时传递给该函数的对象。

结果

以下是上述代码的实际运行结果。尝试一下,在浏览器的开发者工具中玩玩它,并尝试在您自己的代码中使用它。

规范

规范
requestIdleCallback() 协作调度后台任务
# the-requestidlecallback-method

浏览器兼容性

BCD 表格仅在浏览器中加载

另请参阅