首页 › programming › 卡顿从哪来:事件循环神课深读

卡顿从哪来:一场把 JavaScript 讲透的传奇演讲深度解读

概述

在 JavaScript 的学习路径上,有一个几乎人人绕不过去的名字:Philip Roberts 在 JSConf EU 2014 上的演讲 《What the heck is the event loop anyway?》(事件循环到底是什么鬼?)。这场不到 30 分钟的演讲,用一页著名的示意图和几行伪代码,把“单线程的 JavaScript 为什么能处理异步”这道几乎所有前端工程师都似懂非懂的问题,讲成了十年后仍在被反复引用的经典。本文以这场演讲为唯一对象,完整还原它的论证脉络、背后的工程与规范溯源,并用今天(2026 年)的眼光审视哪些结论依然坚挺、哪些需要修正——让你无论是否看过原视频,都能把“事件循环”这个概念真正装进自己的认知框架里。

一、视频基本信息

项目 内容
视频标题 What the heck is the event loop anyway?
中文译名 事件循环到底是个什么东西?
演讲者 Philip Roberts(时任 And Yet 工程师,Loupe 可视化工具作者)
频道 / 活动 JSConf(欧洲 JavaScript 大会 JSConf EU 2014 柏林站官方频道)
发布时间 2014 年(演讲年份;据 JSConf 官方频道)
热度 长期位居该主题全球播放量前列,累计播放达数百万量级(本次写作环境无法联网,未能实时核实最新数字,以频道页面为准)
时长 约 26 分钟

说明:本文写作环境无法访问网络,以上信息来自演讲与频道的公开资料,未做实时二次核实。文中不引用未经核实的精确数字。

二、核心主张拆解:演讲到底讲了什么

这场演讲的结构堪称技术演讲的范本:从一个让全场发笑的问题开场,逐层拆解,最后用一个工具收束全场。我们按叙事顺序完整还原它的五步论证。

2.1 开场之问:“你们真的懂 JavaScript 吗?”

Philip 开场抛出的挑衅式问题是:我们都听说过“JavaScript 是单线程的”“回调、setTimeout 是异步的”“Node.js 非阻塞无阻塞 IO”这些说法,但如果你问他“事件循环是什么”,很多人只能含糊地比划。他给出的判断是:理解事件循环,是理解 JavaScript 运行时行为的分水岭——不懂它,你写的每一行异步代码都在“碰运气”。

这个开场策略很高明:它没有从 API 或语法入手,而是直指一个心智模型(mental model)的缺失。绝大多数开发者会用异步代码,但解释不了“浏览器是如何在只有一个线程的情况下,同时监听点击、发起网络请求、又执行回调的”。

2.2 调用栈:程序的心电图

演讲的第一个技术支点是调用栈(Call Stack)。Philip 把它形容为 JavaScript 引擎里“记录我们执行到哪了”的数据结构:每调用一个函数,就压入(push)一个栈帧;函数返回,就弹出(pop)。

这里他演示了两个经典片段:

关键结论随之而来:JavaScript 的调用栈一次只能运行一个函数,因为栈是串行的。“单线程”这个词在演讲里被精确定义为“一个调用栈”——不是玄学,而是一个数据结构的物理约束。

2.3 “阻塞”到底阻塞了什么:掉帧的真相

接着 Philip 引入全场最重要的一段推理:既然只有一个栈,那么当栈里有一个耗时的函数在跑时,其他任何事都做不了。

他举的场景是图片解码、网络请求被同步执行、或者一个笨重的循环——如果这些代码在浏览器主线程上同步运行 300 毫秒,那么这 300 毫秒里:渲染不能发生、点击无法响应、动画停摆。这就是用户感知到的“卡死”。

演讲在此给出了一个量化锚点:显示器通常以 60fps 刷新,意味着浏览器每帧只有约 16.7 毫秒的预算去完成“跑 JS → 计算样式 → 布局 → 绘制”的全部工作。任何一段超过这个预算的同步代码,都会直接表现为掉帧。这个数字在后来的 Web 性能工程里被 Google 的 RAIL 模型正式化(响应 100ms 内、动画帧 16ms 内,参见 web.dev 的 RAIL 文档)。

