在 JavaScript 中使用 queueMicrotask() 进行微任务
微任务是短函数,它在创建它的函数或程序退出后并且只有在JavaScript 执行栈为空时执行,但在将控制权返回给用户代理用来驱动脚本执行环境的事件循环之前执行。
此事件循环可以是浏览器的主要事件循环,也可以是驱动Web Worker的事件循环。这使得给定函数能够运行,而不会有干扰其他脚本执行的风险,同时也确保微任务在用户代理有机会对微任务采取的操作做出反应之前运行。
JavaScript Promise和Mutation Observer API都使用微任务队列来运行它们的回调函数,但在其他情况下,将工作推迟到当前事件循环传递结束也是有用的。为了允许第三方库、框架和 polyfill 使用微任务,queueMicrotask()
方法在Window
和Worker
接口上公开。
任务 vs. 微任务
为了正确讨论微任务,首先了解 JavaScript 任务是什么以及微任务与任务的区别是有帮助的。这是一个简短的、简化的解释,但如果您想了解更多详细信息,可以在文章中阅读相关信息:深入:微任务和 JavaScript 运行时环境.
任务
任务是指任何通过标准机制安排运行的东西,比如最初开始运行程序、异步调度事件或触发间隔或超时。这些都安排在任务队列上。
例如,当以下情况发生时,任务会添加到任务队列中
- 直接执行新的 JavaScript 程序或子程序(例如从控制台,或通过在
<script>
元素中运行代码)。 - 用户点击元素。然后创建一个任务,并执行所有事件回调函数。
- 使用
setTimeout()
或setInterval()
创建的超时或间隔达到,导致相应的回调函数被添加到任务队列中。
驱动您的代码的事件循环按它们入队的顺序,一个接一个地处理这些任务。任务队列中最老的 runnable 任务将在事件循环的单次迭代中执行。之后,微任务将被执行,直到微任务队列为空,然后浏览器可以选择更新渲染。然后浏览器继续执行下一个事件循环迭代。
微任务
起初,微任务和任务之间的区别似乎很小。它们确实很相似;两者都由 JavaScript 代码组成,这些代码被放置在队列中并在适当的时间运行。然而,事件循环只运行迭代开始时队列中存在的任务,一个接一个,而它处理微任务队列的方式则截然不同。
有两个主要区别。
首先,每次任务退出时,事件循环都会检查该任务是否将控制权返回给其他 JavaScript 代码。如果没有,它会运行微任务队列中的所有微任务。因此,微任务队列在事件循环的每次迭代中都会被多次处理,包括在处理事件和其他回调函数之后。
其次,如果微任务通过调用queueMicrotask()
将更多微任务添加到队列中,这些新添加的微任务将在下一个任务运行之前执行。这是因为事件循环会不断调用微任务,直到队列为空,即使队列中不断添加更多微任务也是如此。
警告:由于微任务本身可以将更多微任务排队,并且事件循环会一直处理微任务直到队列为空,因此存在事件循环无休止地处理微任务的真正风险。请谨慎对待递归添加微任务的方式。
使用微任务
在更深入地探讨之前,需要再次注意,大多数开发人员不会经常使用微任务,甚至根本不会使用。它们是现代基于浏览器的 JavaScript 开发中高度专业化的功能,允许您调度代码,使它跳到用户计算机上等待发生的一系列事件中的其他事件前面。滥用此功能会导致性能问题。
排队微任务
因此,您通常只应在没有其他解决方案时,或在创建需要使用微任务来实现其功能的框架或库时使用微任务。虽然过去一直有技巧可以将微任务排队(例如,通过创建一个立即解析的 Promise),但添加queueMicrotask()
方法提供了一种安全且无需技巧地引入微任务的标准方法。
通过引入queueMicrotask()
,可以避免在使用 Promise 创建微任务时出现的怪癖。例如,当使用 Promise 创建微任务时,回调函数抛出的异常会作为拒绝的 Promise 报告,而不是作为标准异常报告。此外,创建和销毁 Promise 会在时间和内存方面产生额外的开销,而正确排队微任务的函数可以避免这些开销。
将要在处理微任务时调用的 JavaScript Function
传递到queueMicrotask()
方法中,该方法在全局上下文中公开,由Window
或Worker
接口定义,具体取决于当前执行上下文。
queueMicrotask(() => {
/* code to run in the microtask here */
});
微任务函数本身不接受任何参数,也不返回值。
何时使用微任务
在本节中,我们将看看微任务特别有用的场景。通常,它是在 JavaScript 执行上下文的正文退出后,但在任何事件处理程序、超时和间隔或其他回调函数处理之前捕获或检查结果,或执行清理操作。
何时有用?
使用微任务的主要原因是:即使结果或数据同步可用,也要确保任务的一致顺序,同时最大程度地减少用户感知的操作延迟风险。
确保有条件使用 Promise 时的顺序
微任务可以用来确保执行顺序始终一致的一种情况是,当在一个 `if...else` 语句(或其他条件语句)的一个分支中使用 Promise,但在另一个分支中没有使用 Promise 时。考虑以下代码:
customElement.prototype.getData = (url) => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
这里引入的问题是,在一个 `if...else` 语句的分支中使用任务(在图像在缓存中可用的情况下),但在 `else` 分支中涉及 Promise,会导致操作顺序不一致;例如,如下所示。
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data…");
element.getData();
console.log("Data fetched");
连续两次执行此代码会得到以下结果。
当数据未缓存时
Fetching data Data fetched Loaded data
当数据已缓存时
Fetching data Loaded data Data fetched
更糟糕的是,有时元素的 `data` 属性会被设置,有时则不会,这取决于代码运行完成的时间。
我们可以通过在 `if` 分支中使用微任务来确保这些操作的顺序一致,从而平衡两个分支。
customElement.prototype.getData = (url) => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
这通过让两种情况都处理 `data` 的设置和 `load` 事件的触发来平衡分支(在 `if` 分支中使用 `queueMicrotask()`,并在 `else` 分支中使用 `fetch()` 方法使用的 Promise)。
批量操作
您还可以使用微任务将来自不同来源的多个请求收集到一个批次中,避免可能涉及多个调用来处理相同类型工作的开销。
下面的代码片段创建了一个函数,将多个消息批处理到一个数组中,使用微任务在上下文退出时将它们作为单个对象发送。
const messageQueue = [];
let sendMessage = (message) => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
fetch("url-of-receiver", json);
});
}
};
当 `sendMessage()` 被调用时,指定的 message 首先被推送到 message queue 数组中。然后事情变得有趣起来。
如果我们刚刚添加到数组中的 message 是第一个 message,我们会排队一个微任务来发送一个批次。微任务将始终在 JavaScript 执行路径到达顶层时执行,就在运行回调之前。这意味着在期间内对 `sendMessage()` 的任何进一步调用都将把它们的 message 推送到 message queue 中,但由于在添加微任务之前进行的数组长度检查,不会排队新的微任务。
那么,当微任务运行时,它将有一个包含多个 message 的数组在等待它。它首先使用 `JSON.stringify()` 方法将其编码为 JSON。之后,数组的内容不再需要,因此我们清空了 `messageQueue` 数组。最后,我们使用 `fetch()` 方法将 JSON 字符串发送到服务器。
这允许在事件循环的同一迭代期间对 `sendMessage()` 的每一次调用将其 message 添加到同一个 `fetch()` 操作中,而不会因为其他任务(如超时等)而延迟传输。
服务器将接收 JSON 字符串,然后可能会对其进行解码并处理在结果数组中找到的 message。
示例
简单的微任务示例
在这个简单的示例中,我们看到排队一个微任务会导致微任务的回调在顶级脚本主体完成运行之后运行。
JavaScript
在以下代码中,我们看到对 `queueMicrotask()` 的调用用于调度一个微任务运行。此调用被对 `log()` 的调用包围,`log()` 是一个自定义函数,用于将文本输出到屏幕上。
log("Before enqueueing the microtask");
queueMicrotask(() => {
log("The microtask has run.");
});
log("After enqueueing the microtask");
结果
超时和微任务示例
在这个示例中,一个超时被调度在零毫秒后触发(或“尽快”)。这演示了在调度新任务(例如,使用 `setTimeout()`)和使用微任务时,“尽快”的含义之间的区别。
JavaScript
在以下代码中,我们看到对 `queueMicrotask()` 的调用用于调度一个微任务运行。此调用被对 `log()` 的调用包围,`log()` 是一个自定义函数,用于将文本输出到屏幕上。
下面的代码调度了一个在零毫秒后发生的超时,然后排队一个微任务。这被对 `log()` 的调用包围,以输出额外的消息。
const callback = () => log("Regular timeout callback has run");
const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");
log("Main program started");
setTimeout(callback, 0);
queueMicrotask(urgentCallback);
log("Main program exiting");
结果
注意,从主程序主体记录的输出首先出现,其次是微任务的输出,最后是超时的回调。这是因为当处理主程序执行的任务退出时,微任务队列在任务队列之前被处理,任务队列是超时回调所在的队列。记住任务和微任务保存在单独的队列中,并且微任务优先运行,这将有助于理解这一点。
来自函数的微任务
这个示例在之前的示例的基础上略微扩展,添加了一个执行一些工作的函数。此函数使用 `queueMicrotask()` 来调度一个微任务。需要记住的重点是,微任务不是在函数退出时处理,而是在主程序退出时处理。
JavaScript
主程序代码如下。这里的 `doWork()` 函数调用 `queueMicrotask()`,但微任务仍然不会在整个程序退出之前触发,因为这是任务退出并且执行堆栈上没有其他内容的时候。
const callback = () => log("Regular timeout callback has run");
const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");
const doWork = () => {
let result = 1;
queueMicrotask(urgentCallback);
for (let i = 2; i <= 10; i++) {
result *= i;
}
return result;
};
log("Main program started");
setTimeout(callback, 0);
log(`10! equals ${doWork()}`);
log("Main program exiting");