技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

深入理解 JavaScript Event Loop

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/113-深入理解 JavaScript Event Loop
本文目录18 个章节
  1. 0x00 基础概念
  2. JavaScript Engine 和 JavaScript Runtime
  3. 0x01 关于 JavaScript 语言
  4. 0x02 Event Loop
  5. 题外话
  6. Event Loop 中的任务队列
  7. 1. Task Queue
  8. 2. Microtask Queue
  9. JavaScript Runtime 的运行机制
  10. Event Loop 处理模型
  11. Microtask Queue 执行时机
  12. Microtask Queue 应用场景
  13. 0x03 在浏览器中测试和验证 Event Loop
  14. 0x04 写在最后
  15. 原文勘误与现代化说明
  16. 图片来源与授权说明
  17. 原文归属
  18. 参考链接

深入理解 JavaScript Event Loop

Category(分类): JavaScript Status: 已整理

本文保留了原文(2019 年,源自知乎 nonoroazoro)的完整主线:Engine 与 Runtime 之分、WHATWG 事件循环标准、Task/Microtask 队列、处理模型、经典的真实点击 vs 合成 .click() 实验、Vue nextTick 应用与 Performance 验证。原文从知乎导出时排版损坏(大量块引用内容丢失、代码被加粗标记打散),已按语义修复;经典点击实验的输出已在当前 Chromium 中重新实测核对。文末附勘误清单。

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

0x00 基础概念

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

JavaScript Engine 和 JavaScript Runtime

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

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

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

举个栗子:

V8 实现并提供了 ECMAScript 标准定义的核心语言能力(语法、内置对象、执行语义)。

  • 浏览器的 Runtime(如 Chrome 的 Blink)在此之上提供了 DOMwindowfetchsetTimeout 等宿主对象和 API;
  • Node.js 的 Runtime 则提供了 require(CommonJS 模块系统)、processfs 等另一套宿主 API。

(原文此处表格在导出时损坏,已按上下文恢复。)

0x01 关于 JavaScript 语言

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

Engine、Runtime、Heap、Stack 关系示意图(原文插图)

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

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

然而 JavaScript 却又是一个非阻塞、异步、并发式的编程语言,这就得说说 JavaScript 的事件循环机制了。

现代注解:说「JavaScript 是单线程」时,准确含义是「一个 agent(执行代理)内只有一个执行线程」。浏览器可以通过多个标签页、iframe(部分场景)和 Web Worker 创建多个 agent 并行执行 JavaScript;Node.js 有 worker_threads。但每个 agent 内部仍然是单线程 + 事件循环,本文讨论的模型不变。

0x02 Event Loop

事件循环 是让 JavaScript 做到既是单线程,又不会阻塞的核心机制,也是 JavaScript 并发模型的基础,是用来协调各种事件、用户交互、脚本执行、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,它也更加复杂一些(Worker 没有渲染步骤,只有任务与微任务循环)。

另外,还需要注意的是: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:动态标准(Living Standard,持续更新)

W3C:固定标准(按版本号快照发布)

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

(注:原文提到的两套标准详细对比站 diffofhtmls.herokuapp.com 已下线,此处移除该链接。2019 年后 W3C 也开始与 WHATWG 合作维护同一份 Living Standard,历史分歧已基本弥合。)

Event Loop 中的任务队列

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

实际上,称「任务队列」比「事件队列」更准确——队列中存放的是 Task(任务),事件(Event)只是任务的一种来源。(原文此处的对比表述在导出时损坏,按上下文恢复。)

1. Task Queue

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

关于 Task,常有人称它为 Macrotask(宏任务),但其实 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,通常认为有以下几种:

这里要特别提一下:有很多文章把 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. 对于同步任务,创建执行上下文,按顺序进入执行栈(参考 Calling scripts);
  3. 对于异步任务
    • 与步骤 2 相同,同步执行这段代码本身;
    • 将相应的 Task(或 Microtask)添加到 Event Loop 的任务队列;
    • 由其他线程来执行具体的异步操作。

    其他线程是指:尽管 JavaScript 是单线程的,但浏览器内核是多线程的,它会将 GUI 渲染、定时器触发、HTTP 请求等工作交给专门的线程来处理——这些「异步接口」由 Runtime 提供,不属于 JS 引擎。(原文此句在导出时损坏,按上下文恢复。)

  4. 当主线程执行完当前执行栈中的所有任务,就会去读取 Event Loop 的任务队列,取出并执行任务;
  5. 重复以上步骤。

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

JavaScript Runtime 运行机制简图(原文插图)

还是拿 setTimeout 举个栗子:

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

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

Event Loop 处理模型

前面简单介绍了 JavaScript Runtime 的整个运行流程,而 Event Loop 作为其中的重要一环,它的每一次循环过程也相当复杂,因此将它单独拿出来介绍。下面我会尽量保持 HTML 标准中对处理模型的定义,并尽量简化,步骤如下(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 被特意标注出来以示区别)。

Event Loop 处理模型简图(原文插图)

现代注解:标准后来在「执行 Task」之前还定义了「spin the event loop」「pause」等特殊分支,渲染步骤也新增了 blocking=render 属性的等待逻辑,但上面三步骨架没变。另外第 3 步只在存在渲染机会时执行——后台标签页可能连续多轮都跳过渲染,因此「每轮循环必有 rAF」是不成立的。

Microtask Queue 执行时机

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

  1. 某个 Task 执行完毕时(即上述情况)。
  2. 进入脚本执行的清理阶段(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"))
    .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);

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

嵌套 DIV 演示页面(原文插图)

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

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

鼠标点击的输出结果(原文截图)

