面试官:什么是 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", {
bubbles: true
});
// 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
异步执行功能,但要在环境状态下执行。