2.4 浏览器不止 JavaScript:WebAPIs 与任务队列

然后是全场最著名的转折:那些“异步”的东西,根本不在 JavaScript 引擎里。

Philip 指出,浏览器提供的 setTimeout、DOM 事件、XMLHttpRequest(以及今天的 fetch),这些能力属于浏览器 WebAPIs——它们由浏览器实现,调用时浏览器接管任务,JavaScript 调用栈继续往下走。等浏览器那边的事情完成(定时器到点、请求返回、事件触发),回调函数并不会直接冲进调用栈,而是先被放进任务队列(Task Queue,演讲中称 callback queue)排队。

2.5 事件循环:真正的主角只有几行代码

终于到了标题里的主角。Philip 给出的定义至今仍被无数次引用,它的伪代码大致是:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

翻译成人话:事件循环就是当调用栈为空时,把任务队列里的第一个回调取出来,压到栈上执行的守门人。它没有魔法,就是一个永不停歇的 while 循环。

由此他给出了那个改变无数人认知的结论:setTimeout(fn, 0) 并不意味着“0 毫秒后执行”。它只承诺“至少 0 毫秒后,回调会进入任务队列”;至于它什么时候真正执行,完全取决于调用栈什么时候清空。如果栈上有一段跑 5 秒的代码,你的 setTimeout(fn, 0) 就会迟到 5 秒。演讲现场他用一段代码当场验证了这一点,全场安静的那几秒正是这个演讲名场面。

2.6 收束工具:Loupe,让异步看得见

演讲的最后,Philip 演示了他为这场演讲构建的工具 Loupe(可在 latentflip.com/loupe 直接使用,代码开源):把任意 JS 代码粘贴进去,它会逐步可视化“调用栈压入弹出 → WebAPI 注册 → 回调入队 → 事件循环搬运”的全过程,还能插入 console.log 观察输出顺序。这套可视化后来成为大量讲师复用的教学素材——你不是在“背”事件循环,而是能亲眼看一遍它。

三、知识与理论溯源:这些说法都从哪来

这场演讲不是发明了事件循环,而是把散落在规范、引擎和操作系统里的既有事实第一次串成了普通人能吸收的叙事。它的每一个支点都有真实出处。

调用栈与运行时模型:来自 ECMAScript 规范定义的执行上下文机制(execution context),MDN 的《Concurrency model and the event loop》文档对其有系统总结:JavaScript 拥有一个调用栈、一个堆,以及由运行时环境(浏览器/Node)提供的队列。这是“单线程”表述的规范学根源。

WebAPIs 与事件循环本身:浏览器行为由 WHATWG HTML Living Standard 的 Event Loops 章节规定——注意是复数,规范明确每个浏览器可以(在某些条件下必须有,如 Web Worker)多个事件循环,并定义了 task queue 与后续标准化的 microtask queue。Philip 演讲中的简化模型与规范条款高度对应,只是做了教学裁剪。

setTimeout 的最小延迟条款:HTML 规范规定,嵌套超过 5 层的 setTimeout,最小延迟被钳制(clamp)为 4ms——这是“定时器精度不可信”的规范学证据,比演讲更进一步。

Node.js 一侧的对应物:Ryan Dahl 在 2009 年创建 Node.js 时,把这套“非阻塞 + 事件循环”的模型搬到了服务端,底层依赖 libuv 库实现事件循环与异步 IO(libuv 起源于 Node.js 项目,后来成为独立的跨平台异步 IO 库)。Node 官方文档《The Node.js Event Loop, Timers, and process.nextTick()》明确描述了其事件循环的六个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks),与浏览器模型同源但细节不同。

帧预算与性能工程:演讲中“16.7ms”的直觉判断,后来被 Google 在 RAIL 性能模型中正式化,并衍生出 Long Tasks API(探测超过 50ms 的主线程长任务)与 web.dev 上一系列“拆分长任务”的工程指南。

