Advertisement

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

阅读量:

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

文章翻译自:

复制代码
    https://javascript.info/event-loop

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

EventLoop 概念是什么

为什么需要 EventLoop

事件循环

浏览器 JavaScript 和 Node.js 都是建立在事件循环的基础之上,在代码优化方面掌握其机制至关重要。在本章中我们将首先阐述事物如何运行的基本理论框架随后深入探讨这一知识的实际运用

一种无限循环机制存在:JavaScript 引擎在等待新任务,并准备好接收新的任务。当当前的任务完成后,它会进入休眠状态,并准备好接收新的任务。

引擎的一般算法

有任务时:

从最早的任务开始执行它们。

休眠直到出现任务,然后转到有任务时

在浏览页面的过程中遇到了规范性数据。JavaScript 引擎通常情况下未执行任何操作,在特定条件下启动脚本、处理程序或事件激活时才会运行。

任务示例

<script src="...">加载外部脚本时,任务是执行它

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

当计划好的时间到了 setTimeout,任务是运行其回调。

... 等等

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

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

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

例如,在引擎专注于执行 script 时,在用户可能在移动鼠标时触发 mousemove 的情况下,“setTimeout” 可能是由于任务超时所导致的。其余未完成的任务则会形成一个队列(如上图所示)。

队列中的任务按照‘先进先到’的方式依次处理。引擎浏览器在完成后执行 script 后, 随后会处理 mousemove 事件, 并接着使用 setTimeout 方法依次处理后续程序。

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

另外两个细节:

引擎始终不会主动进行渲染流程。无论所处理的任务所需时间有多长或资源消耗有多大都不会影响系统的性能表现。只有当所有子任务均完成之后才会开始并完成对 DOM 结构的更新操作。

如果某个任务耗时太久,则会导致无法完成其他任务。例如,在处理用户事件的情况下,请耐心等待。由于长时间运行可能导致系统崩溃或性能下降的原因,则会触发类似‘页面无响应’的警报提示信息。当遇到大量复杂计算或可能导致无限循环的编程错误时,则会引发此问题。

用例1:分割 CPU 任务

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

例如,在颜色化此页面上的代码示例时会显著消耗 CPU 资源较多。为了突出显示代码内容它将进行分析过程并创建大量彩色元素随后将其添加到文档中并耗时巨 大地编写了大量文本内容。

当引擎专注于语法高亮显示时, 它就无法进行与 DOM 相关的操作, 包括处理用户事件等任务。这会导致浏览器出现闪退现象, 并暂时停止响应用户的事件, 这样的结果会产生严重的后果。

为了规避可能出现的问题, 我们可以通过将大任务划分为若干小部分来实现. 在具体实施过程中, 可以优先突出显示前100行内容, 并对后100行进行 setTimeout 配置 (确保无延迟), 类推地处理后续段落.

为了验证这种方法的有效性,在简化计算而非强调高亮显示的情况下,请问您是否愿意让我们定义一个函数来计算从1累加到一亿?

当您运行下面的代码时,请注意引擎会暂时停滞(即'挂起'一段时间)。对于显式地定义在服务器端JavaScript的情况而言,请尝试执行以下操作:单击页面上的其他按钮——您会发现,在计数结束前不会处理其他事件。

复制代码
 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。

…等等。

现在,在JavaScript引擎忙碌地处理第1部分期间(即当引擎正专注于第1个步骤时),如果发生了一个新的辅助任务(例如某个事件的发生),则应将其放入队列中等待处理。接着,在第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();

在启动count()函数后意识到需要执行更多count()任务时,则会迅速规划好工作的时间表,并在完成这些任务后再进行下一步骤。

如果您运行它,很容易注意到它花费的时间大大减少。

为什么?

这一项操作相当简单:您需注意,在浏览器中嵌套使用的 setTimeout 操作在某些情况下仍需等待至少 4ms 的时间。即便设置为零值,在某些情况下仍需等待至少 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));
    
 };

宏任务和微任务

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

我们的代码仅生成微任务;这些微任务通常由.then, .catch, 和.finally$ Promise来创建;这些处理程序在执行时被视为独立的任务。此外,在某些情况下(尽管不直接可见),我们还间接利用了$await来进行承诺式的执行管理。

新增一个独特功能queueMicrotask(function name),该函数可以在微任务队列中被安排执行。

每当执行一个宏任务之后

例如,看一下:

复制代码
 setTimeout(() => alert("timeout"));

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

这将是什么顺序?

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

Promise列表中显示第二个元素的原因在于:它使用了microtask队列,并且在当前代码执行后立即执行。

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 异步执行功能,但要在环境状态下执行。

全部评论 (0)

还没有任何评论哟~