Background Tasks 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,因为一旦您的回调返回,这将调用该 Promise 解决或拒绝的处理程序。 - 在需要时使用超时,但仅在需要时使用。使用超时可以确保您的代码及时运行,但它也可能通过强制浏览器在没有足够时间运行而不会中断性能时调用您,从而导致滞后或动画卡顿。
接口
后台任务 API 只添加了一个新接口
IdleDeadline-
此类型的对象将传递给空闲回调,以提供空闲期预计持续多长时间的估计,以及回调是否因为其超时期限已过而运行。
Window 接口也通过此 API 进行了增强,提供了新的requestIdleCallback() 和cancelIdleCallback() 方法。
示例
在此示例中,我们将探讨如何使用requestIdleCallback()在浏览器空闲时运行耗时、低优先级的任务。此外,此示例还演示了如何使用requestAnimationFrame()调度文档内容的更新。
下面您将只找到此示例的 HTML 和 JavaScript。CSS 未显示,因为它对理解此功能并不特别重要。
HTML
为了了解我们正在尝试完成什么,让我们看一下 HTML。这建立了一个框(id="container"),用于显示操作的进度(毕竟,您永远不知道解码“量子灯丝超光速粒子发射”需要多长时间),以及第二个主框(id="logBox"),用于显示文本输出。
<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>元素显示进度,以及一个带有部分的标签,这些部分被更改以显示有关进度的数字信息。此外,还有一个“开始”按钮(巧妙地命名为“startButton”),用户将使用它来开始数据处理。
JavaScript
文档结构定义好后,构建将完成工作的 JavaScript 代码。目标:能够将调用函数的请求添加到队列中,并使用空闲回调,只要系统有足够长的空闲时间来取得进展,就运行这些函数。
变量声明
const taskList = [];
let totalTaskCount = 0;
let currentTaskNumber = 0;
let taskHandle = null;
这些变量用于管理等待执行的任务列表,以及有关任务队列及其执行的状态信息
taskList是一个Array对象数组,每个对象代表一个等待运行的任务。totalTaskCount是已添加到队列中的任务数量计数器;它只会增加,不会减少。我们使用它来计算以百分比形式显示总工作进度的数学。currentTaskNumber用于跟踪到目前为止已处理的任务数量。taskHandle是当前正在处理的任务的引用。
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>。
let logFragment = null;
let statusRefreshScheduled = false;
最后,我们为其他项目设置了几个变量
logFragment将用于存储一个DocumentFragment,它由我们的日志记录函数生成,用于在渲染下一个动画帧时创建要附加到日志的内容。statusRefreshScheduled用于跟踪我们是否已为即将到来的帧调度了状态显示框的更新,以便我们每帧只执行一次
管理任务队列
接下来,让我们看看我们管理需要执行的任务的方式。我们将通过创建一个任务的 FIFO 队列来做到这一点,我们将在空闲回调期间在时间允许的情况下运行这些任务。
任务入队
首先,我们需要一个用于将任务排队以供将来执行的函数。该函数 enqueueTask() 如下所示
function enqueueTask(taskHandler, taskData) {
taskList.push({
handler: taskHandler,
data: taskData,
});
totalTaskCount++;
taskHandle ||= requestIdleCallback(runTaskQueue, { timeout: 1000 });
scheduleStatusRefresh();
}
enqueueTask() 接受两个参数作为输入
taskHandler是一个将用于处理任务的函数。taskData是一个对象,它作为输入参数传递给任务处理程序,以允许任务接收自定义数据。
要将任务入队,我们将一个对象推入到taskList数组中;该对象包含taskHandler和taskData值,分别命名为handler和data,然后增加totalTaskCount,这反映了已入队任务的总数(当任务从队列中移除时我们不会减少它)。
接下来,我们检查是否已经创建了空闲回调;如果taskHandle为 0,我们知道还没有空闲回调,所以我们调用requestIdleCallback()来创建一个。它配置为调用一个名为runTaskQueue()的函数,我们稍后会查看,并设置 1 秒的timeout,这样即使没有实际的空闲时间可用,它也会至少每秒运行一次。
运行任务
当浏览器确定有足够的空闲时间让我们做一些工作或我们的一秒超时到期时,我们的空闲回调处理程序runTaskQueue()会被调用。此函数的工作是运行我们排队的任务。
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),并且任务列表中有任务,循环就会继续。
对于队列中我们有时间执行的每个任务,我们执行以下操作
- 我们从队列中移除任务对象。
- 我们增加
currentTaskNumber以跟踪我们已执行的任务数量。 - 我们调用任务的处理程序
task.handler,将任务的数据对象(task.data)作为输入参数传递给它。 - 我们调用一个函数
scheduleStatusRefresh(),以处理调度屏幕更新以反映我们进度的变化。
当时间用完时,如果列表中仍有任务,我们会再次调用requestIdleCallback(),以便我们下次有空闲时间时可以继续处理任务。如果队列为空,我们将 taskHandle 设置为 0,表示我们没有调度回调。这样,我们下次调用enqueueTask()时就会知道请求回调。
更新状态显示
我们希望能够做的一件事是使用日志输出和进度信息更新我们的文档。但是,您不能在空闲回调中安全地更改 DOM。相反,我们将使用requestAnimationFrame()请求浏览器在可以安全更新显示时调用我们。
调度显示更新
DOM 更改通过调用 scheduleStatusRefresh() 函数进行调度。
function scheduleStatusRefresh() {
if (!statusRefreshScheduled) {
requestAnimationFrame(updateDisplay);
statusRefreshScheduled = true;
}
}
这是一个简单的函数。它通过检查 statusRefreshScheduled 的值来查看我们是否已经调度了显示刷新。如果它是 false,我们调用 requestAnimationFrame() 来调度刷新,提供 updateDisplay() 函数来处理该工作。
更新显示
updateDisplay() 函数负责绘制进度框和日志的内容。当 DOM 处于安全状态,我们可以在渲染下一帧的过程中应用更改时,浏览器会调用它。
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。我们将使用它来确定我们是否应该更新滚动位置,以确保在添加内容后日志保持在末尾。
接下来,如果有任何任务已排队,我们会更新进度和状态信息。
- 如果进度条的当前最大值与当前排队的任务总数(
totalTaskCount)不同,则我们更新显示的已排队任务总数(totalTaskCountElem)和进度条的最大值,以便它正确缩放。 - 我们对到目前为止已处理的任务数量也做同样的事情;如果
progressBarElem.value与当前正在处理的任务编号(currentTaskNumber)不同,那么我们更新当前正在处理的任务的显示值和进度条的当前值。
然后,如果有文本等待添加到日志中(即,如果logFragment不是null),我们使用Element.appendChild()将其附加到日志元素,并将logFragment设置为null,以便我们不再添加它。
如果日志在我们开始时已滚动到底部,我们会确保它仍然如此。然后我们将statusRefreshScheduled设置为false,以表示我们已处理刷新并且可以安全地请求新的刷新。
向日志添加文本
log() 函数将指定的文本添加到日志中。由于我们不知道在调用log()时是否可以安全地立即操作 DOM,因此我们将缓存日志文本直到可以安全更新。上面,在updateDisplay()的代码中,您可以找到在动画帧更新时实际将日志文本添加到日志元素的代码。
function log(text) {
logFragment ??= document.createDocumentFragment();
const el = document.createElement("div");
el.textContent = text;
logFragment.appendChild(el);
}
首先,如果logFragment当前不存在,我们创建一个名为logFragment的DocumentFragment对象。此元素是一个伪 DOM,我们可以在其中插入元素而不会立即更改主 DOM 本身。
然后,我们创建一个新的<div>元素,并将其内容设置为与输入text匹配。然后,我们将新元素追加到logFragment中的伪 DOM 的末尾。logFragment将累积日志条目,直到下次调用updateDisplay(),一旦 DOM 准备好进行更改。
运行任务
现在我们已经完成了任务管理和显示维护代码,我们就可以开始设置代码来运行完成工作的任务了。
任务处理程序
我们将用作任务处理程序的函数——即用作任务对象的handler属性值的函数——是logTaskHandler()。它是一个简单的函数,为每个任务向日志输出大量内容。在您自己的应用程序中,您会将此代码替换为您希望在空闲时间执行的任何任务。请记住,任何您希望更改 DOM 的操作都需要通过requestAnimationFrame()来处理。
function logTaskHandler(data) {
log(`Running task #${currentTaskNumber}`);
for (let i = 0; i < data.count; i += 1) {
log(`${(i + 1).toString()}. ${data.text}`);
}
}
主程序
当用户点击“开始”按钮时,所有内容都会触发,这会导致调用decodeTechnoStuff()函数。
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);
decodeTechnoStuff()首先将 totalTaskCount(到目前为止添加到队列中的任务数量)和 currentTaskNumber(当前正在运行的任务)的值归零,然后调用updateDisplay()将显示重置为“尚未发生任何事情”状态。
此示例将创建随机数量的任务(介于 100 到 200 个之间)。为此,我们使用getRandomIntInclusive()函数,它在Math.random()的文档中作为示例提供,以获取要创建的任务数量。
然后我们开始一个循环来创建实际的任务。对于每个任务,我们创建一个对象,taskData,其中包含两个属性
count是要从任务中输出到日志的字符串数量。text是要输出到日志的文本,输出次数由count指定。
然后,通过调用enqueueTask()将每个任务排队,将logTaskHandler()作为处理函数传递,并将taskData对象作为在调用函数时传递给函数的对象。
结果
下面是上面代码的实际运行结果。尝试一下,在浏览器的开发者工具中玩一下,并在您自己的代码中尝试使用它。
规范
| 规范 |
|---|
| requestIdleCallback() # requestIdleCallback 方法 |
浏览器兼容性
加载中…