浏览器JavaScript执行流程以及Node.js中的流程均基于事件循环。
了解事件循环的工作方式对于优化(有时对于正确的体系结构)非常重要。
在本章中,我们首先介绍有关事物如何工作的理论细节,然后介绍该知识的实际应用。
事件循环
该事件循环的概念是很简单的。有一个无限循环,JavaScript引擎等待任务,执行任务,然后休眠,等待更多任务。
引擎的一般算法:
- 当有任务:
- 从最早的任务开始执行它们。
- 休眠直到出现任务,然后转到步骤1。
这是浏览页面时看到的形式化信息。JavaScript引擎大部分时间不执行任何操作,仅在脚本/处理程序/事件被激活时才运行。
任务示例:
-
加载外部脚本时,任务是执行它。
- 用户移动鼠标时,任务是调度
mousemove
事件并执行处理程序。 - 当计划好的时间到了时
setTimeout
,任务是运行其回调。 - 等等。
设置任务-引擎处理它们-然后等待更多任务(在睡眠时消耗接近零的CPU)。
可能是在引擎繁忙时任务来了,然后才入队了。
这些任务形成一个队列,即所谓的“宏任务队列”(v8术语):
例如,当引擎正忙于执行时script
,用户可能会移动鼠标mousemove
,这=setTimeout
可能是由于任务到期而导致的,等等,这些任务形成了一个队列,如上图所示。
队列中的任务将按照“先到先得”的原则进行处理。当引擎浏览器用完成后script
,它将处理mousemove
事件,然后setTimeout
处理程序,依此类推。
到目前为止,很简单,对吧?
还有两个细节:
- 引擎执行任务时永远不会进行渲染。任务是否花费很长时间都没关系。仅在任务完成后才绘制对DOM的更改。
- 如果一项任务花费的时间太长,浏览器将无法执行其他任务,例如处理用户事件。因此,过了一会儿,它会发出类似“页面无响应”的警报,提示您杀死整个页面的任务。当存在大量复杂的计算或导致无限循环的编程错误时,就会发生这种情况。
那是理论。现在,让我们看看如何应用这些知识。
用例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
完成一部分工作,然后根据需要重新安排自身的时间,例如:
- 首次运行计数:
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
调用在浏览器中的最小延迟为4毫秒。即使我们设置了0
,它4ms
(或者更多)。因此,我们计划得越早–运行速度越快。
最后,我们将需要大量CPU的任务分成了几个部分–现在它不会阻塞用户界面。而且它的整体执行时间不会更长。
用例2:进度指示
为浏览器脚本分配繁重任务的另一个好处是,我们可以显示进度指示。
如前所述,仅在当前运行的任务完成后才绘制对DOM的更改,而不管它花费多长时间。
一方面,这很棒,因为我们的函数可能会创建许多元素,将它们一个接一个地添加到文档中并更改其样式-访问者不会看到任何“中间”未完成的状态。重要的事情,对不对?
这是演示,在函数完成之前不会显示对i
的更改,因此我们将仅看到最后一个值:
…但是我们也可能希望在任务执行过程中显示一些东西,例如进度条。
如果我们使用来将繁重的任务分成几部分setTimeout
,则更改将在它们之间绘制出来。
这看起来更漂亮:
现在, 在事件处理程序中,我们可能会决定推迟一些操作,直到事件冒泡并在所有级别上得到处理。我们可以通过将代码包装在零延迟 在分派自定义事件一章中,我们看到了一个示例:自定义事件 微任务仅来自我们的代码。它们通常是由Promise创建的:处理程序 还有一个特殊功能 在每个宏任务执行之后,引擎会立即运行微任务队列中的所有任务,然后再运行其他宏任务或渲染或其他任何操作。** 例如,看一下: 这将是什么顺序? 更丰富的事件循环图片如下所示(顺序是从上到下,即:首先是脚本,然后是微任务,渲染等): 在执行任何其他事件处理或呈现或执行任何其他宏任务之前,所有微任务都已完成。 这很重要,因为它可以确保微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。 如果我们想异步执行一个函数(在当前代码之后),但是在呈现更改或处理新事件之前,可以使用进行调度 这是一个带有“计数进度条”的示例,与之前显示的示例类似,但 更详细的事件循环算法(尽管与规范相比仍简化了): 要安排新的宏任务: 这可以用于将繁重的计算任务分解为多个部分,以使浏览器能够对用户事件做出反应并显示它们之间的进度。 另外,在事件处理程序中用于安排事件完全处理(冒泡完成)后的操作。 安排新的微任务 微任务之间没有UI或网络事件处理:它们立即一个接一个地运行。 因此,您可能想queueMicrotask异步执行功能,但要在环境状态下执行。 Web Worker Event loop: microtasks and macrotasksi
,这是一种进度条。
用例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));
};
宏任务和微任务
.then/catch/finally
的执行成为微任务。微任务也被“秘密使用” await
,因为它是Promise处理的另一种形式。queueMicrotask(func)
,func
可在微任务队列中排队等待执行。setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
code
首先显示,因为它是常规的同步调用。promise
显示第二个,因为它.then
通过微任务队列,并在当前代码之后运行。timeout
最后显示,因为它是一个宏任务。
queueMicrotask
。queueMicrotask
用于代替setTimeout
。您可以看到它在最后渲染。就像同步代码一样:
概括
setTimeout(f)
。
queueMicrotask(f)
。
对于不应该阻塞事件循环的长时间繁琐的计算,我们可以使用Web Workers。
这是在另一个并行线程中运行代码的方式。
Web Workers可以与主进程交换消息,但是它们具有自己的变量和事件循环。
Web Worker没有访问DOM的权限,因此它们对于同时使用多个CPU内核的计算非常有用。参考