微任务的补全:演讲时代的观众还看不到完整图景——Promise 直到 ES2015 才被标准化,随之而来的是比 task queue 优先级更高的微任务队列(microtask queue)。把这块拼图补全的标志性演讲是 Chrome 团队 Jake Archibald 2018 年的《In The Loop》(JSConf.Asia):微任务在每个宏任务执行完毕后立即整体清空,然后才可能轮到渲染。两场演讲相隔四年,合起来才是 2026 年完整的事件循环知识体系。

四、批判性审查:哪些站得住,哪些要修正

证据充分、至今坚挺的部分:

  1. 单调用栈模型与阻塞成因。主线程一次跑一段代码、长任务阻塞渲染,这在 2026 年的浏览器里依然完全成立,是 web.dev、Chrome DevTools 文档反复强调的第一性能定律。
  2. setTimeout(fn, 0) 的语义。“至少延迟,不保证准时”不仅成立,规范还加了 4ms 嵌套钳制,实际工程中定时器精度还要叠加页面后台化时的节流(浏览器会把后台标签页的定时器节流到每秒 1 次左右)。
  3. “事件循环是简单循环”的教学模型。作为心智模型它极度有效,Loupe 的可视化至今仍是讲解该主题的标准工具。

需要修正或补充的部分:

  1. “JavaScript 是单线程”这句话在今天要精确理解。自 ES2017 引入 async/await、浏览器普遍支持 Web Workers、Node 提供 worker_threads、SharedArrayBuffer 与 Atomics 允许真正的内存共享并发之后,“语言单线程”的说法已不准确——准确表述是:默认的 JS 执行环境(主线程)是一个事件循环驱动的单线程,并行能力由宿主环境的额外线程提供。演讲完成于 2014 年,彼时这样讲没有问题,今天直接复述就要加限定词。
  2. 演讲简化掉了微任务队列。这不能苛责(Promise 尚未标准化),但今天若只按演讲的“一个队列”模型推理 Promise.then 与 setTimeout 的顺序,会得出错误结论。正确顺序是:同步代码 → 清空全部微任务 → (必要时渲染)→ 取一个宏任务 → 再清空微任务,如此往复。
  3. Node 与浏览器的事件循环并不完全一样。演讲以浏览器为语境,若把模型原样套到 Node 上会踩坑:Node 的循环有明确阶段划分,process.nextTick 队列优先级甚至高于 Promise 微任务;且 Node 11(2018 年)曾专门调整微任务的执行时机以向浏览器对齐——这类“运行时差异”恰恰说明把教学模型当生产模型是有代价的。
  4. “渲染每帧 16.7ms”是 60Hz 显示器的特例。2026 年高刷新率屏幕(120Hz、144Hz、240Hz)普及,帧预算相应缩短到 8.3ms 甚至更少,"16.7ms"应理解为“按设备刷新率计算的帧预算”这一公式,而非固定常数。

总体评价:这是一篇证据密度极高、几乎零事实错误的演讲,它被引用十余年不是情怀,而是因为它的简化模型在“教学正确性”与“工程可用性”之间取到了罕见的平衡。需要修正的四点全部属于“2014 年之后世界变了”的范畴,而非演讲本身的疏漏。

五、认知提升与实用启示:三条可以立刻执行的建议

第一,用“帧预算”重估你的每一行同步代码。 写代码时默认这样一个预算表:以你的目标设备刷新率去除 1000ms,得到每帧预算(60Hz 约 16.7ms,120Hz 约 8.3ms)。任何预估超过 50ms 的同步逻辑(大数组循环、同步 JSON 解析、大量 DOM 操作)都应视为“会卡”的代码。落地手段:把长循环切片,用 setTimeout/MessageChannel/scheduler.yield()(较新的浏览器已支持)把控制权交还事件循环,让出间隙给渲染与输入响应。

