Published on

JavaScript Event Loop

Authors
  • avatar
    Name
    Deng Hua
    Twitter

目录

浏览器 JavaScript 执行流程以及Node.js执行流程都基于事件循环。了解事件循环的工作原理对于优化非常重要,有时对于正确的架构也很重要。

在本章中,我们首先介绍有关事物如何运作的理论细节,然后看到这些知识的实际应用。

事件循环

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

通用算法描述:

  1. 如果有任务:

    • 从最早的任务开始执行
  2. 执行完毕后进入睡眠,再重复任务1

JavaScript 引擎大多数时候什么都不做,它只在脚本/处理程序/事件激活时运行。


一些任务示例:

  • 加载外部脚本,如(<script src="...">) 时。

  • 当用户移动鼠标时,任务是调度 mousemove 事件并执行处理程序。

  • 处理setTimeout时,任务是当timeout时间到了,运行其回调。

  • 等等

编排任务 - 引擎处理执行 - 等待更多任务

可能会发生这样的情况:即任务在引擎繁忙时到来,那么此时将需要排队。这些任务形成一个队列,即所谓的“Marcotask Queue - 宏任务队列”(v8 术语):

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

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

到目前为止,很简单,对吧?

还有两个细节:

  1. 当引擎执行任务时,渲染永远不会发生。任务是否需要很长时间并不重要。只有在任务完成后,才会绘制 DOM 的更改

  2. 如果任务花费的时间太长,浏览器将无法执行其他任务,例如处理用户事件。因此,一段时间后,它会发出类似“Page Unresponsive”的警报,建议终止整个页面的任务。当存在大量复杂的计算或导致无限循环的编程错误时,就会发生这种情况。

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


用例1:拆分占用大量 CPU 的任务

假设我们有一个需要大量 CPU计算 的任务。

例如,语法突出显示(用于为本页上的代码示例着色)占用大量 CPU。为了突出显示代码,它执行分析,创建彩色元素,将它们添加到文档中 - 对于需要大量CPU计算时间的大量文本。

当引擎忙于语法突出显示时,它无法执行其他与 DOM 相关的事情、处理用户事件等。它甚至可能导致浏览器“打嗝”甚至“挂起”一会儿,这是不可接受的。

我们可以通过将大任务分成几部分来避免问题。突出显示前 100 行,然后为接下来的 100 行安排 setTimeout (零延迟),依此类推。

为了演示这种方法,为了简单起见,让我们采用一个从 11000000000 计数的函数,而不是文本突出显示。

运行以下代码,引擎将“挂起”一段时间。如果你在浏览器中运行它,那么尝试单击页面上的其他按钮 —— 你会看到在计数完成之前浏览器无法处理其他事件。

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();

现在,浏览器UI在count过程中也能响应其他交互事件。

单次运行 count 会完成部分的计数 ,然后在重新调用自身继续累加:

  1. 首次运行计数: i=1...1000000

  2. 第二次运行计数: i=1000001..2000000

  3. ...

这样,如果在引擎执行第 1 部分时出现新的其他任务(例如 onclick 事件),它会排队到第 1 部分完成时再执行,然后继续再执行下一部分。

事件循环为JavaScript引擎提供了足够的“空隙时间”来执行其他操作,以对其他用户操作做出反应。

让我们做一个改进:

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();

运行它,很容易注意到它花费的时间要少得多。

为什么?

这很简单:您记得吗,对于许多嵌套 setTimeout 的调用,浏览器内的最小延迟为 4 毫秒。即使我们设置 0。因此,我们越早运行它,它的运行速度就越快。

总结: 我们将一个需要密集 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>

宏任务和微任务

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

微任务完全来自我们编写的代码。它们通常由Promise创建: 执行.then/catch/finally就能创建一项微任务。

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

在执行完每个宏任务后,引擎会立即执行微任务队列中的所有任务,然后再运行任何其他宏任务或者渲染任务等。

例:

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

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

alert("code");

这里的顺序是什么?

  • code 首先显示,因为它是常规同步调用。

  • promise 显示第二个,因为 .then 属于微任务队列,在当前代码之后运行。

  • 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. 执行所有微任务:

    • 当微任务队列不为空:

    • 执行最早进入微任务队列的任务,(哪怕执行微任务过程中产生新的微任务,也会在当前轮次执行完毕)。

    • 重复1,直到微任务队列为空

  3. 渲染DOM更改(如果存在更改)

  4. 如果宏任务队列为空,则等待新的宏任务产生(sleep)

  5. 回到步骤 1。


关于任务与微任务的区别:

  • 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行

  • 每次当一个任务退出且执行上下文栈为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行 —— 即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,这些新的微任务将在下一个任务开始运行之前,在当前事件循环迭代结束之前执行。

参考:

Event loop: microtasks and macrotasks

深入:微任务与 Javascript 运行时环境

Microtasks