在 JavaScript 中使用 microtask (queueMicrotask())

微任务是一个简短的函数,它在其创建函数或程序退出后执行,并且仅当 JavaScript 执行栈为空时才执行,但会在控制权返回给 用户代理 用于驱动脚本执行环境的事件循环之前执行。

此事件循环可以是浏览器的主要事件循环,也可以是驱动 Web Worker 的事件循环。这使得给定函数可以在不干扰其他脚本执行的风险下运行,同时也确保微任务在用户代理有机会对微任务执行的操作做出反应之前运行。

JavaScript PromiseMutation Observer API 都使用微任务队列来运行其回调,但在其他时候,将工作推迟到当前事件循环结束时再处理也很有用。为了允许第三方库、框架和 polyfill 使用微任务,queueMicrotask() 方法在 WindowWorkerGlobalScope 接口上公开。

任务与微任务

要正确讨论微任务,首先了解 JavaScript 任务是什么以及微任务与任务有何不同是很有用的。这是一个快速、简化的解释,但如果您想了解更多细节,可以阅读文章 深入了解:微任务和 JavaScript 运行时环境 中的信息。

任务

任务是通过标准机制(例如最初启动程序、异步调度事件或触发间隔或超时)计划运行的任何内容。这些都会被调度到任务队列中。

例如,在以下情况下,任务会添加到任务队列中:

  • 直接执行新的 JavaScript 程序或子程序(例如从控制台,或通过运行 <script> 元素中的代码)。
  • 用户点击一个元素。然后创建一个任务并执行所有事件回调。
  • 使用 setTimeout()setInterval() 创建的超时或间隔到达,导致相应的回调添加到任务队列中。

驱动代码的事件循环按它们入队的顺序一个接一个地处理这些任务。任务队列中最旧的可运行任务将在事件循环的单次迭代中执行。之后,微任务将执行,直到微任务队列为空,然后浏览器可能会选择更新渲染。然后浏览器进入事件循环的下一次迭代。

微任务

乍一看,微任务和任务之间的差异似乎很小。它们确实相似;两者都由 JavaScript 代码组成,这些代码被放置在队列中并在适当的时间运行。然而,事件循环只运行迭代开始时队列中存在的任务,一个接一个地运行,它处理微任务队列的方式则大相径庭。

有两个关键区别

  1. 每次任务退出时,事件循环都会检查任务是否将控制权返回给其他 JavaScript 代码。如果没有,它会运行微任务队列中的所有微任务。因此,微任务队列在事件循环的每次迭代中都会被多次处理,包括在处理事件和其他回调之后。
  2. 如果微任务通过调用 queueMicrotask() 向队列添加更多微任务,则这些新添加的微任务会在下一个任务运行之前执行。这是因为事件循环会不断调用微任务,直到队列中没有剩余微任务,即使不断有新的微任务被添加。

警告:由于微任务本身可以入队更多微任务,并且事件循环会持续处理微任务直到队列为空,因此存在事件循环无休止地处理微任务的真实风险。请谨慎对待如何递归添加微任务。

使用微任务

在深入讨论之前,再次强调大多数开发者即使使用微任务也很少,甚至根本不使用,这一点很重要。它们是现代基于浏览器的 JavaScript 开发中高度专业化的功能,允许您调度代码以在用户计算机上等待发生的漫长事件集合中抢占其他事情。滥用此功能将导致性能问题。

微任务入队

因此,您通常只在没有其他解决方案时,或者在创建需要使用微任务来实现其功能的框架或库时才应该使用微任务。虽然过去有一些技巧可以使微任务入队(例如通过创建立即解决的 Promise),但添加 queueMicrotask() 方法提供了一种安全且无需技巧的标准方式来引入微任务。

通过引入 queueMicrotask(),可以避免在使用 Promise 创建微任务时出现的怪癖。例如,当使用 Promise 创建微任务时,回调抛出的异常会被报告为被拒绝的 Promise,而不是标准异常。此外,创建和销毁 Promise 会带来额外的时间和内存开销,而正确将微任务入队的函数可以避免这些开销。

