深入理解 JavaScript Event Loop
Category(分类): JavaScript Status: 已整理
本文保留了原文(2019 年,源自知乎 nonoroazoro)的完整主线:Engine 与 Runtime 之分、WHATWG 事件循环标准、Task/Microtask 队列、处理模型、经典的真实点击 vs 合成
.click()实验、VuenextTick应用与 Performance 验证。原文从知乎导出时排版损坏(大量块引用内容丢失、代码被加粗标记打散),已按语义修复;经典点击实验的输出已在当前 Chromium 中重新实测核对。文末附勘误清单。
提到 JavaScript 事件循环(Event Loop),很多人都知道是它驱动了 JavaScript 异步编程。然而因为它在标准中的定义相当复杂和晦涩,它的实现又属于看不见、摸不着的那种,所以也激起了我深入研究的兴趣,也想看看究竟能不能搞明白这个玩意。
0x00 基础概念
相信很多人都不太喜欢看基础概念,所以这里只列了两个与 Event Loop 相关、同时我认为比较重要的概念,如果你已经知道了就跳过吧。
JavaScript Engine 和 JavaScript Runtime
简单来说,为了让 JavaScript 运行起来,要完成两部分工作(当然实际比这复杂得多):
- 编译并执行 JavaScript 代码,完成内存分配、垃圾回收等;
- 为 JavaScript 提供一些对象或机制,使它能够与外界交互。
这里的第一部分,是 Engine(执行引擎);第二部分,是 Runtime(执行环境)。
举个栗子:
V8 实现并提供了 ECMAScript 标准定义的核心语言能力(语法、内置对象、执行语义)。
- 浏览器的 Runtime(如 Chrome 的 Blink)在此之上提供了
DOM、window、fetch、setTimeout等宿主对象和 API;- Node.js 的 Runtime 则提供了
require(CommonJS 模块系统)、process、fs等另一套宿主 API。(原文此处表格在导出时损坏,已按上下文恢复。)
0x01 关于 JavaScript 语言
在 JavaScript 运行的时候,JavaScript Engine 会创建和维护相应的堆和栈,同时通过 JavaScript Runtime 提供的一系列 API(例如 setTimeout、XMLHttpRequest 等)来完成各种各样的任务。

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 中。
- Browsing Context 是指一种用来将 Document 展现给用户的环境。例如浏览器中的 tab、window 或 iframe 等,通常都包含 Browsing Context。
- 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 Standard 和 W3C 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 Queue 和 Microtask Queue 两种。
实际上,称「任务队列」比「事件队列」更准确——队列中存放的是 Task(任务),事件(Event)只是任务的一种来源。(原文此处的对比表述在导出时损坏,按上下文恢复。)
1. Task Queue
一个 Event Loop 会有一个或多个 Task Queue,这是一个先进先出的有序列表,存放着来自不同 Task Source(任务源)的 Task。
关于 Task,常有人称它为 Macrotask(宏任务),但其实 HTML 标准中并没有这种说法。
在 HTML 标准中,定义了几种常见的 Task Source:
- DOM manipulation(DOM 操作);
- User interaction(用户交互);
- Networking(网络请求);
- History traversal(History API 操作)。
Task Source 的定义非常的宽泛,常见的鼠标、键盘事件,AJAX,数据库操作(例如 IndexedDB),以及定时器相关的 setTimeout、setInterval 等等都属于 Task Source,所有来自这些 Task Source 的 Task 都会被放到对应的 Task Queue 中等待处理。
对于 Task、Task Queue 和 Task Source,有如下规定:
- 来自相同 Task Source 的 Task,必须放在同一个 Task Queue 中;
- 来自不同 Task Source 的 Task,可以放在不同的 Task Queue 中;
- 同一个 Task Queue 内的 Task 是按顺序执行的;
- 但对于不同的 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,通常认为有以下几种:
- Promise(在 Promises/A+ Note 3.1 中被称为 job queue;注意 Promise 本身立即执行,进入微任务队列的是它的 then/catch/finally 回调)
- MutationObserver(DOM 标准:queueing a mutation record)
- queueMicrotask()(现代补充的标准入队 API)
Object.observe(已废弃并从各浏览器移除)
这里要特别提一下:有很多文章把 Node.js 的 process.nextTick 和 Microtask 混为一谈,事实上虽然两者层级(运行时机)非常接近,但并不是同一个东西。process.nextTick 是 Node.js 自身定义实现的一种机制,有自己的 nextTickQueue,与 HTML 标准中的 Microtask 不是一回事。在 Node.js 中,process.nextTick 会先于 Microtask Queue 被执行。这里不细说了,可以参考这里的讨论。
JavaScript Runtime 的运行机制
了解了 Event Loop 和队列的基本概念后,就可以从相对宏观的角度先了解一下 JavaScript Runtime 的运行机制了,简化后的步骤如下:
- 主线程不断循环;
- 对于同步任务,创建执行上下文,按顺序进入执行栈(参考 Calling scripts);
- 对于异步任务:
- 与步骤 2 相同,同步执行这段代码本身;
- 将相应的 Task(或 Microtask)添加到 Event Loop 的任务队列;
- 由其他线程来执行具体的异步操作。
其他线程是指:尽管 JavaScript 是单线程的,但浏览器内核是多线程的,它会将 GUI 渲染、定时器触发、HTTP 请求等工作交给专门的线程来处理——这些「异步接口」由 Runtime 提供,不属于 JS 引擎。(原文此句在导出时损坏,按上下文恢复。)
- 当主线程执行完当前执行栈中的所有任务,就会去读取 Event Loop 的任务队列,取出并执行任务;
- 重复以上步骤。
是不是有点坑。。。用一张简图来表示一下这种运行机制:

