深入浅出事件循环


我印象里的 JavaScript,总是一副破破烂烂的样子。有时候乱七八糟的项目,却刚刚好能够跑起来,不会出什么问题。 它又没有很多功能,必须要借助外部的 API 才能满足正常的需求。 打个比方来说,好比一个跛脚的年轻人,看着也好,就是跑起来不太方便,需要多借助一些外物。 为了帮助他能够跑得更好,浏览器给它装了外骨骼,可以接入各种拓展,让它能快速奔跑,甚至有时超乎常人。 这里说到的外骨骼,就是前端开发者必须理解的一个概念——事件循环

前置知识

对以下知识有一定了解会更方便理解本文中提及的内容:

  • JavaScript
  • 浏览器的进程和线程架构
  • 队列

解构事件循环

进程和线程

在正式开始我们的主题之前,我们需要回顾一些涉及的知识,避免混淆。

进程是一个独立的运行单位,其是操作系统进行资源分配和调度的基本单位。

线程是为了更好支撑并发执行,提高资源利用率的基本 CPU 执行单元,也是程序执行流的最小单元。

线程是进程中的一个实体,是被系统独立调度和分派的基本单位,其自身不拥有系统资源,只拥有必须的允许资源,但可和其他同一进程中的线程共享该进程拥有的所有资源。

引入线程后,进程不再作为执行单元,而是线程取代了此职能,其特性使得不同线程在属于相同进程时切换上下文的成本较低。有一点重要的是,这样以线程为独立调度的基本单元的现代操作系统中,我们说一个进程运行,其实是进程中的某一线程在运行

进程与线程
进程与线程

单线程的 JavaScript

JavaScript 是单线程的。或者说,在单线程上运行。这个方言的原规范,ECMAScript 规范中,本身就没有线程这一概念。

但用过 JavaScript 的开发者,都知道 JavaScript 能够执行异步任务。这导致很多初学者直觉上认为,JavaScript 自身就具备异步能力。

事实恰恰与直觉相反,JavaScript 能够执行异步任务,并非其自身的因素,而在于其运行时提供的数个模块和机制。对于网页来说,那便是浏览器,对于 Node.js 服务来说,那就是 libuv。这些外部的因素提供队列和 API,同时借助一个基于事件驱动的机制——事件循环,实现了 JavaScript 的异步多线程工作。甚至,在 Node.js 中,我们可以使用 JavaScript 去操作多进程。

打个比方,JavaScript 可能是的方向盘和操作杆,到底在什么交通工具上,是面包车、超跑还是飞机,决定了最终有什么能力。

想象一下,有这样一个场景。自助餐厅里,摆放着许多盛满饭菜的硕大盘子,你是餐厅的一位顾客,每次吃完饭都要去取一点食物回来。为了尽可能划算地吃上一顿,如果有牛排,你可能优先拿一些牛排。后厨的师傅一直在烹饪,做完菜时会间歇地补充盘子上的食物,但也可能原材料耗尽,导致盘子最后一直是空的。餐厅也提供生食材,你可以自取做火锅和烤肉,但像是活螃蟹之类的,你只能带给师傅帮你现做,之后放到盘子里通知你来取。

这个场景里,你就是“JavaScript 引擎”,后厨的师傅就是外部环境——如浏览器,我们称这些盘子为“任务队列”或者“微任务队列”,你根据最划算方案从这些“队列”取食物回来消费的步骤称为“事件循环”,你有一定的烹饪能力,但不够强大,但你可以让师傅帮你烹饪,在浏览器环境下这就是调用 Web API,这一步骤的产出最后还会回到专门的“队列”中等你取走消费——这就是 JavaScript 的异步。

吃自助餐
吃自助餐

这个场景可能不完全正确,但应该有助于对事件循环的整体认识。整个事件循环实际是一种巧妙的生产者-消费者模式,在操作系统进程共享和常见的消息队列系统(RabbitMQ、Kafka 等)都有使用。

事件循环概述

事件循环是存在于大多数 JavaScript 运行环境中的一套机制,在浏览器中,它用以协调事件、用户交互、脚本、渲染、网络等等。它不是JavaScript 自身的一部分,也不是 JavaScript 相关的专用名词。用户代理必须遵循Web 规范 中的事件循环描述。

在现代浏览器中,就存在 window 事件循环worker 事件循环worklet 事件循环数种事件循环。我们常去描述的事件循环,指的是其中的window 事件循环。本文主要讨论的也是此事件循环。此外,在 Node.js 中,也自有一套事件循环机制

事件循环之所以称之为“循环”,是因为其内部实现类似于这样的机制(忽略细节):

while(true) {
  // 用算法选择一个可运行的任务,然后运行它
  const chosenTaskQueue = chooseATaskQueue();
  const task = chooseRunnableTask(chosenTaskQueue);
  run(task);
  // 做其他事
  // 然后处理所有微任务
  let microTask = null;
  while (microTask =  dequeueMicroTask()) {
    run(microTask);
  }
  // 做其他事
}

JavaScript 的宿主环境往往是多线程的,而一个事件循环仅会在一个线程上运行,因此不会造成应用的阻塞。规范并未限定每个事件循环必须有单独的实现线程,按具体情况,也可能出现一个线程多个事件循环。

从整体上看,我们可以将事件循环所在的系统按职责描述为以下几个部分:

  • JavaScript 引擎,包含调用栈等,负责运行任务
  • 事件循环,选取任务指派给 JavaScript 引擎
  • 任务队列和微任务队列,其他模块用其存放任务以便与事件循环共享
  • Web API,提供给 JavaScript 引擎异步回调、功能扩展和间接入队任务的能力
  • 其他部分,如计时器模块、UI 模块、网络模块等,可以在满足特定条件后让任务入队“队列”
事件循环分界图
事件循环分界图

任务和微任务的概念

事件循环中的各种机制和任务这个概念相关。在浏览器的事件循环中,任务(Task)特指一类特殊的数据结构,其声明了步骤、任务源、关联文档、脚本求值(Script Evaluation)的环境设定对象集合等。

一个相关联的概念是微任务,这是为区分通过微任务入队算法生成的任务和其他任务入队算法生成的任务而提出的称呼。微任务是任务的一种,微任务的全体是任务全体的真子集。