将 JavaScript Function 传递给 queueMicrotask() 方法,以便在上下文处理微任务时调用。根据当前执行上下文,该方法会在 WindowWorker 接口定义的全局上下文上公开。

js
queueMicrotask(() => {
  /* code to run in the microtask here */
});

微任务函数本身不接受参数,也不返回任何值。

何时使用微任务

在本节中,我们将探讨微任务特别有用的场景。通常,它是在 JavaScript 执行上下文的主体退出之后——但在任何事件处理程序、超时和间隔或其他回调处理之前——捕获或检查结果,或执行清理。

这何时有用?

使用微任务的主要原因是确保任务的顺序一致,即使结果或数据是同步可用的,同时还能降低操作中用户可察觉的延迟风险。

在条件使用 Promise 时确保顺序

微任务可以用来确保执行顺序始终一致的一种情况是,当 Promise 在 if...else 语句(或其他条件语句)的一个分支中使用,但在另一个分支中不使用时。考虑以下代码:

js
customElement.prototype.getData = function (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,我们遇到了操作顺序可能不同的情况;例如,如下所示。

js
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` 子句中使用微任务来平衡两个子句,从而确保这些操作的顺序一致

js
customElement.prototype.getData = function (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)。

批处理操作

您还可以使用微任务将来自各种来源的多个请求收集到一个批次中,从而避免多次调用处理相同类型工作可能带来的开销。

下面的代码片段创建了一个函数,该函数将多个消息批量存储到一个数组中,并在上下文退出时使用微任务将它们作为单个对象发送。

js
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() 时,指定的消息首先被推送到消息队列数组中。然后事情变得有趣起来。

如果刚刚添加到数组中的消息是第一条,我们会将一个微任务入队,该微任务将发送一个批次。微任务将像往常一样在 JavaScript 执行路径到达顶层时执行,就在运行回调之前。这意味着在此期间对 sendMessage() 的任何进一步调用都会将其消息推送到消息队列中,但由于在添加微任务之前进行了数组长度检查,因此不会有新的微任务入队。

然后,当微任务运行时,它有一个可能包含许多消息的数组等待处理。它首先使用 JSON.stringify() 方法将其编码为 JSON。之后,不再需要数组的内容,因此我们清空 messageQueue 数组。最后,我们使用 fetch() 方法将 JSON 字符串发送到服务器。

这使得在事件循环的同一次迭代中对 sendMessage() 的每次调用都可以将其消息添加到相同的 fetch() 操作中,而不会潜在地导致其他任务(例如超时等)延迟传输。

服务器将收到 JSON 字符串,然后大概会对其进行解码并处理结果数组中找到的消息。

示例

简单微任务示例

在这个简单的例子中,我们看到入队一个微任务会导致微任务的回调在该顶级脚本主体运行完成后运行。

JavaScript

在以下代码中,我们看到调用了 queueMicrotask() 以调度一个微任务运行。此调用被 log()(一个用于向屏幕输出文本的自定义函数)的调用所包围。

js
log("Before enqueueing the microtask");
queueMicrotask(() => {
  log("The microtask has run.");
});
log("After enqueueing the microtask");

结果

超时和微任务示例

在此示例中,超时计划在零毫秒后(或“尽快”)触发。这演示了调度新任务(例如使用 setTimeout())与使用微任务时“尽快”的含义之间的区别。

JavaScript

在以下代码中,我们看到调用了 queueMicrotask() 以调度一个微任务运行。此调用被 log()(一个用于向屏幕输出文本的自定义函数)的调用所包围。

下面的代码安排了一个在零毫秒后发生的超时,然后将一个微任务入队。这被 log() 调用包围以输出更多消息。

js
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(),但微任务仍然要等到整个程序退出时才会被触发,因为那时任务退出并且执行栈上没有其他内容。

js
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");

结果

另见