还是拿 setTimeout 举个栗子:
- 主线程同步执行这个 setTimeout 函数本身。
- 将负责执行这个 setTimeout 的回调函数的 Task 添加到 Task Queue。
- 定时器开始工作(实际上是靠 Event Loop 不断循环检查系统时间来判断是否已经到达指定的时间点)。
- 主线程继续执行其他任务。
- 当执行栈为空,且定时器触发时,主线程取出 Task 并执行相应的回调函数。
很明显,执行 setTimeout 不会导致阻塞。当然,如果主线程很忙的话(执行栈一直非空),就会出现明明时间已经到了,却也不执行回调的现象,所以类似 setTimeout 这样的回调函数都是没法保证执行时机的。
Event Loop 处理模型
前面简单介绍了 JavaScript Runtime 的整个运行流程,而 Event Loop 作为其中的重要一环,它的每一次循环过程也相当复杂,因此将它单独拿出来介绍。下面我会尽量保持 HTML 标准中对处理模型的定义,并尽量简化,步骤如下(3 步):
- 执行 Task:从 Task Queue 中取出最老的一个 Task 并执行;如果没有 Task,直接跳过。
- 执行 Microtasks:遍历 Microtask Queue 并执行所有 Microtask(参考 Perform a microtask checkpoint)。
- 进入 Update the rendering(更新渲染)阶段:
- 设置 Performance API 中 now() 的返回值。Performance API 属于 W3C High Resolution Time API 的一部分,用于前端性能测量,能够细粒度地测量首次渲染、首次渲染内容等的各项绘制指标,是前端性能追踪的重要技术手段,感兴趣的同学可关注。
- 遍历本次 Event Loop 相关的 Documents,执行更新渲染。在迭代执行过程中,浏览器会根据各种因素判断是否要跳过本次更新(是否处于「渲染机会」)。
- 当浏览器确认继续本次更新后,处理更新渲染相关工作:
- 触发各种事件:Resize、Scroll、Media Queries、CSS Animations、Fullscreen API。
- 执行 animation frame callbacks,window.requestAnimationFrame 就在这里。
- 更新 intersection observations,也就是 Intersection Observer API(可用于图片懒加载)。更新渲染和 UI,将最终结果提交到界面上。
至此,Event Loop 的一次循环结束。。。还是用一张简图来描述吧(process.nextTick 被特意标注出来以示区别)。

现代注解:标准后来在「执行 Task」之前还定义了「spin the event loop」「pause」等特殊分支,渲染步骤也新增了
blocking=render属性的等待逻辑,但上面三步骨架没变。另外第 3 步只在存在渲染机会时执行——后台标签页可能连续多轮都跳过渲染,因此「每轮循环必有 rAF」是不成立的。
Microtask Queue 执行时机
在上面介绍的 Event Loop 处理模型中,Microtask Queue 会在第 2 步时被执行。实际上按照 HTML 标准,在以下几种情况中 Microtask Queue 都会被执行:
- 某个 Task 执行完毕时(即上述情况)。
- 进入脚本执行的清理阶段(Clean up after running script)时。
- 创建和插入节点时。
- 解析 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);
这个东西看起来是这样的:

接下来,分别通过鼠标点击和代码调用的方式来触发 inner 和 outer 的 click 事件,我们分别来看:
第一种方式:鼠标点击黄色方块,输出结果如下(在线测试,最好在 Chrome、Firefox 新版本中运行):

