2023 年 7 月 9 日

事件循环:微任务和宏任务

浏览器 JavaScript 执行流程,以及 Node.js,都是基于事件循环

了解事件循环的工作原理对于优化非常重要,有时对于正确的架构也很重要。

在本章中,我们首先介绍有关工作原理的理论细节,然后看看这些知识的实际应用。

事件循环

事件循环的概念非常简单。有一个无限循环,JavaScript 引擎在其中等待任务,执行它们,然后休眠,等待更多任务。

引擎的一般算法

  1. 当有任务时
    • 执行它们,从最旧的任务开始。
  2. 休眠直到出现任务,然后转到步骤 1。

这正式化了我们在浏览页面时所看到的现象。JavaScript 引擎在大多数情况下什么也不做,只有在脚本/处理程序/事件激活时才会运行。

任务示例

  • 当外部脚本 <script src="..."> 加载时,任务是执行它。
  • 当用户移动鼠标时,任务是分派 mousemove 事件并执行处理程序。
  • 当计划的 setTimeout 到期时,任务是运行其回调函数。
  • 等等。

任务被设置 - 引擎处理它们 - 然后等待更多任务(同时休眠并消耗接近零的 CPU)。

可能发生的是,当引擎忙于执行任务时,会收到新的任务,此时会将其排队。

任务形成一个队列,称为“宏任务队列”(v8 术语)。

例如,当引擎忙于执行 script 时,用户可能会移动鼠标导致 mousemove 事件,并且 setTimeout 可能到期等等,这些任务会形成一个队列,如上图所示。

队列中的任务按照“先到先服务”的原则进行处理。当引擎浏览器完成 script 的执行后,它会处理 mousemove 事件,然后处理 setTimeout 处理程序,依此类推。

到目前为止,相当简单,对吧?

另外两个细节

  1. 渲染永远不会在引擎执行任务时发生。无论任务需要多长时间,这都不重要。只有在任务完成后才会绘制对 DOM 的更改。
  2. 如果任务花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,一段时间后,它会发出类似“页面无响应”的警报,建议终止整个页面的任务。当存在大量复杂的计算或导致无限循环的编程错误时,就会发生这种情况。

这就是理论。现在让我们看看如何应用这些知识。

用例 1:拆分 CPU 密集型任务

假设我们有一个 CPU 密集型任务。

例如,语法高亮(用于在此页面上对代码示例进行着色)非常占用 CPU。为了突出显示代码,它会执行分析,创建许多彩色元素,并将它们添加到文档中 - 对于大量文本来说,这需要花费大量时间。

当引擎忙于语法高亮时,它无法执行其他与 DOM 相关的操作,处理用户事件等等。它甚至可能导致浏览器“卡顿”或“挂起”一段时间,这是不可接受的。

我们可以通过将大型任务分解成多个小任务来避免问题。首先突出显示前 100 行,然后为接下来的 100 行安排一个 setTimeout(延迟为零),以此类推。

为了演示这种方法,为了简单起见,我们不进行文本突出显示,而是使用一个从 1 计数到 1000000000 的函数。

如果你运行下面的代码,引擎会“卡住”一段时间。对于服务器端 JS 来说,这很明显,如果你是在浏览器中运行它,那么尝试点击页面上的其他按钮——你会发现,在计数完成之前,其他事件都不会被处理。

let i = 0;

let start = Date.now();

function count() {

  // do a heavy job
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

浏览器甚至可能会显示“脚本运行时间过长”的警告。

让我们使用嵌套的 setTimeout 调用来拆分任务。

let i = 0;

let start = Date.now();

function count() {

  // do a piece of the heavy job (*)
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // schedule the new call (**)
  }

}

count();

现在,在“计数”过程中,浏览器界面可以完全正常工作。

count 的一次运行完成一部分工作 (*),然后根据需要重新安排自身 (**)

  1. 第一次运行计数:i=1...1000000
  2. 第二次运行计数:i=1000001..2000000
  3. 等等。

现在,如果在引擎忙于执行第一部分时出现了一个新的侧边任务(例如 onclick 事件),它会被排队,然后在第一部分完成之后,在下一部分开始之前执行。在 count 执行之间定期返回事件循环,为 JavaScript 引擎提供了足够的“空间”来执行其他操作,以响应其他用户操作。

值得注意的是,两种变体——使用 setTimeout 拆分任务和不拆分任务——在速度上是相当的。总体计数时间没有太大区别。

为了使它们更接近,让我们进行改进。

我们将把调度移到 count() 的开头。

let i = 0;

let start = Date.now();

function count() {

  // move the scheduling to the beginning
  if (i < 1e9 - 1e6) {
    setTimeout(count); // schedule the new call
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }

}

count();

现在,当我们开始 count() 并发现我们需要 count() 更多时,我们会立即安排它,在执行任务之前。

如果你运行它,你会很容易注意到它所花费的时间明显减少了。

为什么?

很简单:正如你所记得的,对于许多嵌套的 setTimeout 调用,浏览器中存在 4ms 的最小延迟。即使我们设置了 0,它也是 4ms(或更多)。因此,我们越早安排它,它运行的速度就越快。

最后,我们已经将一个 CPU 密集型任务分解成多个部分——现在它不会阻塞用户界面。而且它的总体执行时间并没有长很多。

用例 2:进度指示

将繁重的任务拆分以供浏览器脚本使用带来的另一个好处是,我们可以显示进度指示。

如前所述,DOM 的更改只有在当前正在运行的任务完成后才会被绘制,无论它需要多长时间。

一方面,这很棒,因为我们的函数可能会创建很多元素,逐个添加到文档中并更改它们的样式——访问者不会看到任何“中间”的未完成状态。这很重要,对吧?

