深入理解 JavaScript Event Loop

深入理解 JavaScript Event Loop

提到 JavaScript 事件循环(Event Loop),很多人都知道是它驱动了 JavaScript 异步编程。然而因为它在标准中的定义相当复杂和晦涩,它的实现又属于看不见、摸不着的那种,所以也激起了我深入研究的兴趣,也想看看究竟能不能搞明白这个玩意。


0x00 基础概念

相信很多人都不太喜欢看基础概念,所以这里只列了两个与 Event Loop 相关、同时我认为比较重要的概念,如果你已经知道了就跳过吧。

JavaScript Engine 和 JavaScript Runtime

简单来说,为了让 JavaScript 运行起来,要完成两部分工作(当然实际比这复杂的多):

  • 编译并执行 JavaScript 代码,完成内存分配、垃圾回收等;
  • 为 JavaScript 提供一些对象或机制,使它能够与外界交互。

这里的第一部分,是 Engine(执行引擎);第二部分,是 Runtime(执行环境)。

举个栗子:
Chrome 和 Node.js 都使用了 V8 Engine:V8 实现并提供了 ECMAScript 标准中的所有数据类型、操作符、对象和方法(注意并没有 DOM)。
但它们的 Runtime 并不一样:Chrome 提供了 windowDOM,而 Node.js 则是 requireprocess 等等。


0x01 关于 JavaScript 语言

在 JavaScript 运行的时候,JavaScript Engine 会创建和维护相应的堆(Heap)和栈(Stack),同时通过 JavaScript Runtime 提供的一系列 API(例如 setTimeout、XMLHttpRequest 等)来完成各种各样的任务。

JavaScript 是一种单线程的编程语言,只有一个调用栈,决定了它在同一时间只能做一件事情。

在 JavaScript 的运行过程中,真正负责执行 JavaScript 代码的始终只有一个线程,通常被称为主线程,各种任务都会用排队的方式来同步执行。这种方式最常见的一个问题就是:如果你尝试执行一段非常耗时的同步代码,浏览器就没办法同时去渲染 GUI,导致界面失去响应,也就是被阻塞了。

然而 JavaScript 却又是一个非阻塞(Non-blocking)、异步(Asynchronous)、并发式(Concurrent)的编程语言,这就得说说 JavaScript 的事件循环(Event Loop)机制了。


0x02 Event Loop