在当前 Chromium 中的实测输出为:
click → promise → mutate → click → promise → mutate → timeout → timeout
为了容易看明白整个过程,原文做了一个动画演示(知乎视频已无法内嵌,保留封面帧):

第二种方式:改成通过代码调用方式触发,在上面例子的最后一行加上
inner.click();
console.log("end");
输出结果如下(在线测试,请在 Chrome、Firefox 新版本中运行):

在当前 Chromium 中的实测输出为:
click → click → end → promise → mutate → promise → timeout → timeout
(注意:两次 setAttribute 产生的两条 mutation record 会在同一次 MutationObserver 微任务回调里一起投递,所以只打印一次 mutate;而每个监听器各排了一个 setTimeout,所以 timeout 出现两次。)
同样,看视频吧(知乎视频已无法内嵌,保留封面帧):

总结一下,两次执行过程的本质区别,就在于执行 Microtask Queue 前,当前执行栈是否为空:
- 真实点击:整个事件派发由浏览器的事件循环作为 task 驱动。第一个监听器执行完回到派发逻辑时 JS 执行栈已空,微任务检查点立即发生——所以两个
click之间穿插了promise/mutate; - 合成
inner.click():派发发生在你自己的脚本执行期间,外层脚本上下文一直压在栈上,两次onClick之间栈不为空,微任务被推迟到整个脚本(包括end)执行完之后才统一执行。
P.S. 有兴趣的朋友可以在例子 2 里把
inner.click()拆成两个独立的 task(例如分别放进两个setTimeout里),再观察输出顺序的变化。(原文此处的补充说明在导出时损坏,按上下文恢复。)
Microtask Queue 应用场景
一个典型应用就是 Vue.js 的异步更新队列的实现,Microtask Queue 在这里起到的主要作用:
- 提供异步队列。
使 Vue.js 能够缓冲在同一个 Event Loop 中发生的所有数据变更,从而有机会在真正更新 UI 前,去除重复触发的数据变更,避免 DOM 进行不必要的更新。
- 借助
Promise.then、MutationObserver等实现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 工具来记录和分析。
- 打开测试页面(例如我们前面讨论的第一个例子);
- 在 Chrome 中进入 DevTools 并打开 Performance 工具;
- 点击录制;
- 鼠标点击黄色方块;
- 点击停止录制。
这样在 Performance 栏中就能看到录制结果,选择鼠标点击事件所在时间段,即可看到这段时间内的所有活动,如下图所示:

鼠标选择事件能看到更详细的信息,在下方的 Call Tree 中还有具体的调用信息,非常方便进行分析和验证,赶紧去试试吧。
0x04 写在最后
能坚持看到这的都很不容易 :),就不废话了,仅希望本文能对各位理解 Event Loop 有所帮助。
最后我要吐槽,知乎的编辑器实在烂的没法排版,只能删掉一些内容。
原文勘误与现代化说明
- 修复知乎导出导致的排版损坏:Engine/Runtime 的举例列表、任务队列/事件队列的辨析、异步任务三步中的「其他线程」说明、P.S. 补充实验等四处块引用内容丢失,已按上下文语义恢复并标注;代码示例从加粗标记污染中还原(click 示例、
inner.click()等)。 - 实测核对经典点击实验:两种触发方式的输出在当前 Chromium(Playwright 环境)重新运行——真实点击为
click → promise → mutate → click → promise → mutate → timeout → timeout;合成.click()为click → click → end → promise → mutate → promise → timeout → timeout,与原文结论一致(区别在于两次 onClick 之间执行栈是否为空);并补充了「两条 mutation record 合并投递、两次 timeout 各自排队」的解释。 - 微任务来源清单补充标准 API
queueMicrotask();Object.observe保留废弃标注。 - 「单线程」表述补充 agent/Worker 维度的现代注解;Browsing Context/Worker 两种事件循环补充「Worker 无渲染步骤」的差异说明。
- WHATWG 与 W3C 的历史分歧补充 2019 年后双方合作维护同一 Living Standard 的现状;移除已下线的 diffofhtmls.herokuapp.com 链接。
- 事件循环处理模型补充说明:渲染步骤仅在有渲染机会时执行,后台标签页可跳过(因此「每轮必有 rAF」不成立)。
- Vue 一节补充 Vue 3
nextTick(直接Promise.then)与响应式(Proxy)现状。 - 参考链接整理: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 在线示例为其原作,均已验证可用)。当前语义以本文列出的规范和官方文档为准。