这是演示,对i的更改直到函数完成才会显示,所以我们只会看到最后一个值

<div id="progress"></div>

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

…但我们也可能想在任务期间显示一些东西,例如进度条。

如果我们使用setTimeout将繁重的任务分成几部分,那么更改就会在它们之间被绘制出来。

这看起来更漂亮

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

现在<div>显示了i的递增值,这是一种进度条。

用例 3:在事件之后做一些事情

在事件处理程序中,我们可能决定将某些操作推迟到事件冒泡并已在所有级别处理完毕为止。我们可以通过将代码包装在零延迟setTimeout中来做到这一点。

分派自定义事件一章中,我们看到了一个例子:自定义事件menu-open是在setTimeout中分派的,以便它在“click”事件完全处理完毕后发生。

menu.onclick = function() {
  // ...

  // create a custom event with the clicked menu item data
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // dispatch the custom event asynchronously
  setTimeout(() => menu.dispatchEvent(customEvent));
};

宏任务和微任务

除了本章中描述的宏任务之外,还有微任务一章中提到的微任务

微任务完全来自我们的代码。它们通常由 Promise 创建:.then/catch/finally 处理程序的执行成为一个微任务。微任务也“在幕后”用于await,因为它是 Promise 处理的另一种形式。

还有一个特殊的函数queueMicrotask(func),它将func排队以在微任务队列中执行。

在每个宏任务之后,引擎会立即执行来自微任务队列的所有任务,然后才会运行任何其他宏任务或渲染或其他任何操作。

例如,看一下

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

这里将是什么顺序?

  1. code 首先显示,因为它是一个普通的同步调用。
  2. promise 第二个显示,因为.then 通过微任务队列,并在当前代码之后运行。
  3. timeout 最后显示,因为它是一个宏任务。

更丰富的事件循环图看起来像这样(顺序是从上到下,即:脚本首先,然后是微任务,渲染等等)

所有微任务都在任何其他事件处理或渲染或任何其他宏任务发生之前完成。

这很重要,因为它保证应用程序环境在微任务之间基本上是相同的(没有鼠标坐标变化,没有新的网络数据等)。

如果我们想异步执行一个函数(在当前代码之后),但在更改被渲染或新的事件被处理之前,我们可以使用queueMicrotask来调度它。

这里有一个使用“计数进度条”的例子,类似于之前显示的例子,但使用queueMicrotask而不是setTimeout。你可以看到它在最后渲染。就像同步代码一样

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e6) {
      queueMicrotask(count);
    }

  }

  count();
</script>

总结

一个更详细的事件循环算法(尽管与规范相比仍然是简化的)

  1. 宏任务队列中出队并运行最旧的任务(例如“脚本”)。
  2. 执行所有微任务
    • 当微任务队列不为空时
      • 出队并运行最老的微任务。
  3. 渲染任何更改。
  4. 如果宏任务队列为空,则等待宏任务出现。
  5. 转到步骤 1。

要安排新的宏任务

  • 使用延迟为零的setTimeout(f)

这可以用来将一个计算量大的任务分成多个部分,以便浏览器能够对用户事件做出反应并在它们之间显示进度。

此外,在事件处理程序中使用它来在事件完全处理后(冒泡完成)安排一个操作。

要安排新的微任务

  • 使用queueMicrotask(f)
  • 承诺处理程序也会通过微任务队列。

微任务之间没有 UI 或网络事件处理:它们一个接一个地立即运行。

因此,人们可能希望queueMicrotask异步执行一个函数,但在环境状态内。

Web Workers

对于不应该阻塞事件循环的长时间、繁重的计算,我们可以使用Web Workers

这是一种在另一个并行线程中运行代码的方法。

Web Workers 可以与主进程交换消息,但它们有自己的变量和自己的事件循环。

Web Workers 无法访问 DOM,因此它们主要用于计算,以同时使用多个 CPU 内核。

任务

重要性:5
console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

控制台输出为:1 7 3 5 2 6 4。

这个任务很简单,我们只需要知道微任务和宏任务队列是如何工作的。

让我们一步一步地看看发生了什么。

console.log(1);
// The first line executes immediately, it outputs `1`.
// Macrotask and microtask queues are empty, as of now.

setTimeout(() => console.log(2));
// `setTimeout` appends the callback to the macrotask queue.
// - macrotask queue content:
//   `console.log(2)`

Promise.resolve().then(() => console.log(3));
// The callback is appended to the microtask queue.
// - microtask queue content:
//   `console.log(3)`

Promise.resolve().then(() => setTimeout(() => console.log(4)));
// The callback with `setTimeout(...4)` is appended to microtasks
// - microtask queue content:
//   `console.log(3); setTimeout(...4)`

Promise.resolve().then(() => console.log(5));
// The callback is appended to the microtask queue
// - microtask queue content:
//   `console.log(3); setTimeout(...4); console.log(5)`

setTimeout(() => console.log(6));
// `setTimeout` appends the callback to macrotasks
// - macrotask queue content:
//   `console.log(2); console.log(6)`

console.log(7);
// Outputs 7 immediately.

总结一下,

  1. 数字17立即出现,因为简单的console.log调用不使用任何队列。
  2. 然后,在主代码流完成后,微任务队列运行。
    • 它包含命令:console.log(3); setTimeout(...4); console.log(5)
    • 数字35出现,而setTimeout(() => console.log(4))console.log(4)调用添加到宏任务队列的末尾。
    • 宏任务队列现在是:console.log(2); console.log(6); console.log(4)
  3. 微任务队列变空后,宏任务队列执行。它输出264

最后,我们得到输出:1 7 3 5 2 6 4

教程地图

评论

在评论之前请阅读…
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入少量代码,请使用<code>标签,对于多行代码,请将其包装在<pre>标签中,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)