深入:Microtask 和 JavaScript 运行时环境
在调试或尝试确定解决与任务和微任务的时间安排相关问题的最佳方法时,了解 JavaScript 运行时在幕后的操作方式可能很有用。
JavaScript 本质上是一种单线程语言。它设计于这样一个时代:单线程是一个积极的选择;当时普通大众很少能接触到多处理器计算机,而且 JavaScript 预期的代码处理量也相对较低。
当然,随着时间的推移,我们知道计算机已经发展成为功能强大的多核系统,而 JavaScript 已成为计算世界中使用最广泛的语言之一。大量最流行的应用程序至少部分基于 JavaScript 代码。为了支持这一点,有必要找到方法让项目摆脱单线程语言的限制。
从 Web API 添加超时和间隔(setTimeout() 和 setInterval())开始,Web 浏览器提供的 JavaScript 环境逐渐发展,以包含强大的功能,实现任务调度、多线程应用程序开发等。为了理解 queueMicrotask() 在此处的用武之地,了解 JavaScript 运行时在调度和运行代码时如何操作会有所帮助。
JavaScript 执行上下文
注意: 此处的细节通常对大多数 JavaScript 程序员来说并不重要。此信息作为微任务为何有用以及它们如何运作的基础提供;如果您不关心,可以跳过此部分,如果以后发现需要,再回来查看。
当一段 JavaScript 代码运行时,它在一个执行上下文中运行。有三种类型的代码会创建新的执行上下文
- 全局上下文是为运行代码主体而创建的执行上下文;也就是说,任何存在于 JavaScript 函数之外的代码。
- 每个函数都在其自己的执行上下文中运行。这通常被称为“局部上下文”。
- 使用不明智的
eval()函数也会创建新的执行上下文。
每个上下文本质上是代码中的一个作用域级别。当这些代码段之一开始执行时,会构建一个新的上下文来运行它;当代码退出时,该上下文就会被销毁。考虑下面的 JavaScript 程序
const outputElem = document.getElementById("output");
const userLanguages = {
Mike: "en",
Teresa: "es",
};
function greetUser(user) {
function localGreeting(user) {
let greeting;
const language = userLanguages[user];
switch (language) {
case "es":
greeting = `¡Hola, ${user}!`;
break;
case "en":
default:
greeting = `Hello, ${user}!`;
break;
}
return greeting;
}
outputElem.innerText += `${localGreeting(user)}\n`;
}
greetUser("Mike");
greetUser("Teresa");
greetUser("Veronica");
这个简短的程序包含三个执行上下文,其中一些在程序执行过程中会创建和销毁多次。当每个上下文被创建时,它被放置在执行上下文堆栈上。当它退出时,该上下文将从上下文堆栈中移除。
-
程序启动时,创建全局上下文。
-
当到达
greetUser("Mike")时,为greetUser()函数创建一个上下文;此执行上下文被推送到执行上下文堆栈上。- 当
greetUser()调用localGreeting()时,会创建另一个上下文来运行该函数。当此函数返回时,localGreeting()的上下文将从执行堆栈中移除并销毁。程序执行将从堆栈中找到的下一个上下文greetUser()继续;此函数将从它离开的地方恢复执行。 greetUser()函数返回,其上下文从堆栈中移除并销毁。
- 当
-
当到达
greetUser("Teresa")时,为其创建一个上下文并推送到堆栈上。- 当
greetUser()调用localGreeting()时,会创建另一个上下文来运行该函数。当此函数返回时,localGreeting()的上下文将从执行堆栈中移除并销毁。greetUser()继续从它离开的地方执行。 greetUser()函数返回,其上下文从堆栈中移除并销毁。
- 当
-
当到达
greetUser("Veronica")时,为其创建一个上下文并推送到堆栈上。- 当
greetUser()调用localGreeting()时,会创建另一个上下文来运行该函数。当此函数返回时,localGreeting()的上下文将从执行堆栈中移除并销毁。 greetUser()函数返回,其上下文从堆栈中移除并销毁。
- 当
-
-
主程序退出,其执行上下文从执行堆栈中移除;由于堆栈上没有剩余的上下文,程序执行结束。
以这种方式使用执行上下文,每个程序和函数都能够拥有自己的一组变量和其他对象。每个上下文还跟踪程序中应该运行的下一行以及对该上下文操作至关重要的其他信息。通过以这种方式使用上下文和上下文堆栈,可以管理程序操作的许多基本原理,包括局部变量和全局变量、函数调用和返回等等。
关于递归函数的一个特别说明——也就是说,调用自身的函数,可能跨越多个深度或递归级别:对函数的每次递归调用都会创建一个新的执行上下文。这允许 JavaScript 运行时跟踪递归的级别以及通过该递归返回结果,但也意味着每次函数递归时,都需要更多的内存来创建新的上下文。
运行吧,JavaScript,运行吧
为了运行 JavaScript 代码,运行时引擎维护一组代理来执行 JavaScript 代码。每个代理由一组执行上下文、执行上下文堆栈、一个主线程、一组用于处理 worker 的任何额外线程、一个任务队列和一个微任务队列组成。除了主线程——一些浏览器在多个代理之间共享——代理的每个组件都是该代理独有的。
在这里,我们更详细地了解运行时如何运作。
事件循环
每个代理都由一个事件循环驱动,该事件循环会反复处理。在每次迭代中,它最多运行一个挂起的 JavaScript 任务,然后是任何挂起的微任务,然后执行任何必要的渲染和绘制,然后再次循环。
您的网站或应用程序代码与 Web 浏览器本身的用户界面在相同的线程中运行,共享相同的事件循环。这就是主线程,除了运行您网站的主代码体之外,它还处理接收和分派用户和其他事件、渲染和绘制 Web 内容等等。
因此,事件循环驱动着浏览器中发生的一切,因为它与用户交互有关,但更重要的是,它负责调度和执行在其线程中运行的每段代码。
有三种类型的事件循环
- 窗口事件循环
-
窗口事件循环是驱动所有共享类似源的窗口的事件循环(尽管对此还有进一步的限制,如下所述)。
- Worker 事件循环
-
Worker 事件循环是驱动 worker 的事件循环;这包括所有形式的 worker,包括基本的Web Workers、Shared Workers 和Service Workers。Worker 保持在一个或多个独立于“主”代码的代理中;浏览器可以使用单个事件循环来处理给定类型的所有 worker,或者可以使用多个事件循环来处理它们。
- Worklet 事件循环
-
Worklet 事件循环是用于驱动运行给定代理的 worklet 代码的代理的事件循环。这包括
Worklet和AudioWorklet类型的 worklet。
从同一源加载的多个窗口可能在同一事件循环上运行,每个窗口都将任务排入事件循环,以便它们的任务轮流使用处理器,一个接一个。请记住,在 Web 术语中,“窗口”实际上是指“Web 内容运行的浏览器级容器”,包括实际的窗口、选项卡或框架。
在特定情况下,具有共同源的窗口之间可以共享事件循环,例如
- 如果一个窗口打开了另一个窗口,它们很可能会共享一个事件循环。
- 如果一个窗口实际上是
<iframe>中的容器,它很可能与包含它的窗口共享一个事件循环。 - 在多进程 Web 浏览器实现中,这些窗口碰巧共享相同的进程。
具体细节可能因浏览器而异,取决于它们的实现方式。
任务与微任务
任务是任何通过标准机制(例如最初开始执行脚本、异步分派事件等)安排运行的内容。除了使用事件之外,您还可以使用 setTimeout() 或 setInterval() 将任务排队。
任务队列和微任务队列之间的区别很简单但非常重要
- 当事件循环的新迭代开始时,运行时会从任务队列中执行下一个任务。在此迭代开始后添加到队列中的后续任务和任务将不会运行,直到下一次迭代。
- 每当任务退出且执行上下文堆栈为空时,微任务队列中的所有微任务都会依次执行。不同之处在于微任务的执行会持续到队列为空为止——即使在此期间安排了新的微任务。换句话说,微任务可以排队新的微任务,这些新的微任务将在下一个任务开始运行之前以及当前事件循环迭代结束之前执行。
问题
由于您的代码与浏览器的用户界面在相同的线程中运行,并使用相同的事件循环,如果您的代码阻塞或进入无限循环,浏览器本身就会停滞。即使是缓慢的性能,无论是由于错误还是由于您的代码正在进行复杂的工作,都可能导致用户遭受缓慢的浏览器。
当多个程序和这些程序中的多个代码对象开始尝试同时工作时,再加上浏览器也需要处理器时间——更不用说渲染和绘制网站及其自身 UI、处理用户事件等的时间了——如今一切都太容易堵塞了。
解决方案
使用Web Workers,它允许主脚本在新线程中运行其他脚本,有助于缓解这个问题。设计良好的网站或应用程序使用 Workers 执行任何复杂或耗时的操作,让主线程在更新、布局和渲染网页之外尽可能少地工作。
通过使用异步 JavaScript 技术(例如Promise)来进一步缓解这个问题,它允许主代码在等待请求结果的同时继续运行。然而,在更基础层面运行的代码——例如构成库或框架的代码——可能需要一种方式来安排代码在安全的时间运行,同时仍在主线程上执行,而不依赖于任何单个请求或任务的结果。
微任务是解决这个问题的另一种方案,它提供了更精细的访问程度,使得在事件循环的下一次迭代开始之前就可以安排代码运行,而不是必须等到下一次迭代。
微任务队列已经存在了一段时间,但它历史上仅在内部用于驱动 Promise 等事物。添加 queueMicrotask(),将其暴露给 Web 开发人员,创建了一个统一的微任务队列,无论何时需要在 JavaScript 执行上下文堆栈上没有执行上下文时安全地安排代码运行,都会使用它。跨多个实例以及所有浏览器和 JavaScript 运行时,标准化的队列机制意味着这些微任务将以相同的顺序可靠地运行,从而避免可能难以发现的错误。