我们常常把微任务以外的任务称为宏任务,以明显区分二者,由此自然而然地,这些任务所在的任务队列就成为了宏任务队列。但由于规范中未指定该种任务的称呼,对应的英文可能MacroTask,也仅在网络论坛中出现,同时,鉴于中英文语境下“宏”不体现任何此类任务的特性,且容易造成理解上的问题(如,理解为使用宏的任务),因此,本文仍然使用任务作为指代。

为创建一个非微任务的任务,你可以使用 API:

  • XMLHttpRequest
  • setTimeout
  • setInterval

为创建一个微任务,你可以使用 API:

  • queueMicrotask
  • fetch
  • 期约 Promise 中的 then,如:Promise.resolve().then
  • async/await

事件循环中的队列

浏览器的事件循环中有多种“队列”参与,但它们在总体上可以划分为两种,一种是任务队列,另一种是微任务队列

任务队列是存放任务的集合(Set)微任务队列是存放微任务的队列(Queue)。 之所以称呼前者为集合,是因为其行为上表现不同于队列的定义,事件循环的处理模型从中选择可运行的任务,而令队头元素出队。 因此,微任务队列不是任务队列,也不是任务队列的子集。有时候,术语的定义有很大程度的历史原因,也会发生这种集合不是集合,叫做队列的情况。

一个事件循环可以有一或多个任务队列,但微任务队列只能有一个。每个任务通过源 source 字段指定其来自一个特定的任务源(Task Source),借助此字段,其与特定的任务队列相关联,相对地,任务队列通过此字段对其相关联的任务进行分组和序列化。

