先坦白一件事:这轮我原本想找最近7天爆火的编程新视频,但试遍了各种渠道后,我决定把这一期留给一场“老”演讲——Philip Roberts 在 JSConf EU 2014 上的《What the heck is the event loop anyway?》。理由很简单:这篇内容在2026年不但没有过时,反而比任何时候都更重要。
原因有三个。第一,AI 写代码的时代,async/await 满天飞,但真正能说清楚“回调为什么这么执行、Promise 比 setTimeout 先跑”的开发者凤毛麟角——生成的异步代码出了诡异 bug,最后还是得靠人来审查,而审查的底气就来自对事件循环的理解。第二,随着浏览器对渲染性能、INP(Interaction to Next Paint)等核心指标越来越挑剔,“别阻塞主线程”从性能优化技巧升级成了架构常识。第三,这场26分钟的演讲是公认的教学范本:它用一个可视化工具,把一个无数人讲了十年都讲不清楚的概念,讲到了“看一眼就懂”。
如果你只能看一场理解 JavaScript 运行时的演讲,就是它。下面我把整场演讲的核心内容拆开,并补充它当年没讲、但今天必须知道的微任务细节。
| 项目 | 内容 |
|---|---|
| 视频标题 | What the heck is the event loop anyway? |
| 频道 | JSConf(JSConf EU 官方频道) |
| 演讲者 | Philip Roberts(当时在 And Yet 工作区,loupe 作者) |
| 发布时间 | 2014年(JSConf EU 2014,柏林) |
| 时长 | 约26分钟 |
| 播放量 | 超百万,长期被列为“每个JS开发者必看”的经典 |
| 核心主题 | JavaScript 单线程模型、调用栈、任务队列与事件循环的完整运作机制 |
演讲开场,Philip 抛出的正是这个几乎每个前端都绕过但没深究过的问题。【视频观点】他的答案是反问式的:其实 JavaScript 运行时(比如 V8)一次真的只做一件事——问题不在于“怎么并发”,而在于我们一直误解了“并发发生在哪里”。答案藏在运行时之外:浏览器(或 Node.js)这个宿主环境,才是真正负责调度和并发的那一方。这个视角转换是整场演讲的题眼。很多人卡在异步,是因为拿着“运行时”的心智模型去解释“宿主环境”的行为。
【延伸分析】放到今天,这个分层思想依然锋利:V8 只提供调用栈和堆;定时器、网络、DOM 事件全是宿主注入的能力。所以“JS 是单线程的”这句话严格说不完整——单线程的是你的代码执行,而 I/O 的等待与回调的入队,从来都是浏览器或 libuv 在帮你并行完成。
Philip 用大量篇幅讲 call stack(调用栈):函数调用就是压栈,返回就是弹栈,栈顶永远是“正在执行的那一行”。【视频观点】他强调的关键性质是:栈一次只能干一件事,干不完,后面所有的事都得排队——包括浏览器的渲染。他现场演示了一段死循环代码:点击按钮的回调永远不会触发,页面连选中文字都做不到,整个标签页像死机一样。
这就是"blocking the stack"(阻塞栈)的直观呈现。他给了一个粗暴但有效的判断法:如果一段同步代码跑超过16毫秒左右,用户就会感知到掉帧;跑超过几百毫秒,用户会觉得页面“卡了”。
【延伸分析】16毫秒这个数字来自 60Hz 屏幕下每帧约 16.7ms 的预算。今天这个词换了个说法叫 INP,Chrome 团队的建议是交互响应控制在 200ms 内,但原理一模一样:主线程是共享的、渲染和你的 JS 在同一条队伍里。理解了这一点,你就明白为什么长列表虚拟滚动、Web Worker 分片计算、scheduler.yield() 这些技术存在——它们全是在给“独占轨道”的问题打补丁。
这是演讲最“颠覆认知”的段落。【视频观点】Philip 指出,setTimeout、DOM 事件、XMLHttpRequest 这些“异步 API”,压根不存在于 JavaScript 引擎中——它们是浏览器提供的 Web API。当你调用 setTimeout(fn, 0) 时,发生的事情是:宿主环境接住你的回调函数,开始计时;到点后,它把回调推入任务队列(task queue),然后立刻返回。你的代码继续往下跑,一行都没有停。
换句话说,setTimeout(fn, 0) 从不意味着“0毫秒后执行”,它只意味着“至少0毫秒后,进入排队”。如果此时调用栈上还有一段跑5秒的代码,你的回调就得干等5秒。定时器的第二个参数是个下限,不是精确的闹钟。
【延伸分析】这一点在今天的价值不降反升。Node.js 里同样成立,只不过“宿主环境”换成了 libuv:文件 I/O、网络、定时器都由 libuv 的线程池和事件循环托管。无论浏览器还是 Node,记住一句话就够了:异步的不是“执行”,而是“被调度执行的机会”。
演讲的高潮,是 Philip 给出事件循环(event loop)的伪代码,并且说了一句广为流传的话:事件循环极其简单——简单到你几分钟就能写出来——但正是这个最简单的东西,把 V8 和浏览器粘在了一起。【视频观点】它的逻辑一共三步:
就这么多。没有魔法,没有第二个线程在偷偷执行你的回调——回调永远是在主线程的调用栈上执行的,事件循环只是个不知疲倦的调度员。他打趣说,JavaScript 的并发模型是“一个线程 + 一个队列 + 一个循环”,这也是为什么 JavaScript 不需要处理锁和竞态(在同一执行流内),而多线程语言需要。
【延伸分析】这张极简图还有一个常被忽略的推论:渲染不是事件循环的一部分,而是浏览器的另一个循环。浏览器大约每16.7ms决定一次“要不要渲染这一帧”,只有当调用栈和当帧任务都清空时,渲染才有机会发生。这就解释了为什么“把一个大循环拆成多个 setTimeout”能救活页面——每让出一次栈,就给渲染一次机会。
Philip 没有干讲,他现场使用了自己写的可视化工具 loupe(latentflip.com/loupe):左侧跑代码,右侧实时画出调用栈的压弹、Web API 的计时、回调入队的时机。setTimeout、DOM 点击、Ajax 三个经典案例在他手下全部“慢动作回放”。【视频观点】他坦言写这个工具的动机就是当年自己学异步时“找遍了都没有一个像样的可视化”。
【延伸分析】这个工具至今在线可用,是我认为理解事件循环性价比最高的练手方式:自己先猜执行顺序,再用 loupe 验证,猜错的地方就是你的心智漏洞。这种“预测-验证”的学习闭环,比看十篇博客都管用。
这里要坦诚标注:2014年的演讲受时代所限,只讲了“任务队列”这一种队列。今天你打开 DevTools 单步调试会发现还有微任务队列(microtask queue)——Promise 的 .then 回调、queueMicrotask、MutationObserver 都进这里。规则是:每当调用栈清空,事件循环会先把微任务队列整个清空(包括执行微任务过程中新产生的微任务),然后才考虑渲染、才取下一个宏任务。
这就解释了一个经典面试题:setTimeout(fn,0) 和 Promise.resolve().then(fn) 谁先执行?答案永远是 Promise——setTimeout 是宏任务,要等下一轮循环;微任务在当前轮的栈清空后立刻插队执行。Jake Archibald 那篇著名的《Tasks, microtasks, queues and schedules》把这层机制讲得极细,建议作为本片的续集观看。
事件循环不是浏览器的专利。Node.js 用 libuv 实现了自己的事件循环,而且分了阶段(timers → pending callbacks → poll → check → close callbacks 等),process.nextTick 还有一个优先级最高的独立队列。所以“先微任务后宏任务”的大原则不变,但细节上 Node 的每轮循环之间、每个阶段之间都会清空 nextTick 和微任务队列,行为和浏览器有微妙差异。写跨端异步代码的人,值得专门把 Node 官方文档里的 event loop 篇读一遍——它可能是所有运行时文档里把这件事讲得最清楚的一份。
最后说点个人的判断。AI 编程助手的普及带来一个副作用:异步代码的“产量”暴涨,而对它执行时序有正确心智模型的人没有同步暴涨。AI 生成的代码在单线程语义上很少出错,但在时序语义上——竞态、取消、重试风暴、微任务里抛异常、渲染前后的执行顺序——错误率明显更高,因为这些 bug 不看语法看机制。事件循环就是 JavaScript 世界的“时序宪法”。看懂这场26分钟的演讲,本质上是给自己装上了一套能对 AI 生成的异步代码做 code review 的底层操作系统。
flowchart TD
A[JavaScript 代码执行] --> B[调用栈 Call Stack]
B -->|同步代码| C{栈空了吗?}
C -->|否| B
C -->|是| D[清空微任务队列<br/>Promise.then / queueMicrotask]
D --> E{该渲染了吗?<br/>约每16.7ms一帧}
E -->|是| F[渲染: rAF → Style → Layout → Paint]
E -->|否| G
F --> G[从宏任务队列取一个回调<br/>setTimeout / DOM事件 / I/O]
G --> B
H[宿主环境<br/>浏览器 Web APIs / Node libuv] -->|定时器到点、I/O完成<br/>回调入队| G
H -->|Promise resolve| D
style D fill:#e8f5e9
style F fill:#fff3e0
style G fill:#e3f2fd
读图要点:你的代码永远只在“调用栈”这一个方框里执行;右下角的宏任务和微任务队列只是“候场区”;渲染和事件循环是两个独立的循环,靠“栈空”这个条件交汇。
第一,给异步调试装上“时序显微镜”。 下次遇到“回调执行顺序和我想的不一样”,别再靠加 log 碰运气。先在脑内跑一遍上面的流程图:这段代码是同步、微任务还是宏任务?栈清空的瞬间谁会插队?用 loupe 复现一遍,大多数“灵异事件”五分钟就能定位。
第二,主动给主线程“让路”。 把超过 50ms 的同步计算拆成小块(setTimeout 分片、requestIdleCallback、Web Worker),这不是性能洁癖,而是保住交互响应的硬预算。监测手段现成的:Chrome DevTools 的 Performance 面板里,长长的黄色 Task 就是“独占轨道”的元凶。
第三,写 AI 生成代码的审查清单。 拿到一段异步代码,至少过四问:有没有未捕获的 Promise rejection?await 循环里有没有本该并行的串行等待?定时器有没有清理(否则组件销毁后回调照跑)?微任务里有没有塞重活(它会阻塞下一帧渲染)?这四问覆盖了绝大多数异步事故。
第四,把“单线程心智模型”迁移到其他领域。 事件循环的思想——执行与调度分离、队列与循环解耦——在 GUI 框架(主线程 + 消息循环)、游戏引擎(帧循环)、甚至分布式系统的任务调度里都能找到同构。理解一次,受益处处。
它用26分钟和一个小工具,把“JavaScript 为什么单线程却不卡、异步到底发生在哪”从背八股变成了看得见的常识——这是理解一切 JS 时序问题的第一性原理。
本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