事件循环(Event Loop) 是让 JavaScript 做到既是单线程,又绝对不会阻塞的核心机制,也是 JavaScript 并发模型(Concurrency Model的基础,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制。

说的更简单一点:Event Loop 只不过是实现异步的一种机制而已。

Event Loop 分为两种,一种存在于 Browsing Context 中,还有一种在 Worker 中。

  1. Browsing Context 是指一种用来将 Document 展现给用户的环境。例如浏览器中的 tab,window 或 iframe 等,通常都包含 Browsing Context。
  2. Worker 是指一种独立于 UI 脚本,可在后台执行脚本的 API。常用来在后台处理一些计算密集型的任务。

本文重点介绍的是 Browsing Context 中的 Event Loop,相比 Worker 中的 Event Loop,它也更加复杂一些。

另外,还需要注意的是:Event Loop 并不是在 ECMAScript 标准中定义的,而是在 HTML 标准中定义的:

To coordinate events, user interaction, scripts, rendering, networking, and so forth...

在 JavaScript Engine 中(以 V8 为例),只是实现了 ECMAScript 标准,而并不关心什么 Event Loop。也就是说 Event Loop 是属于 JavaScript Runtime 的,是由宿主环境提供的(比如浏览器)。所以千万不要搞错了,这也是前面介绍 JavaScript Engine 和 Runtime 的原因。

题外话

关于 Event Loop 的定义,还牵涉到两套不同的 HTML 标准:WHATWG HTML Living StandardW3C HTML5。至于为什么会有两套标准,又是 HTML 发展史中的另一段故事了,这里只简单总结一下这两个组织的区别(这里可以查看两套文档的详细对比):

WHATWG 得到了 Mozilla,Google,Apple 和 Opera 的支持,希望将 HTML 做成一个持续改进的动态标准,因此称为 HTML Living Standard,快速推动 HTML5 的发展,确保用户得到最新的体验。
缺点就是标准的内容时常会发生变更,另外也需要浏览器厂商和程序员及时跟上快速迭代的节奏。

W3C 背后则是 Microsoft,致力于将 HTML5 做成一个单一的、确定的固定标准,更像是 WHATWG 标准的一个快照(Snapshot)。
缺点就是响应缓慢,标准一旦制定后,即便出现错误也无法快速修正。

本文以 WHATWG HTML Living Standard 为准(不过两套标准中对于 Event Loop 的定义,还是相当一致的)。另外,为了保持原意,下文一些专有名词尽量保持英文原文。

Event Loop 中的任务队列

在执行和协调各种任务时,Event Loop 会维护自己的任务队列。任务队列又分为 Task QueueMicrotask Queue 两种。

实际上,称任务队列事件队列(Event Queue)可能会更容易理解。所谓的事件驱动(Event-driven),就是将一切抽象为事件(Event),比如 AJAX 完成、鼠标点击、I/O 操作等等,都是一个个的事件,而 Event Loop 就是一个事件循环的过程。

不过本文还是以 HTML 标准中的叫法作为参考。

1. Task Queue

一个 Event Loop 会有一个或多个 Task Queue,这是一个先进先出(FIFO)的有序列表,存放着来自不同 Task Source(任务源)的 Task。

关于 Task,常有人称它为 Marcotask,但其实 HTML 标准中并没有这种说法。

在 HTML 标准中,定义了几种常见的 Task Source:

  1. DOM manipulation(DOM 操作);
  2. User interaction(用户交互);
  3. Networking(网络请求);
  4. History traversal(History API 操作)。

Task Source 的定义非常的宽泛,常见的鼠标、键盘事件,AJAX,数据库操作(例如 IndexedDB),以及定时器相关的 setTimeout、setInterval 等等都属于 Task Source,所有来自这些 Task Source 的 Task 都会被放到对应的 Task Queue 中等待处理。

对于 Task、Task Queue 和 Task Source,有如下规定:

  1. 来自相同 Task Source 的 Task,必须放在同一个 Task Queue 中;
  2. 来自不同 Task Source 的 Task,可以放在不同的 Task Queue 中;
  3. 同一个 Task Queue 内的 Task 是按顺序执行的;
  4. 但对于不同的 Task Queue(Task Source),浏览器会进行调度,允许优先执行来自特定 Task Source 的 Task。
例如,鼠标、键盘事件和网络请求都有各自的 Task Queue,当两者同时存时,浏览器可以优先从用户交互相关的 Task Queue 中挑选 Task 并执行,比如这里的鼠标、键盘事件,从而保证流畅的用户体验。

2. Microtask Queue

Microtask Queue 与 Task Queue 类似,也是一个有序列表。不同之处在于,一个 Event Loop 只有一个 Microtask Queue

在 HTML 标准中,并没有明确规定 Microtask Source,通常认为有以下几种:

Promises/A+ Note 3.1 中提到了 then、onFulfilled、onRejected 的实现方法,但 Promise 本身属于平台代码,由具体实现来决定是否使用 Microtask,因此在不同浏览器上可能会出现执行顺序不一致的问题。不过好在目前的共识是用 Microtask 来实现事件队列。

这里要特别提一下:有很多文章把 Node.js 的 process.nextTick 和 Microtask 混为一谈,事实上虽然两者层级(运行时机)非常接近,但并不是同一个东西。process.nextTick 是 Node.js 自身定义实现的一种机制,有自己的 nextTickQueue,与 HTML 标准中的 Microtask 不是一回事。在 Node.js 中,process.nextTick 会先于 Microtask Queue 被执行。这里不细说了,可以参考这里的讨论

JavaScript Runtime 的运行机制

了解了 Event Loop 和队列的基本概念后,就可以从相对宏观的角度先了解一下 JavaScript Runtime 的运行机制了,简化后的步骤如下:

1. 主线程不断循环;

2. 对于同步任务,创建执行上下文(Execution Context),按顺序进入执行栈(参考 Calling scripts);

3. 对于异步任务

  • 与步骤 2 相同,同步执行这段代码;
  • 将相应的 Task(或 Microtask)添加到 Event Loop 的任务队列;
  • 由其他线程来执行具体的异步操作。
其他线程是指:尽管 JavaScript 是单线程的,但浏览器内核是多线程的,它会将 GUI 渲染、定时器触发、HTTP 请求等工作交给专门的线程来处理。

另外,在 Node.js 中,异步操作会优先由 OS 或第三方系统提供的异步接口来执行,然后才由线程池处理。

4. 当主线程执行完当前执行栈中的所有任务,就会去读取 Event Loop 的任务队列,取出并执行任务;

5. 重复以上步骤。

是不是有点坑。。。用一张简图来表示一下这种运行机制:

还是拿 setTimeout 举个栗子:

  1. 主线程同步执行这个 setTimeout 函数本身。
  2. 将负责执行这个 setTimeout 的回调函数的 Task 添加到 Task Queue。
  3. 定时器开始工作(实际上是靠 Event Loop 不断循环检查系统时间来判断是否已经到达指定的时间点)。
  4. 主线程继续执行其他任务。
  5. 当执行栈为空,且定时器触发时,主线程取出 Task 并执行相应的回调函数。

很明显,执行 setTimeout 不会导致阻塞。当然,如果主线程很忙的话(执行栈一直非空),就会出现明明时间已经到了,却也不执行回调的现象,所以类似 setTimeout 这样的回调函数都是没法保证执行时机的。

Event Loop 处理模型

前面简单介绍了 JavaScript Runtime 的整个运行流程,而 Event Loop 作为其中的重要一环,它的每一次循环过程也相当复杂,因此将它单独拿出来介绍。下面我会尽量保持 HTML 标准中对处理模型(Processing Model)的定义,并尽量简化,步骤如下(3 步):

  1. 执行 Task:从 Task Queue 中取出最老的一个 Task 并执行;如果没有 Task,直接跳过。
  2. 执行 Microtasks:遍历 Microtask Queue 并执行所有 Microtask(参考 Perform a microtask checkpoint)。
  3. 进入 Update the rendering(更新渲染)阶段
    1. 设置 Performance API 中 now() 的返回值。Performance API 属于 W3C High Resolution Time API 的一部分,用于前端性能测量,能够细粒度的测量首次渲染、首次渲染内容等的各项绘制指标,是前端性能追踪的重要技术手段,感兴趣的同学可关注。
    2. 遍历本次 Event Loop 相关的 Documents,执行更新渲染。在迭代执行过程中,浏览器会根据各种因素判断是否要跳过本次更新。
    3. 当浏览器确认继续本次更新后,处理更新渲染相关工作:
      1. 触发各种事件:Resize、Scroll、Media Queries、CSS Animations、Fullscreen API。
      2. 执行 animation frame callbacks,window.requestAnimationFrame 就在这里。
      3. 更新 intersection observations,也就是 Intersection Observer API(可用于图片懒加载)。更新渲染和 UI,将最终结果提交到界面上。

至此,Event Loop 的一次循环结束。。。还是用一张简图来描述吧(process.nextTick 被特意标注出来以示区别)。

Microtask Queue 执行时机

在上面介绍的 Event Loop 处理模型中,Microtask Queue 会在第 2 步时被执行。实际上按照 HTML 标准,在以下几种情况中 Microtask Queue 都会被执行:

  1. 某个 Task 执行完毕时(即上述情况)。
  2. 进入脚本执行(Calling scripts)的清理阶段(Clean up after running script)时。
  3. 创建和插入节点时。
  4. 解析 XML 文档时。

同时,在当前 Event Loop 轮次中动态添加进来的 Microtasks,也会在本次 Event Loop 循环中全部执行完(上图其实已经画出来了)。

最后一定要注意的是,执行 Microtasks 是有前提的:当前执行栈必须为空,且没有正在运行的执行上下文。否则,就必须等到执行栈中的任务全部执行完毕,才能开始执行 Microtasks。

也就是说:JavaScript 会确保当前执行的同步代码不会被 Microtasks 打断。

这样就会导致一些初看上去很诡异的现象,拿一个经典的例子来验证一下:

首先创建一个由内外两个 DIV 嵌套组成的简单结构:

<div id="outer">
    <div id="inner"></div>
</div>

JavaScript 代码如下:

const inner = document.getElementById("inner");
const outer = document.getElementById("outer");

// 监听 outer 的属性变化。
new MutationObserver(() => console.log("mutate outer"))
    .observe(outer, { attributes: true });

// 处理 click 事件。
function onClick()
{
    console.log("click");
    setTimeout(() => console.log("timeout"), 0);
    Promise.resolve().then(() => console.log("promise"));
    outer.setAttribute("data-mutation", Math.random());
}

// 监听 click 事件。
inner.addEventListener("click", onClick);
outer.addEventListener("click", onClick);

这个东西看起来是这样的:

接下来,分别通过鼠标点击代码调用的方式来触发 inner 和 outer 的 click 事件,我们分别来看:

第一种方式:鼠标点击黄色方块,输出结果如下(在线测试,最好在 Chrome、Firefox 新版本中运行):

为了容易看明白整个过程,我做了一个简单的动画演示:

https://www.zhihu.com/video/953950402266218496

第二种方式:改成通过代码调用方式触发,在上面例子的最后一行加上

inner.click();

输出结果如下(在线测试,请在 Chrome、Firefox 新版本中运行):

同样,看视频吧:

https://www.zhihu.com/video/953950767745396736

总结一下,两次执行过程的本质区别,就在于执行 Microtask Queue 前,当前执行栈是否为空。因此在例子 2 的两次 onClick 之间,就不会执行 Microtask Queue,也就不会有控制台输出了。

P.S. 有兴趣的朋友可以在例子 2 最后再加上一行 console.log("end");,看看输出结果是怎样的。

Microtask Queue 应用场景

一个典型应用就是 Vue.js 的异步更新队列的实现,Microtask Queue 在这里起到的主要作用:

1. 提供异步队列

使 Vue.js 能够缓冲在同一个 Event Loop 中发生的所有数据变更,从而有机会在真正更新 UI 前,去除重复触发的数据变更,避免 DOM 进行不必要的更新。

2. 借助 Promise.thenMutationObserver 等实现 Vue.nextTick() 方法(本质就是利用 Microtask Queue 的执行时机)。

使 Vue.js 有能力让 UI 尽早得到更新,即在当前 Event Loop 轮次(而非下一轮)中就更新完毕。

这样带来的一个很明显的好处就是 UI 更稳定、更流畅。

这里有两个例子可以对比一下:例子 1例子 2,目标是希望当页面滚动时固定住(Fixed)黄色方块,可以明显看到后者比前者更稳定、流畅。

原因就是尤雨溪曾经在某个版本中,为了解决 Vue.js 在 iOS UIWebView 中遇到的一个 Bug,错误的将 nextTick 的实现由 MutationObserver 换成了 window.postMessage,使原本应该在 Microtask Queue 中进行的 UI 更新,放到了 Task Queue 中,也就是推迟到了下一轮 Event Loop 中,最终导致 UI duang 了(ISSUE 在这里)。


0x03 在浏览器中测试和验证 Event Loop

如果你想对 Event Loop 进行简单的测试和验证,可以借助 Chrome 的 Performance 工具来记录和分析。

  1. 打开测试页面(例如我们前面讨论的第一个例子);
  2. 在 Chrome 中进入 DevTools 并打开 Performance 工具;
  3. 点击录制(Record)
  4. 鼠标点击黄色方块;
  5. 点击停止录制(Stop)

这样在 Performance 栏中就能看到录制结果,选择鼠标点击事件所在时间段,即可看到这段时间内的所有活动,如下图所示:

鼠标选择事件能看到更详细的信息,在下方的 Call Tree 中还有具体的调用信息,非常方便进行分析和验证,赶紧去试试吧。

0x04 写在最后

能坚持看到这的都很不容易 :),就不废话了,仅希望本文能对各位理解 Event Loop 有所帮助。

最后我要吐槽,知乎的编辑器实在烂的没法排版,只能删掉一些内容。


参考链接

编辑于 2018-03-05

文章被以下专栏收录

    阿里巴巴南京研发中心隶属于阿里巴巴集团客户体验事业群,成立一年以来,团队规模迅速扩张,业务涵盖淘宝天猫业务维权咨询、帮助中心、阿里小蜜、人力云众包、大众评审等,岗位已包含后端技术、前端开发、产品经理和 UX 设计师等,想在南京工作或者想回江苏离爸妈近的同学们,还等什么呢,快点递交你们的简历吧!