无论浏览器实现如何,规范中定义了一些通用任务源(Generic Task Source)

  • DOM 操作任务源(the DOM Manipulation Task Source):此任务源用于响应 DOM 操作的特性,比如,元素插入到文档时以非阻塞方式发生某事。
  • 用户交互任务源(the User Interaction Task Source):此任务用于响应用户输入的特性,比如,键盘和鼠标输入。响应用户输入(像是 click 事件)而发送的事件(Event)必须使用用户交互任务源为参数的任务入队算法。
  • **网络任务源(the Networking Task Source)`:此任务源用于响应网络活动而触发的特性。
  • 导航和遍历任务源(the Navigation and Traversal Task Source):此任务源用于入队导航和历史遍历中涉及的任务。
  • 渲染任务源(the Rendering Task Source):此任务源单独用于更新渲染。

因此,一般地讲,现代浏览器中将包含这五种通用任务源对应的任务队列,同时还有一个微任务队列。 浏览器如何处理这些“队列”,和任务,将会在接下来的章节叙述。

JavaScript 执行上下文和执行上下文栈

我们在之前的章节也谈到了,任务声明了其要执行的步骤。这些步骤是算法的步骤。我们说运行任务,实际是一定规定执行任务的步骤。在步骤中,可能存在关联的回调,这些回调就需要传递一些参数,交给 JavaScript 引擎执行

伴随而来的两个概念是执行上下文执行上下文栈。我们可以在 ECMA262 中找到它们的定义——

执行上下文是一种规范设备,其被 ECMAScript 实现用以追踪代码的运行时求值。在任何时点上,每个代理最多有一个实际运行代码的执行上下文。在 ECMA262 规范中,这被称为代理的运行执行上下文

执行上下文栈是用于追踪执行上下文的堆栈。运行执行上下文总是栈顶元素。只要控制从当前运行执行上下文转移到与该执行上下文无关的可执行代码,就会创建一个新的执行上下文。新创建的执行上下文会推入栈中并成为运行执行上下文。

一个执行上下文无论实现如何都必须包含特定状态以追踪它所关联代码的执行状态。每个执行上下文至少有以下状态组件:

  • 代码运行状态。任何当前需要用以执行、挂起或是恢复此执行上下文关联代码的状态。
  • 函数。如果执行上下文对函数对象进行求值,这就是该函数对象。如果执行上下文是脚本或模块的求值,那么值为 null。
  • 域(Realm),相关代码访问 ECMAScript 资源的域记录。
  • 脚本或模块。关联代码出处的模块记录或是脚本记录。如果没有出处脚本或模块,值为 null

我们可以大概地描述,当一个 JavaScript 引擎运行一段代码时,是在执行一个脚本或回调。在执行回调或脚本时,将往其执行上下文栈中压入一个执行上下文,此时,旧的运行执行上下文挂起,新的执行上下文成为运行执行上下文,在执行结束后恢复上一个执行上下文。

我们常称执行上下文栈为执行栈,或代码执行栈,但在深层次理解 JavaScript 的运行时必须清楚其存放的是何种数据类型。

事件循环处理模型

要想摸清事件循环到底如何工作的,还是需要对照 WHATWG 规范中的处理模型一章,理解整个事件循环核心的工作机制。我在这篇文章对其进行了部分翻译(附带部分对照表以查阅),方便了解其中细节,内容较多,感兴趣的话可以前往阅读。如果你遇到比较困惑的术语,也可以前往查看对照表。大概未来的某天,我计划里添加的术语查询功能完成后就不需要反复对照了。规范曾在 2022 年有一翻译版本,但鉴于未翻译内容过多,且可读性差,故以伪代码风格自己加工了一遍。

事件循环处理模型提取重点部分,可总结如下:

事件循环模型存在时,执行如下步骤

  1. 如果有包含可运行任务的任务队列,则按照自己实现的算法选择其中一个队列。此时不可能选择微任务队列,但可能有与微任务源关联的任务队列:
    1. 提取该队列的第一个可运行任务。此时任务可能是微任务。
    2. 运行任务,运行完毕时,此时现象为执行上下文栈
    3. 执行微任务检查点
      1. 如果正在执行微任务检查点标志为 true,则返回。
      2. 设置正在执行微任务检查点标志为 true。
      3. 满足微任务队列不为空,则:
        1. 从微任务队列出队一个微任务,执行微任务
      4. 设置正在执行微任务检查点标志为 false。此时微任务队列清空,执行上下文栈再次为
    4. 对于 window 事件循环,如果不再有包含可运行任务队列,计算允许执行的死线,然后执行开始空闲周期算法,将 requestIdleCallback 传入回调关联的任务排队到相关任务队列。
    5. 对于 worker 事件循环,运行其动画帧回调

并行地,window 事件循环必须执行以下步骤:

  1. 等待一个事件循环关联的表示文档的“可通行”(特殊数据结构,Navigable)具有渲染机会,入队执行以下步骤的全局任务:
    1. 排序、筛选活动文档并显示。
    2. 刷新自动聚焦候选。
    3. 对于每个筛选后的文档,执行以下步骤:
      1. 运行揭示文档步骤。此步骤包含了跨文档视图切换,并于文档的关联全局对象上派发“pagereveal”事件。
      2. 运行调整尺寸步骤。此步骤于 Window 对象和 VisualViewport 上派发“resize”事件
      3. 运行滚动步骤。此步骤于 Document 上冒泡地,或是非 Document 上不冒泡地派发“scroll”事件
      4. 运行求值媒体查询步骤并报告改变步骤。此步骤于 MediaQueryList 上派发“change”事件
      5. 更新动画并发送相应事件。内部又包含一个微任务检查点。此过程改变动画帧而不是建立新的动画帧。
      6. 运行全屏步骤。此步骤派发“fullscreen”系列事件。
      7. 如果 Canvas 上下文丢失,运行上下文丢失步骤。
      8. 运行动画帧回调,即 requestAnimationFrame 排队的回调:
        1. 拷贝保存动画帧回调的映射的键。
        2. 对于每个键,执行:
          1. 调用映射中该键保存的回调
          2. 删除映射中这个键对应的值
      9. 开始布局相关步骤:
        1. 重新计算样式并更新布局。
        2. 处理 Resize Observer 回调。
      10. 处理页面聚焦逻辑,通常派发“blur”和“focus”事件,可能也于此之前派发“change”(对 input 元素)。
      11. 执行待定的过渡操作(视图过渡)。此操作派发过渡相关事件。
      12. 处理 Intersection Observer 回调。
      13. 更新渲染或用户界面。
      14. 处理顶级层移除。顶级层是浏览器特定的图层,用以显示应处于其他所有图层之上的元素,如全屏视频、dialog 等。

即便进行了一些简化,但显较长,但这样更方便我们从整体上理解事件循环整个系统的模型,纠正一些过往或者以后将发生的错误认知。

借助事件循环实现异步

利用 Web API,你可以将一个回调暂时移出当前的同步执行块,交予外部框架,以在事件循环未来的某个时点回到 JavaScript 引擎执行。常见的 API 有:

  • setTimeout 和 setInterval 规划一个未来超时后执行的任务关联的回调。
  • queueMicrotask 和 Promise.prototype.then 规划一个当前任务执行完立刻执行的微任务关联的回调。
  • requestAnimationFrame 规划一个未来渲染任务中更新渲染前执行的回调。
  • requestIdleCallback 规划一个未来所有队列无可运行任务时执行的回调。
  • XMLHttpRequest 和 fetch 分别规划网络请求状态更新后的任务和微任务回调。
  • MutationObserver 规划一个节点修改后排队微任务的回调。
  • ResizeObserver 和 IntersectionObserver 规划调整尺寸和元素相交时排队的任务回调。
  • 事件处理程序,如 addEventListener,可以规划未来特定条件被触发的回调
  • 其他类型,WindowProxy、MessageChannel 和 Worker 通信等

一旦 JavaScript 引擎的相对外部系统发现满足条件,回调就作为事件循环的一部分进入,最终塞入引擎中执行,此时 JavaScript 引擎不会执行其他任何代码。

宏观上看,JavaScript 系统确实是异步的,JavaScript 现在具备了异步的能力。微观上看,JavaScript 引擎永远是同步执行的。

任务和微任务的顺序

我们讨论整个事件循环系统中的回调时,大部分回调是通过附着于任务上的形式进行处理的。针对于这部分回调,我们在讨论任务顺序的同时,实际也是在讨论回调的顺序。

参考“事件循环处理模型”一节,假设浏览器充分实现了规范,我们可以得出以下事实:

浏览器代码执行时,执行上下文栈不为空。每次执行新的函数,将推入一个新的执行上下文。直到所有当前运行代码执行完毕,执行上下文栈表现为空,开始事件循环的下一步骤。此时对应执行“微任务检查点”。因此,我们也说清空执行栈时执行微任务。

从这个角度看,总会有一个任务先于微任务执行,但我们直觉地从当前代码审视时,总不自觉地忽略掉当前运行任务,因此会说,微任务总是快于“宏任务”。这并不会对实际使用造成多少影响,但需要理解,精确来讲应该是,运行任务时,微任务总是先于下一个其他类型的任务执行。

执行微任务检查点时,必须清空所有的微任务。这意味着,当微任务内部再次为微任务队列添加微任务时,新的微任务最终会在此次检查点中执行。这样,假设无限地在微任务中排队微任务,必定造成应用的阻塞。

与之不同,其他任务在一趟循环中,仅会选择其中的一个,之后的步骤仍然会正常执行。因此可能会因长任务造成卡顿,但不会造成应用的阻塞。一个经典的例子就是分别使用 setTimeoutqueueMicrotask 嵌套调用自身。

夜不能寐的顺序问题

我们先来一些简单的热身。以下代码输出什么?

console.log(1);
setTimeout(function () {
  console.log(6)
}, 10);
setTimeout(function () {
  console.log(4);
  setTimeout(function() {
    console.log(5)
  },0);
}, 0);
console.log(2);

console.log(3);

答案是 1 2 3 4 5 6。

每个 setTimeout 都会创建一个关联的任务,在条件满足(超时)时入队。同步执行完当前的执行上下文中的代码后,再轮到异步的部分。即便是在函数内部嵌套地调用,一轮循环最多只会选出一个可运行的计时器任务以执行。

你可以尝试一下 Philip Roberts 搭建的 Loupe,这个工具可以将关于事件循环的任务可视化,但对于微任务的支持不是很好。

仅讨论非微任务的任务自然很简单,那么,混点微任务又如何呢?

const p = Promise.resolve();

setTimeout(function() {
  console.log(1);
  p
    .then(function() {
      console.log(2);
    });
},0);
setTimeout(function() {
  console.log(3);
},0);
console.log(0);

答案是:0 1 2 3。

在运行 console.log(1) 之后,微任务被排队,一定会先于下一个任务执行,尽管现在计时器相关的任务队列中还有一个未运行的任务。

const p = Promise.resolve();
p
.then(function() {
    console.log(0);
})
.then(function() {
    console.log(1);
});

p
.then(function() {
    console.log(2);
    return new Promise(function(resolve) {
        console.log(3);
        resolve();
    }).then(function() {
        console.log(4);
    });
}).then(function() {
    console.log(5);
});

p
.then(function() {
    console.log(6);
});

答案是:0 2 3 6 1 4 5。

Promise 构造函数内是同步执行的。每个 then 需要等到前面一个期约解决后才能将回调包装为微任务推入微任务队列。执行期约绑定的回调时,会使用当前期约解决时传入的值作为参数。

可以看出,由于创建微任务常用的期约可以进行链式调用,因此稍微有些麻烦。但记住一些要点,关于任何微任务的次序问题便迎刃而解:

基础要点,有关期约 Promise 构造函数和 then:

  • 期约 Promise 构造函数内永远是同步的。
  • 每个期约内部用两个插槽,为存放成功或失败时相关反应(Reaction)的列表,反应理解上等同于通过 then 等方法绑定的回调
  • 期约的 then 方法将回调绑定到期约自身,返回一个新的期约,此时该回调可称之为 then 作业(Job)回调
    • 如果期约已解决,直接规划一个微任务,以解决的值调用回调
    • 如果期约未解决,绑定到期约的回调之上,未来解决时以微任务调用
    • then 方法内部使用返回的新期约上的 resolve 和传入 then 的回调执行的返回值来解决新期约
  • 如果期约尝试解决(resolve)时,用于解决的回调返回值为非 thenable 的值,以该值解决该期约
  • 如果期约(称其为期约 A)尝试解决时,用于解决的回调返回值为 thenable 对象,将会把解决当前期约的函数绑定到其“then”对于的回调上,此外,可能需要两个额外微任务以求值:
    • 一个微任务用于调用 thenable 对象的 then 方法,传入接收此 thenable 值,然后解决期约 A 的回调。此行为称为 NewPromiseResolveThenableJob,我们称此微任务为 PromiseResolveThenableJob。
    • 如果 thenable 为期约,调用 then 必然再次规划一个具有上一步绑定回调的微任务,以使用接收到的值执行回调——解决期约 A。
  • 期约被解决后为每个回调规划一个微任务,以最终的返回值调用它自身 then 的回调,此行为称为 TriggerPromiseReactions。我们称此中每个微任务为 PromiseReactionJob。
  • 类似地,async/await 作为 Promise 的语法糖,async 函数返回的值最终会被以 Promise 包装,自然满足 thenable 条件,会导致 NewPromiseResolveThenableJob 发生。

特别指出,上述步骤中,描述了解决(resolve)传入的如果是一个非 thenable 值,那么将有一个 PromiseReactionJob 微任务。如果传入的是 thenable,则会有三个(额外两个)微任务,PromiseResolveThenableJob、PromiseReactionJob 和固有的 PromiseReactionJob。

总结来讲,then 内部使用 resolve。如果 resolve 一个期约(或 thenable)值,会额外产生两个微任务,其中一个运行后才触发另一个。如果 resolve 非期约(非 thenable),不会产生额外微任务。then 本身为了解决其返回的期约,一定会在调用 then 的期约解决时产生一个微任务。

注意,Promise.resolve 立刻同步地生成一个已兑现的期约。

Promise.resolve(Promise.resolve(3)).then(v => console.log(v)); // 3 个微任务
Promise.resolve(Promise.resolve(2)); // 2 个微任务
Promise.resolve(() => Promise.resolve(2)); // 2 个微任务
Promise.resolve(1).then(v => console.log(v)); // 1 个微任务
Promise.resolve(0); // 0 个微任务

// 3 1

补充要点,关于 async/await的:

  • async 创建一个隐式 Promise 并返回。
  • 如果中间没有 await,内部将同步执行。
    • 此时内部不会生成任何新的微任务
  • 如果中间存在 await,则执行到此处时产生中断,期待一个期约,以绑定“恢复此中断”作为其解决时的回调:
    • await 右侧值为非 thenable 值:
      • 创建一个新期约,使用非 thenable 的值解决它,不会产生微任务
      • 将解决绑定回调为“恢复此中断”的期约的回调绑定到该对象,这将立刻产生一个执行该回调的微任务
    • await 右侧值为 thenable 对象,旧标准:
      • 旧标准(5、6年前)的实现中等同于 then 的 NewPromiseResolveThenableJob,无论如何都会有三次微任务
      • 创建一个新期约,用 thenable 对象解决这个期约,产生额外两个微任务:
        • 一个微任务,调用该 thenable 对象 then 方法,传入“解决新期约”的绑定回调
        • 一个微任务,执行上一步绑定的回调,当该 thenable 已解决,或是未来被解决时产生
      • 一微任务,负责“恢复此中断”,对应的回调绑定到上述新期约上,将在已解决或未来解决后产生
    • await 右侧值为已经解决的 thenable 对象,较新标准:
      • 如果已经是一个期约,使用此期约
      • 将解决绑定回调为“恢复此中断”的期约的回调绑定到该对象,这将立刻产生一个执行该回调的微任务
    • await 右侧值为未解决的 thenable 对象,较新标准:
      • 如果已经是一个期约,使用此期约。
      • 将解决绑定回调为“恢复此中断”的期约的回调绑定到该对象,这将在未来该对象被解决时,产生一个执行该回调的微任务。
    • 注:未解决在解决时根据外部因素,比如又使用了 thenable,可能会有更多的微任务。已解决的期约由于幂等性,一定不会是 thenable 作为值,不会有额外的微任务。
    • 恢复此中断,以右侧最终的解决值作为 await 操作符的最终结果。
  • 使用返回值,解决创建的隐式期约,如果返回值为 thenable,则按解决 thenable 处理,参考“期约基础要点”描述。
  • 如果外部绑定回调至其上,比如外部 await 或者将 async 函数传递给 then,按上述规则传递 thenable 时情况处理。

总结性地讲:如果没有 await,async 函数执行时是同步的。如果有 await,async 直到 await 前是同步的。await 右侧为非期约(thenable)值,或已解决的期约,会产生一个微任务。await 右侧为未解决期约,未来产生一个微任务。注意最终返回值是否为期约,return arg; 可看作为对于隐式期约 resolve(arg);,根据稍早描述的“基础要点”判断。

async function fooSync() {
  return 1;
}   // 同步

async function fooAsync() {
  return await 3;
}  // 异步,1个

async function fooAsync1() {
  return await Promise.resolve(2);
}  // 异步,1个

async function fooAsync2() {
  return Promise.resolve(4);
}   // 异步,3个

fooSync().then(console.log);
fooAsync().then(console.log);
fooAsync1().then(console.log);
fooAsync2().then(console.log);

// 1 3 2 4

现在,你应该具备所有任务和微任务类问题求解的能力了。

我们将 await 也混入问题中。尝试解答:

async function async1() {
	console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
    console.log('promise1');
    resolve();
})
.then(function () {
    console.log('promise2');
});

console.log('script end');
答案:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

这里,有一个被称之为“令人失眠的 Promise”问题,至此已经摸得清清楚楚,怎么求解顺序,每一步都发生什么我们现在都了如指掌了。现在,试着解决一下。

// 按照讲解,这里等效于 async () => { return 4; }
Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4); 
}).then((res) => {
 	console.log(res);   
});

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
}).then(() => {
    console.log(6);
});
答案:
0 1 2 3 4 5 6

一道略微修改后的变种题:

Promise.resolve().then(async () => {
    console.log(0);
    // 按照讲解,这里等效于 return await Promise.resolve(4);
    return await 4;
}).then((res) => {
 	console.log(res);   
});

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
}).then(() => {
    console.log(6);
});
答案:
0 1 2 3 4 5 6
---------------
Promise.resolve().then(async () => {
    console.log(0);
    // 按照讲解,这里等效于 return await Promise.resolve(4);
    return await 4;
})
此步骤第一个 then(称其所在期约为 A)触发的微任务,在执行后一口气排队了两个微任务1和2,1用以返回 await 的值,2负责触发隐式 Promise 的 then,连接解决期约 A 的回调。
微任务1执行完,return 获得了值,导致隐式 Promise 被解决。
微任务2紧接微任务1,此时隐式 Promise 已兑现,连接解决期约 A 的回调则导致直接发起执行该回调的微任务3。2和3就是我们所说的多出来的两个额外回调。
等到3执行,将继续触发此步骤之后串起来的回调。
主要由于1和2连在一起,而微任务检查点必须要清空微任务队列,因此最后效果等同于“令人失眠的 Promise”问题原题的结果。

两个特殊函数

此外,我们常用的还有两个排队回调的函数,requestAnimationFramerequestIdleCallback

requestAnimationFrame 回调的处理(运行动画帧回调)发生在特定的全局任务之中,这个任务囊括了更新动画、更新渲染、滚动事件分发等。requestIdleCallback 则是不借助任务处理回调,在当前任务处理完,微任务清空,如果没有存在可运行的任务的任务队列,就会允许执行空闲回调,而且其回调可以选择规划到下一次满足对应条件时执行。

乍一看,可能会认为 requestAnimationFrame (的回调)总是会在 requestIdleCallback (的回调)之前。但这只是不在事件循环中插入新任务的结果。二者实际的发生顺序情况是非常复杂的,因为后者受其他所有任务队列是否存在可运行任务的影响,由于浏览器多线程的架构,任务排队可以发生在任何时刻。假设我们调用二者(简称 rAF 和 rIC)时,浏览器不再排队除了渲染关联的全局任务(我们暂称其渲染任务)外的所有一切任务,那么:

  • 如果没有渲染任务排队,仅 rIC 回调发生,rAF 永远不会发生。常见于页面进入后台状态。
  • 如果渲染任务在 rIC 回调的进入条件判断后排队,那么 rIC 回调永远在 rAF 回调前发生。
  • 如果渲染任务在 rIC 回调的进入条件判断前排队,那么 rAF 回调永远在 rIC 回调前发生。

注意,调用 requestAnimationFrame 并不意味着立刻会有渲染任务排队。渲染任务排队仅发生在有渲染机会时,比如说,设备显示器刷新率为 60Hz,同时页面在前台可见,那么不计入性能考虑的帧率优化等,一般会以1/601/60秒,即约 16.67 毫秒的固定间隔发生渲染机会,此时排队渲染任务。实际运行时,事件循环各环节执行时间不是固定的,尽管渲染机会尽可能周期地发生,但渲染任务在我们所谈的判断条件前还是后入队仍然是随机的。

function test(times = 1000, happens = [], happenGap = 10) {
  while(times--) {
    setTimeout(() => {
       requestIdleCallback(() => {
        happens.push('rIC');
      });
      requestAnimationFrame(() => {
        happens.push('rAF');
      });
    }, 50);
  }
}
const output = [];
test(100, output, 10);
console.log(output);

可以修改参数多尝试几次,二者发生的顺序是随着时间不断改变的。鉴于并行因素的存在,我们想确保二者回调之间存在执行顺序是没有意义的,使用 Web API 理应更关注其实际的功能描述。

与上一节的 setTimeoutqueueMicrotask 类似,我们也来讨论一下回调中嵌套调用 requestAnimationFramerequestIdleCallback 的情况。

运行动画帧回调中指出,运行动画帧回调需要把目标动画帧回调的映射的键全部取出,然后根据取出的键,运行键对应的回调并进行移除。这意味着,即使在回调中使用 requestAnimationFrame 增加新的回调,也只会在下一个渲染机会到来时运行。也就是说,嵌套调用此函数不会导致阻塞。

开始空闲期算法中指出,待定的空闲回调将被移动至可运行的空闲回调列表,然后在调用空闲回调算法中描述从该可运行空闲回调列表中取出回调并执行。这意味着,即使将所有空闲回调运行完,其中使用 requestIdleCallback 增加新的回调也是不会在此次步骤中执行的,可以理解为一种快照的机制。

事件监听器的回调

整理了事件循环处理模型相关的规范,一些关于事件监听的处理变得明朗起来。

我们关注渲染相关全局任务的部分,发现运行动画帧回调将其步骤一分为二。在运行动画帧回调前,运行了调整尺寸、求值媒体查询并报告变化、滚动、更新动画和全屏等步骤。在运行动画帧之后,运行了更新布局和样式计算、处理 Resize Observer 相关部分、处理聚焦逻辑、处理页面过渡、更新 Intersection Observer 相关内容、更新渲染和 UI、处理顶级层移除元素等步骤。相应地,步骤中进行对应事件的分发,如 resize、scroll、blur 等。运行事件分发同时会同步地将关联的回调取出并执行。这意味着,通常情况下,为这些事件添加的事件监听器将会和动画帧回调几乎同样的频率进行调用,并且会按照规范中提及的顺序调用。

当然,我们这里要提及一个反例。早些年的 Edge 实现,把运行动画帧回调放到了更新渲染之,这也就导致了在使用 requestAnimationFrame 时总会慢半拍的情况。后面自然是修复了这个问题,不那么特立独行了。

不过,我们提及了几个 Observer 和更新动画,那么 Mutation Observer 和 transition 事件的处理在哪呢?对于前者,它并不是事件循环处理模型的一部分,通过其他模块将微任务排队到微任务队列。后者,则是包含在更新动画步骤中了。

而用户交互、网络事件等类型的监听器的回调——将会在满足条件后对任务/微任务进行排队。比方说,我们点击一个按钮,将会有一个任务入队到用户交互任务源相关联的任务队列中。在事件循环的较前的几个步骤中,任务最终被消费。两个常用的拥有回调功能的 API,XMLHttpRequest 和 fetch API,分别对应了任务和微任务(后者使用期约 Promise)。每趟事件循环由于任务队列选择,不总是处理网络任务,但一定会处理所有微任务,这样,当大量请求同时到来时,前者可能会穿插渲染和其他任务,后者可以尽可能在其他任务,如渲染任务前处理完所有请求的回调。

至此,关于事件循环大致已经说清,接下来我们按照已经明朗的细节,拓展地谈一谈相关的话题。

审视常见的错误认知

要不要多次修改合为一次?

初学 DOM 编程的时候,我总会担心,如果我在执行完这个操作——

会不会文档渲染立刻产生变化,多个操作会不会导致灾难性的影响?

AJAX 异步调用会不会产生竞态条件(Race Condition)?

有时候,必须要反复确认测试才能安心一点……也只是一点。大学时期,身为菜鸟初学者的我和学长在做开发 Web 应用的兼职,总是遇到 JavaScript 奇奇怪怪的异步问题。于是,我们的独门秘招就是,出了问题 setTimeout,一个不行再来一个。可能有点搞笑,但确实是亲身经历。

读到现在,我们已经对 JavaScript 整体有了一些深度认识,现在已经可以解答这些问题了!

多次 DOM 修改的结果如何呢?

首先来回答我们刚才提出的第一个问题。浏览器架构巧妙地将渲染作为某种任务的一部分融入到了基于事件驱动的系统中。因此,明确地说,除非触发强制同步布局,不然在一段代码中修改文档和元素样式,只会有渲染步骤前最终的结果会应用到屏幕上。从这个意义上来说,多次修改合为一次是非必要的。

布局、布局颠簸和 DocumentFragment

那么多个 DOM 修改操作会不会导致灾难性的(性能)影响?如果有人告诉你,尽量避免多次修改 DOM,尽量整合为一个 DocumentFragment 再插入,因为直接修改 DOM 会触发页面布局……道理有点,但不多。

布局英文为 layout 或是 reflow,亦可称为重排。它是页面渲染流水线的一部分,指的是重新计算页面元素,确定各个元素的位置和尺寸,然后创建布局树。这是个昂贵的操作,因为需要遍历 DOM 和计算出的样式,尤其是在样式修改影响元素较多时尤为昂贵。通常会于布局前更新样式,其后执行绘制和合成。我们称在一段代码里交替地修改样式和触发强制布局导致页面反复密集地布局为布局颠簸,或是布局抖动。

修改样式直接触发布局仅限于使用强制同步布局 API,例如 offsetLeft 等。如果我们不注意 DOM API 的使用,经常触发布局颠簸,直接修改样式而不是借助 DocumentFragment 再插入,那么性能将会很差。除此之外,使用 DOM API 主要的损耗在于 JavaScript 与外部系统可能的上下文切换代价,但……使用 DocumentFragment 真的可以避免吗?

我们说 DocumentFragment 是轻量版的 Document,插入其内不会触发 DOM 树的更新。所以,把它插入 DOM 树也不会更新——才怪,那样看不见新增的节点了。有一种说法,使用 DocumentFragment 插入批量节点只会更新一次,分开批量插入节点会触发多次布局,甚至 MDN 的中文文档截止至目前,2024年7月,还是这样描述的。嗯,太怪了,太怪了。我把 DocumentFragment 插入 DOM 树剩下一个空的该对象,还能像粘贴文本一样啊。看看规范文档怎么说。插入 DocumentFragment 最终还是对于每个节点进行了插入操作。

用 DocumentFragment 插入批量节点看来是没有什么性能优化了,反而可能多出了一些步骤,部分引擎测试的性能甚至要比直接插入批量节点要慢一些,在英文版的 MDN 文档指出了这一点。在本人的浏览器测试中,大概要比直接插入的版本慢9%。

DocumentFragment 性能测试
DocumentFragment 性能测试

使用 DocumentFragment,最主要的原因应当还是出于代码可读性和操作方便性的考虑,使用它进行优化能只是以讹传讹的说法罢了。顺带提一句,隐藏元素并在修改后再显示可能也不是个很好的做法,可能有点一叶障目,除非你是分批次多趟事件循环才完成修改(那也要考虑修改的是什么样式、修改多少)。

那么,果真多次 DOM 修改合为一次没有什么用了吗?嗯,我只能说,如果不涉及强制布局同步,其实差异不大,你可以自己进行一些测试。这个问题再深入探讨起来就像是对比两碗一百克的米饭一样,“不合并”总会少一两粒米。更重要的是逻辑看起来比较顺畅清爽,而且让非常重视合并 DOM 修改优化性能的人也能得到慰藉。但请记住,不要出于性能优化做这件事,也不要认为真的“性能优化”了。况且,过早的优化往往会拖慢你的效率。

虚拟 DOM 真的优化了 DOM 修改了吗?

讲到这里,我联想到另一个类似的场景。我在一些文章中见到一个常见的说法,虚拟 DOM 总是要比 DOM 快。或者是,在到达某个阈值后,虚拟 DOM 总是要比 DOM 快。这是不正确的。

一说在 React 之前,虚拟 DOM 尚未达到可用程度,Meta 公司团队改进了虚拟 DOM 算法使得其时间复杂度降低至可用,并用其作为声明式编程的支撑。这之后,总是会有人宣传虚拟 DOM 的“快”这一特点。

我们先限定以下说法,因为虚拟 DOM 总是要比 DOM 快实际比较的是“使用虚拟 DOM 调用 API 修改 DOM 总是要比直接调用 API 修改 DOM 要快”。至此,你应该发现了什么。

在常用的框架中的虚拟 DOM 算法,现代的 Vue3 选择的策略是每个节点对比后直接 patch 节点,而 React 19 则是借助 Fiber 架构简历副作用列表并在任务调度中批量更新。无论具体实现如何,归根到底,虚拟 DOM 还是需要进行真实的 DOM 操作,此外,它总是会在 DOM 上添加一层额外层,同时需要管理以对比新旧节点的区别,以合并和最小化修改。

我们假设 DOM 操作总是最优的且我们知道这种方法。在这个前提下,多一层额外层和管理需求,意味着额外的空间和时间消耗(更何况,虚拟 DOM 自身也需要传输 JavaScript 代码)。这样,总会是 DOM 直接操作更优。

至此,我们得出一个结论,最终得出同样 DOM 操作的情况下,虚拟 DOM 要

但是,我们需要陈述一个事实,并不是所有人都知道最优的 DOM 操作方法,况且还可能无意间触发强制布局同步,实际不总是直接操作 DOM 更快。提供虚拟 DOM 类的框架主要优势在于声明式编写数据驱动的应用,对开发者友好,同时虚拟 DOM 替换 API 操作层可以用于构建其他环境的应用(但真实情况复杂的多),如 React Native,虽然这类应用性能总是较差的,胜在开发效率高(没踩坑或要求优化之前)。当今前端领域,也已经出现拥抱 No Virtual DOM 的声明式解决方案,如 Svelte,或是主要纯服务端构建 HTML 页面的声明式解决方案,如 Astro,以规避虚拟 DOM 性能上的问题。

总结而言,虚拟 DOM 确实优化了 DOM 修改,但通常不是性能方面的,而是关于开发者体验和效率方面的。

围绕 DOM 修改使用 requestAnimationFrame

了解了事件循环,requestAnimationFrame 的功能也在专注动画更新之外出现了新的奇淫技巧。

延迟 DOM 修改

如果你的操作分布在许多不同的函数中,且它们其中的一些将修改共用的值,然后触发强制同步布局,但你需要的是当前帧状态的值,而不是更新后的,也许你不能保证各模块按顺序执行,或是缓存到全局要保持同步非常麻烦,就可以使用 requestAnimationFrame 延迟修改,使得。举个非真实世界的例子:

const el = document.createElement('div');
el.style.cssText = `position: absolute; top: 0; left: 0; width: 20px; height: 20px; background: #000`;
// 在控制模块
function moveTop() {
  const top = el.getClientBoundingRect().top;
  requestAnimationFrame(() => {
    el.style.top = `${top + 10}px`;
  });
}
// 在
function recordTop() {
    const top = el.getClientBoundingRect().top;
    console.log(top);
}