在当前 Chromium 中的实测输出为:

click → promise → mutate → click → promise → mutate → timeout → timeout

为了容易看明白整个过程,原文做了一个动画演示(知乎视频已无法内嵌,保留封面帧):

动画演示封面:真实点击(原文视频封面)

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

inner.click();
console.log("end");

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

代码调用 inner.click() 的输出结果(原文截图)

在当前 Chromium 中的实测输出为:

click → click → end → promise → mutate → promise → timeout → timeout

(注意:两次 setAttribute 产生的两条 mutation record 会在同一次 MutationObserver 微任务回调里一起投递,所以只打印一次 mutate;而每个监听器各排了一个 setTimeout,所以 timeout 出现两次。)

同样,看视频吧(知乎视频已无法内嵌,保留封面帧):

动画演示封面:合成 click()(原文视频封面)

总结一下,两次执行过程的本质区别,就在于执行 Microtask Queue 前,当前执行栈是否为空

  • 真实点击:整个事件派发由浏览器的事件循环作为 task 驱动。第一个监听器执行完回到派发逻辑时 JS 执行栈已空,微任务检查点立即发生——所以两个 click 之间穿插了 promise/mutate
  • 合成 inner.click():派发发生在你自己的脚本执行期间,外层脚本上下文一直压在栈上,两次 onClick 之间栈不为空,微任务被推迟到整个脚本(包括 end)执行完之后才统一执行。

P.S. 有兴趣的朋友可以在例子 2 里把 inner.click() 拆成两个独立的 task(例如分别放进两个 setTimeout 里),再观察输出顺序的变化。(原文此处的补充说明在导出时损坏,按上下文恢复。)

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,目标是希望当页面滚动时固定住黄色方块,可以明显看到后者比前者更稳定、流畅。

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

现代注解:这是 Vue 2 时代的故事(nextTick 内部按 Promise.then → MutationObserver → setImmediate → setTimeout 降级)。Vue 3 的 nextTick 已简化为直接返回 Promise.then 回调,不再做能力降级(需要兼容极老环境时由构建侧处理);Vue 3 的响应式也从 Object.defineProperty 换成了基于 Proxy 的实现。

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

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

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

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

Chrome Performance 面板录制结果(原文截图)

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

0x04 写在最后

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

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


原文勘误与现代化说明

  1. 修复知乎导出导致的排版损坏:Engine/Runtime 的举例列表、任务队列/事件队列的辨析、异步任务三步中的「其他线程」说明、P.S. 补充实验等四处块引用内容丢失,已按上下文语义恢复并标注;代码示例从加粗标记污染中还原(click 示例、inner.click() 等)。
  2. 实测核对经典点击实验:两种触发方式的输出在当前 Chromium(Playwright 环境)重新运行——真实点击为 click → promise → mutate → click → promise → mutate → timeout → timeout;合成 .click()click → click → end → promise → mutate → promise → timeout → timeout,与原文结论一致(区别在于两次 onClick 之间执行栈是否为空);并补充了「两条 mutation record 合并投递、两次 timeout 各自排队」的解释。
  3. 微任务来源清单补充标准 API queueMicrotask()Object.observe 保留废弃标注。
  4. 「单线程」表述补充 agent/Worker 维度的现代注解;Browsing Context/Worker 两种事件循环补充「Worker 无渲染步骤」的差异说明。
  5. WHATWG 与 W3C 的历史分歧补充 2019 年后双方合作维护同一 Living Standard 的现状;移除已下线的 diffofhtmls.herokuapp.com 链接。
  6. 事件循环处理模型补充说明:渲染步骤仅在有渲染机会时执行,后台标签页可跳过(因此「每轮必有 rAF」不成立)。
  7. Vue 一节补充 Vue 3 nextTick(直接 Promise.then)与响应式(Proxy)现状。
  8. 参考链接整理:zhihu 跳转链替换为直链;V8 wiki 链接更新为 v8.dev;jsfiddle 在线示例四个链接全部验证可用;知乎视频无法内嵌,改为保留封面帧并注明。

图片来源与授权说明

正文图片已本地化到 images/ 目录,均来自原文(知乎 nonoroazoro《深入理解 JavaScript Event Loop》)的图床资源(pic*.zhimg.com)。图片的转载授权无法仅凭下载确认,公开发布前应向原作者确认许可:

  • images/113-image-01.webp:Engine/Runtime/Heap/Stack 关系示意图。来源:原文插图。
  • images/113-image-02.webp:Runtime 运行机制简图。来源:原文插图。
  • images/113-image-03.webp:Event Loop 处理模型简图(含 process.nextTick 标注)。来源:原文插图。
  • images/113-image-04.webp:嵌套 DIV 演示页面截图。来源:原文截图。
  • images/113-image-05.webp:真实点击输出结果截图。来源:原文截图。
  • images/113-image-06.webp:动画演示封面(真实点击,知乎视频无法内嵌)。来源:原文视频封面。
  • images/113-image-07.webp:合成 click() 输出结果截图。来源:原文截图。
  • images/113-image-08.webp:动画演示封面(合成 click(),知乎视频无法内嵌)。来源:原文视频封面。
  • images/113-image-09.webp:Chrome Performance 录制截图。来源:原文截图。

原文归属

作者:nonoroazoro(从文中在线示例的 jsfiddle 账号可证)。历史来源:知乎专栏(原文页面链接未在导出稿中保留,且知乎对程序化访问反爬,无法给出可验证直链;文中四个 jsfiddle 在线示例为其原作,均已验证可用)。当前语义以本文列出的规范和官方文档为准。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS