vlambda博客
学习文章列表

面试官:什么是 EventLoop。你:一脸蒙蔽。看完这篇文章就懂了

面试官:什么是 EventLoop。你:一脸蒙蔽。看完这篇文章就懂了

文章翻译自:

https://javascript.info/event-loop

在这片文章,我们要带着两个问题去学习

  • EventLoop 概念是什么
  • 为什么需要 EventLoop

事件循环

浏览器 js 以及 Nodejs 都是基于事件循环,了解事件循环对于代码优化非常重要。在本章中,我们首先介绍有关事物如何工作的理论细节,然后介绍该知识的实际应用。

就是有一个无限循环机制:JavaScript 引擎等待任务,执行任务,然后休眠,等待更多任务。

引擎的一般算法

  • 有任务时:
    • 从最早的任务开始执行它们。
  • 休眠直到出现任务,然后转到有任务时

这是浏览页面时看到的形式化信息。JavaScript 引擎大部分时间不执行任何操作,仅在脚本/处理程序/事件激活时运行。

任务示例

  • <script >加载外部脚本时,任务是执行它
  • 用户移动鼠标时,任务是调度 mousemove 事件并执行处理程序
  • 当计划好的时间到了 setTimeout,任务是运行其回调。
  • ... 等等

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

引擎繁忙时可能会发生任务,然后将其排入队列。

任务形成一个队列,即所谓的“宏任务队列”(v8术语):

例如,当引擎正忙于执行 script,用户可能会移动鼠标 mousemove,这 setTimeout 可能是由于任务到期而导致的,等等,这些任务形成了一个队列,如上图所示。

队列中的任务按“先到先得”的原则处理。引擎浏览器用完成后 script,它将处理 mousemove 事件,然后 setTimeout 处理程序,依此类推。

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

另外两个细节:

  • 引擎执行任务时永远不会进行渲染。任务是否花费很长时间都没关系。仅在任务完成后才绘制对 DOM 的更改。
  • 如果一项任务花费的时间太长,浏览器将无法执行其他任务,例如处理用户事件。因此,过了一会儿,它会发出“页面无响应”之类的警报,建议终止整个页面的任务。当存在大量复杂的计算或导致无限循环的编程错误时,就会发生这种情况。

用例1:分割 CPU 任务

假设我们有一个需要 CPU 的任务。

例如,语法高亮(用于着色此页面上的代码示例)相当占用 CPU 资源。为了突出显示代码,它执行分析,创建许多彩色元素,然后将它们添加到文档中-花费大量时间编写大量文本。

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

通过将大任务分成多个部分,我们可以避免问题。突出显示前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 完成一部分工作,然后根据需要重新计划自身:

  • 首次运行计数:i=1...1000000。
  • 第二次运行计数:i=1000001..2000000。
  • …等等。

现在,如果 onclick 在引擎正在忙于执行第1部分时出现新的辅助任务(例如事件),则将其排队,然后在第1部分完成时在下一部分之前执行。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", {
    bubblestrue
  });

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

宏任务和微任务

随着宏任务,在本章中所描述的,有 microtasks,在章节中提到 Microtasks

微任务仅来自我们的代码。它们通常是由.then/catch/finallyPromise创建的:处理程序的执行成为微任务。微任务也被“秘密使用” await,因为它是承诺处理的另一种形式。

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

每一个后立即宏任务时,引擎执行所有任务 microtask 队列运行任何其他宏任务或渲染或其他任何东西之前,。

例如,看一下:

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执行所有微任务:- 当微任务队列不为空时:- 出队并运行最旧的微任务。
  • 3渲染更改(如果有)。
  • 4如果宏任务队列为空,请等待直到出现宏任务。
  • 5转到步骤1。

要安排新的宏任务:

  • 使用零延迟setTimeout(f)。

这可用于将繁重的计算任务分解为多个部分,以使浏览器能够对用户事件做出反应并显示它们之间的进度。

另外,在事件处理程序中用于安排事件完全处理(冒泡完成)后的操作。

安排新的微任务

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

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

因此,您可能想queueMicrotask 异步执行功能,但要在环境状态下执行。