获取布局更新后的新样式

如果你前端经验丰富,一定遇到过需要触发布局更新后获取新布局的情况。如果你不是很情愿触发强制布局更新,导致每次都多那么一次布局和绘制的话,这里有一个方法,称之为二重动画帧回调:

function runAfterDOMUpdate(fn) {
  function nextFrameWork() {
    requestAnimationFrame(fn);
  }

  requestAnimationFrame(nextFrameWork);
}

这个方法巧妙地运用了动画帧回调在浏览器事件循环中的位置。

异步调用会不会产生竞态条件?

结论而言,不会。你可以放心地修改 DOM,大胆去在异步中修改。

浏览器架构遵循了一个黄金法则,针对于 UI 修改的线程只能有一个,从而避免了大多异步带来的非预期问题。也就是说,无论异步进行了什么操作,它关联的回调永远是回到主线程中运行的——主线程做完手头的工作,才会轮到这些回调,不会出现竞态条件。同时,它对于 DOM/CSSOM 的修改不会同步地反映到屏幕上,除非触发强制同步布局,否则就是在下一个渲染机会到来时被同步到屏幕。最终我们需要考虑的是,如果多个异步同时进行,最终回调都修改 DOM,那么需要确保回调执行顺序吗?如何去确保?这就是另一个话题了。