第二,把“异步”和“并行”在脑中彻底分开。 这场演讲最大的认知红利就在于此:setTimeout、Promise、事件回调全部是排队机制,不是并行机制——排队不省 CPU,只是换一个执行时机。真正并行的是 Web Workers / worker_threads。当你需要“不卡主线程的计算”(如大文件哈希、图像处理),答案不是更花哨的异步写法,而是把工作移出主线程;当你需要“延后但不占资源”,才是异步回调。先用这个二分法定性需求,再选工具,能避开大量“为什么还是卡”的无效调优。

第三,用工具建立直觉,再回到真实代码验证。 花二十分钟在 Loupe(latentflip.com/loupe)里跑三段代码:一段 setTimeout(fn,0) 排队实验、一段嵌套 Promise 与 setTimeout 的混合输出、一段故意死循环阻塞栈的实验——把输出顺序亲手“看”一遍。然后打开 Chrome DevTools 的 Performance 面板录制一次真实页面交互,找到主线程上超过 50ms 的黄色长任务,对照理解“掉帧”和演讲中“栈被占用”的对应关系。概念只有在两个工具之间往返一次,才会变成可迁移的直觉。

六、论证结构图

flowchart TD
    A["开场之问<br/>你真的懂事件循环吗"] --> B["调用栈 Call Stack<br/>一次只跑一个函数"]
    B --> C["阻塞的代价<br/>栈被占用 → 渲染停摆/掉帧<br/>帧预算约16.7ms(60Hz)"]
    C --> D["WebAPIs<br/>setTimeout / DOM事件 / 网络请求<br/>在引擎之外执行"]
    D --> E["任务队列 Task Queue<br/>完成的回调在此排队"]
    E --> F["事件循环 Event Loop<br/>栈空?→取队首回调压栈<br/>一个while循环而已"]
    F --> G{"推论"}
    G --> H["setTimeout(fn,0)<br/>≠ 立即执行<br/>只保证'至少0ms后入队'"]
    G --> I["同步长代码<br/>会让一切回调迟到"]
    H --> J["工具收束<br/>Loupe 可视化验证"]
    I --> J
    J -.2014年后的补充.-> K["微任务队列<br/>Promise → 每个宏任务后整体清空"]
    J -.2014年后的补充.-> L["真正的并行<br/>Web Workers / worker_threads"]

结语

一场 2014 年的演讲能在 2026 年仍被反复提及,靠的不是流行度,而是它击中了一个长久没有好教材的断层:从“会写异步代码”到“理解异步为何这样运行”之间的鸿沟。Philip Roberts 的贡献在于,他把规范条文翻译成了一个只有四个组件的模型——栈、WebAPIs、队列、循环——小到可以装进一张示意图,准到可以预测 setTimeout 与 console.log 的输出顺序。如果你只打算深读一个 JavaScript 主题的视频,它仍然是这个物种里的标杆;读完本文后配合 Loupe 做一次亲手实验,你对“卡顿从哪来”的理解,将超过绝大多数只会背八股的同侪。

延伸阅读

  1. Jake Archibald —《In The Loop》(JSConf.Asia 2018,YouTube 可搜)— 本文的官方续篇:微任务队列、渲染时机、task 与 microtask 的完整执行顺序,看完本文后必看。
  2. MDN 文档 —《Concurrency model and the event loop》— 事件循环的规范级摘要,适合作为案头手册随时查阅。

参考来源

  1. What the heck is the event loop anyway? — Philip Roberts, JSConf EU 2014 — 本文解读的原视频,JSConf 官方频道。
  2. MDN: Concurrency model and the event loop — 调用栈、堆、队列模型的规范级说明。
  3. WHATWG HTML Living Standard — Event Loops — task queue 与事件循环的权威规范定义。
  4. Loupe — Philip Roberts 的异步可视化工具 — 演讲现场演示的教学工具,可在线运行。
  5. Jake Archibald: In The Loop — JSConf.Asia 2018 — 微任务队列的权威补充演讲。

本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。