深入了解:微任务和 JavaScript 运行时环境

在调试或可能在尝试确定解决任务和微任务的计时和调度问题时的最佳方法时,了解 JavaScript 运行时在幕后如何运作的一些信息可能会有所帮助。

JavaScript 本质上是一种单线程语言。它是在一个这样的时代设计的,在这个时代,这是一种积极的选择;当时公众可用的多处理器计算机很少,并且当时预计 JavaScript 将处理的代码量相对较少。

随着时间的推移,我们知道计算机已发展成为功能强大的多核系统,而 JavaScript 已成为计算领域使用最广泛的语言之一。大量最流行的应用程序至少部分基于 JavaScript 代码。为了支持这一点,有必要找到方法来允许项目摆脱单线程语言的限制。

从将超时和间隔作为 Web API 的一部分添加开始(setTimeout()setInterval()),Web 浏览器提供的 JavaScript 环境已逐渐发展到包括强大的功能,使您能够安排任务、开发多线程应用程序等等。为了理解 queueMicrotask() 在这里的作用,了解 JavaScript 运行时在调度和运行代码时如何运作将会有所帮助。

JavaScript 执行上下文

注意: 这里提到的细节对于大多数 JavaScript 程序员来说通常并不重要。提供这些信息是为了解释微任务为何有用以及它们如何运作;如果您不关心,可以跳过此部分,并在发现需要时再回来。

当 JavaScript 代码片段运行时,它是在一个执行上下文中运行的。有三种类型的代码会创建一个新的执行上下文

  • 全局上下文是为运行代码主体而创建的执行上下文;也就是说,任何存在于 JavaScript 函数之外的代码。
  • 每个函数都在其自己的执行上下文中运行。这通常被称为“本地上下文”。
  • 使用不建议使用的 eval() 函数也会创建一个新的执行上下文。

本质上,每个上下文都是代码中的一个作用域级别。当这些代码段中的一个开始执行时,会构建一个新的上下文来运行它;然后,当代码退出时,该上下文将被销毁。请考虑以下 JavaScript 程序

js
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 代码。每个代理都由一组执行上下文、执行上下文栈、一个主线程、一个用于任何可能创建的用于处理工作者的额外线程的集合、一个任务队列和一个微任务队列组成。除了主线程(一些浏览器在多个代理之间共享)之外,代理的每个组件都是该代理特有的。

这里我们将更详细地了解运行时函数。

事件循环

每个代理都由一个事件循环驱动,该循环会反复处理。在每次迭代期间,它最多运行一个挂起的 JavaScript 任务,然后运行任何挂起的微任务,然后执行任何必要的渲染和绘制,然后再循环。

您的网站或应用程序的代码在与 Web 浏览器本身的用户界面共享同一个线程,共享同一个事件循环。这是主线程,除了运行您网站的主要代码体之外,它还处理接收和分发用户和其他事件、渲染和绘制 Web 内容等等。

然后,事件循环驱动浏览器中发生的与用户交互有关的一切,但对于我们这里要讨论的内容来说更重要的是,它负责调度和执行在其线程中运行的每一部分代码。

事件循环有三种类型

窗口事件循环

窗口事件循环是驱动所有共享类似来源的窗口的循环(尽管存在进一步的限制,如下所述)。

工作者事件循环

工作线程事件循环负责驱动工作线程,包括所有类型的线程,例如基本的 Web 工作线程共享工作线程服务工作线程。工作线程被保存在一个或多个与“主”代码分离的代理中,浏览器可能会为特定类型的线程使用一个事件循环,也可能使用多个事件循环来处理它们。

Worklet 事件循环

一个 Worklet 事件循环是用来驱动运行特定代理中 Worklet 代码的代理的事件循环。这包括类型为 WorkletAudioWorklet 的 Worklet。

从同一个 来源 加载的多个窗口可能运行在同一个事件循环中,每个窗口都会将任务排队到事件循环中,以便它们的任務依次使用处理器,一个接一个。请注意,在 Web 术语中,“窗口”实际上是指“浏览器级别的容器,Web 内容运行在其中”,包括实际的窗口、标签页或框架。

在特定情况下,多个具有相同来源的窗口可以共享同一个事件循环,例如:

  • 如果一个窗口打开了另一个窗口,它们很可能共享同一个事件循环。
  • 如果一个窗口实际上是一个 <iframe> 内部的容器,它很可能与包含它的窗口共享同一个事件循环。
  • 在多进程 Web 浏览器实现中,这些窗口恰好共享同一个进程。

具体细节可能因浏览器而异,取决于它们的实现方式。

任务与微任务

一个 **任务** 是任何通过标准机制安排运行的事件,例如最初开始执行脚本、异步分发事件等等。除了使用事件之外,还可以通过使用 setTimeout()setInterval() 将任务排队。

任务队列和微任务队列之间的区别很简单,但非常重要。

  • 当事件循环开始新的迭代时,运行时会从任务队列中执行下一个任务。在迭代开始后添加到队列中的任务和进一步的任务 *将不会在下一轮迭代之前运行*。
  • 当一个任务退出且执行上下文堆栈为空时,所有微任务队列中的微任务都会依次执行。区别在于微任务的执行会一直持续到队列为空,即使在此期间安排了新的微任务。换句话说,微任务可以排队新的微任务,而这些新的微任务会在下一个任务开始运行之前和当前事件循环迭代结束之前执行。

问题

因为您的代码在与浏览器用户界面相同的线程中运行,使用同一个事件循环,如果您的代码阻塞或进入无限循环,浏览器本身也会卡住。即使是性能下降,无论是由错误引起还是由您的代码执行的复杂工作引起,都可能导致用户体验到浏览器卡顿。

如今,当多个程序和这些程序中的多个代码对象同时开始工作,以及浏览器也需要处理时间(更不用说渲染和绘制站点及其自己的 UI、处理用户事件等等)时,一切都变得非常容易堵塞。

解决方案

使用 Web 工作线程 可以解决这个问题,它允许主脚本在新的线程中运行其他脚本。一个设计良好的网站或应用程序使用工作线程来执行任何复杂或耗时的操作,让主线程尽可能少地执行工作,除了更新、布局和渲染网页之外。

使用 异步 JavaScript 技术(例如 承诺)可以进一步缓解这个问题,它允许主代码在等待请求结果时继续运行。但是,在更基本级别上运行的代码(例如构成库或框架的代码)可能需要一种方法来安排代码在安全的时间运行,同时仍在主线程上执行,独立于任何单个请求或任务的结果。

微任务是解决此问题的另一种方法,它提供了更精细的访问权限,使您可以安排代码在事件循环开始下一轮迭代之前运行,而不是必须等到下一轮迭代。

微任务队列已经存在一段时间了,但它在历史上只用于内部,以便驱动诸如承诺之类的东西。queueMicrotask() 的加入将其暴露给 Web 开发人员,为微任务创建了一个统一的队列,该队列在需要安全地安排代码运行时(JavaScript 执行上下文堆栈上没有剩余执行上下文)时使用。在多个实例以及所有浏览器和 JavaScript 运行时中,标准化的微队列机制意味着这些微任务将按照相同的顺序可靠地运行,从而避免可能难以发现的错误。

另请参见