再说防抖

防抖这个技术,旨在限制某算法以特定间隔发生。常见的一个用例就是针对于页面滚动加载的事件监听进行节流。

import { throttle } from 'lodash';
const callback = throttle(() => {}, 50);
window.addEventListener('scroll', callback);

我们常用的防抖技术主要基于特定的标志遍历和计时器的应用,也有人提出一种基于 requestAnimationFrame 的防抖方案,此处也一并讨论。

防抖技术的用途意味着,如果防抖的间隔低于或等于事件的发生的间隔,那防抖将没有意义。

我们讨论过事件循环的处理模型,在其中看到了一些事件分发步骤作为渲染任务的一部分存在,比如“scroll”,而 requestAnimationFrame 在其后。

这意味着,其一,“scroll”事件将在具有渲染机会时触发,那么,理想状态下触发后会以屏幕刷新率的固定间隔在一定时间内连续发生。对于存世量仍不少的 60Hz 的显示器,这个间隔理想状态下是约 16.7ms,意味着,如果小于这个间隔,防抖将是无意义的。因此,最好在防抖前思考一下是大于了显示器刷新的间隔。

其二,“scroll”事件回调将与 requestAnimationFrame 以大致相同频率触发。因此,使用 requestAnimationFrame 进行“scroll”事件的节流是个非常有意思的选择,非常幽默。这个例子我也在 MDN 的文档中见到过,作为参考举例,虽然较委婉地声明了不该这样写,但谁知道呢,可能有人会不看说明呢?

在测试中的顺序问题

我们在事件监听器的回调一节中已经指出,用户交互会导致任务排队到与用户交互源相关的任务队列。似乎我们可以认知为触发交互事件最终会触发任务排队。但请注意,交互事件的触发来源不只是用户的交互,通过 JavaScript 调用也可以,而这也是许多集成测试中触发事件的常用手段,举个经典的例子:

cons a = document.querySelector('a');
a.click();

Jake Archibald 的演讲中明确地提出了此触发导致的问题,在半小时处可以看到(我挺喜欢这老哥的,在很多谷歌的对谈节目里也能看到他,技术深,人也很幽默)。这里进行简单地描述。

假设我们有一个按钮 button,这里为它们附加两个事件监听处理器。

const button = document.createElement('button');
button.innerText = 'Click Me!';
document.body.append(button);

button.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('Microtask 1'));
    console.log('Listener 1');
});

button.addEventListener('click', () => {
   	Promise.resolve().then(() => console.log('Microtask 2'));
    console.log('Listener 2');
});

你可以思考一下,用户点击它的结果如何,答案进行了折叠。

答案:
Listener 1 Microtask 1 Listener 2 Microtask2

预料之中,对吧?现在我们来看看稍微修改后的代码:

const button = document.createElement('button');
button.innerText = 'Click Me!';
document.body.append(button);

button.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('Microtask 1'));
    console.log('Listener 1');
});

button.addEventListener('click', () => {
   	Promise.resolve().then(() => console.log('Microtask 2'));
    console.log('Listener 2');
});

button.click();

现在来思考,此时的输出顺序。

答案:
Listener1 Listener2 Microtask1 Microtask2

也许你会惊讶——“诶,微任务不是应该先执行吗?”你没有错,但这仅是在前一个任务已经运行完时的流程。

我们通过用户点击触发事件回调时,浏览器会为我们生成两个任务,而通过 click() 触发时,仍处于任务运行之中,不会生成新的任务,它将同步地提取回调函数并执行。

这是一个非常常见的现象,好在大多数场景下顺序并没有那么重要。但如果你需要对代码进行测试,而对这个问题感到迷惑,那么现在你应该豁然开朗了。

那么,事件循环的处理模型,再加上这一节,你应该可以很轻松地解决 Jake 留给我们的两个新问题了,这个问题就留给读者思考,不作过多叙述。

输出什么?

const b = document.querySelector('div');
b?.addEventListener('click', () => {
    console.log('in print');
});
console.log('bind');
// 为了确保三个回调都添加到任务队列中
setTimeout(() => b?.click(), 0);
setTimeout(console.log, 0, 'before click');
setTimeout(console.log, 0, 'after click');
答案:
bind
in print
before click
after click

有作用吗?

const link = document.querySelector('a');
const nextTick = new Promise(resolve => {
    link.addEventListener('click', resolve, { once: true });
});

nextClick.then(event => {
    event.preventDefault(); // 有作用吗?
});

link.click();

答案: 没有用,链接依旧会被触发

其他应用的事件循环模式

我们在开头概述的部分也提到了,事件循环并非 JavaScript 的专属。它是一种通用的设计模式,同时也存在于许多常见的程序中。

你可能会好奇,到底 Web 服务器是如何运行的,比如 Express 应用。

我们先以 Expess 为例,查看其源码,可以发现其 listen 内部调用了 nodejs 中 http 模块的方法。

var http = require('http');
/// ... 省略
app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

顺着路径查找,我们可以大致在 C++ 部分的源码中找到相关的内容。同样,也是借助了宿主环境的事件循环能力。

TCPWrap::TCPWrap(Environment* env, Local<Object> object, ProviderType provider)
    : ConnectionWrap(env, object, provider) {
  int r = uv_tcp_init(env->event_loop(), &handle_);
  CHECK_EQ(r, 0);  // How do we proxy this error up to javascript?
                   // Suggestion: uv_tcp_init() returns void.
}

类似地,tornado 内部也是使用了事件循环

def connection_ready(sock, fd, events):
    while True:
        try:
            connection, address = sock.accept()
        except BlockingIOError:
            return
        connection.setblocking(0)
        io_loop = tornado.ioloop.IOLoop.current()
        io_loop.spawn_callback(handle_connection, connection, address)

可以猜测,nginx 内部也使用了类似的技术(确实也是用了事件循环)。

不仅仅是 Web 服务器使用到了此技术。同样的,实现一个 REPL 也会用到事件循环。甚至我们日常使用的 Windows 系统应用程序也有事件循环,这是大多数程序的核心,其称为“消息循环”。此外,游戏循环也是利用此原理,在每趟循环里检查用户输入并作出响应。

事件循环模型属于异步非阻塞的 I/O 模型,通过这种模型,我们可以出色地解决 I/O 密集型任务,避免线程资源的浪费。一个天生 I/O 密集型优化的 JavaScript 运行时,nodejs 就是以此特色闻名的。此外,我也曾用过 Dart,它也是开箱即支持事件循环的语言,作为开发语言出色地支撑了 Flutter。

常见事件循环的应用场景如下:

  • 异步 I/O
  • 进程或线程间通信
  • Web 服务器

总结

本文中,依照规范原文,对事件循环进行了解构和拓展,我们提及了:

  • 浏览器事件循环的组成结构
  • 浏览器事件循环的处理模型
  • 任务和微任务在事件循环中的处理顺序
  • 了解事件循环可以纠正的错误认知
  • 其他应用中的事件循环

现在,你应该对事件循环应该有了整体上的认识。或许,现在正是时候,使用自己擅长的语言,尝试编写一个简单的事件循环了!

参考资料

[1]: WHATWG. HTML Living Standard[DB/OL]. [参考链接](https://html.spec.whatwg.org/multipage/. 2024-07-02: 8.1.7

[2]: TC39 和 ECMAScript 社区. ECMAScript® 2025 Language Specification[DB/OL]. 参考链接. 2024-07-04

[3]: W3C. requestIdleCallback()[DB/OL]. 参考链接. 2022-06-27

[4]: CSSWG. CSSOM View Module[DB/OL]. 参考链接. 2024-06-21

[5]: CSSWG. Web Animations[DB/OL]. 参考链接. 2024-05-17

[6]: Maya Armyanova, Benedikt Meurer. Faster async functions and promises[DB/OL]. 参考链接. 2018-11-12

[7]: Philip Roberts. What the heck is the event loop anyway[Z/OL]. 参考链接. 2014-10-09

[8]: Jake Archibald. On the web browser event loop[Z/OL]. 参考链接. 2014-10-09

[9]: Lydia Hallie. JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue[Z/OL]. 参考链接. 2014-10-09