如果你写 JavaScript 超过一个月,大概率遇到过这些"灵异现象":setTimeout(fn, 0) 的回调并不是立刻执行;一个 for 循环里塞十个 setTimeout,最后打印出来的全是 10;页面一卡,鼠标都不动了,但代码里你明明没写死循环。
这些谜题,根源都指向同一个东西——事件循环(Event Loop)。它是 JavaScript 单线程模型下"假装能并发"的全部秘密。
2014 年 JSConf EU 上,开发者 Philip Roberts 上台做了 26 分钟的演讲《What the heck is the event loop anyway?》。没有 PPT 堆砌的术语,他当场打开自己写的小工具 loupe,把一段段代码的执行轨迹可视化成"调用栈—Web API—回调队列"三块跳动的方块。这场演讲至今累计播放量超过 500 万次,几乎是每一个前端工程师的"入门第一课",也是无数次面试中"说说 JS 的运行机制"的标准答案模板。
它为什么值得反复看?因为它回答的不是某个 API 怎么用,而是JavaScript 世界运行的最底层法则——理解了它,异步、回调、Promise、async/await、渲染卡顿、Node 的事件循环全都能串成一条线。这篇解读,我会在原视频框架之上,补上它当年没讲透(或当时还不存在)的部分:微任务队列、requestAnimationFrame、Node.js 与浏览器的循环差异、Web Workers 如何真正并行。
| 项目 | 内容 |
|---|---|
| 视频标题 | What the heck is the event loop anyway? |
| 演讲者 | Philip Roberts(@philip_roberts) |
| 频道 / 大会 | JSConf EU |
| 时长 | 约 26 分钟 |
| 播放量 | 500 万+ |
| 核心主题 | JavaScript 单线程运行时模型:调用栈、Web API、回调队列、事件循环 |
| 配套工具 | loupe(latentflip.com/loupe,开源可视化调试器) |
Roberts 开场就抛出一个反直觉的事实:JavaScript 运行时只有一根调用栈(call stack)。这意味着同一时刻,JS 只能干一件事。你在浏览器里点按钮、拖动、发请求、跑动画,背后的脚本全都得排队,前一个没干完,后一个就得等。
为什么要设计成单线程?这是 1995 年 Brendan Eich 用十天为 Netscape 仓促发明的语言的历史包袱,但更深层的原因是安全与简单:浏览器里最核心的东西是 DOM,如果允许多个线程同时操作 DOM,就得引入锁、互斥量、死锁——前端开发的复杂度会爆炸。单线程意味着"不存在数据竞争",UI 状态天然是线程安全的。
代价是致命的:任何一段慢代码,都会冻住整个页面。Roberts 当场演示了一个 while 死循环模拟的"假阻塞",500ms 内标签页完全无响应,鼠标点击、滚动、动画全停。这就是"阻塞主线程"的可怕之处——它不是"变慢",而是"冻结"。
【视频原话/演示】他指着屏幕说:如果栈里有个东西要跑很久,浏览器就什么都做不了,包括重绘页面。
调用栈是一个后进先出(LIFO)的数据结构,记录"代码执行到哪一层函数了"。Roberts 用最朴素的例子拆解:
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }
printSquare(4);
执行时,栈从下往上依次压入 printSquare → square → multiply,multiply 返回后弹出,再弹出 square……直到栈清空。栈顶永远是"当前正在执行的那行代码"。
理解这一点后,报错信息里的 stack trace(调用栈回溯)就不再神秘——它就是程序崩溃那一瞬间调用栈的快照。RangeError: Maximum call stack exceeded(栈溢出)的本质,是递归没有正确终止,栈一路压到底层分配的内存上限。
【我的延伸】这层"栈"是 V8、SpiderMonkey、JavaScriptCore 等引擎提供的,但调用栈本身不是 JS 独有——C、Java 都有。JS 真正特别的地方在于:它只有一个栈,而且不提供创建新栈的语言级原语(想并行只能靠 Web Workers,那是另一回事)。
这是整场演讲最关键的认知转折。很多人以为 setTimeout、fetch、DOM 事件是 JavaScript 的功能——不是。它们是浏览器(宿主环境)以 C++ 实现的 Web API,JS 引擎只是"借用"。
当你写下:
console.log('hi');
setTimeout(function cb() { console.log('there'); }, 5000);
console.log('JSConf');
真正的执行过程是:console.log('hi') 入栈、执行、出栈;setTimeout 入栈,引擎把它连同回调函数 cb 一起交给浏览器的定时器 Web API,然后立刻出栈——它本身瞬间就执行完了;console.log('JSConf') 入栈、执行。5 秒后,定时器触发,cb 被推进回调队列(Callback Queue),等待栈清空后被搬上栈执行。
【视频原话】Roberts 反复强调:
setTimeout的第二个参数不是"延迟执行回调",而是"至少等这么久,把回调放进队列"。这是无数 bug 的源头。
回调队列是一个先进先出(FIFO)的结构,存放着"已经就绪、等待执行"的回调函数。而事件循环本身,就是一个死循环,它的工作用一句伪代码就能说完:
while (true) {
if (调用栈为空 && 回调队列非空) {
把队列里的第一个回调压入调用栈,执行它
}
}
就这一条规则。事件循环永远在问两个问题:栈空了吗?队列里有货吗?两个条件同时满足,它就搬一个上去。
Roberts 用 loupe 把这个过程可视化为三块区域的联动——栈、Web API、队列之间,回调像传送带上的零件一样流动。观众第一次直观看到:setTimeout(fn, 0) 之所以"不立刻执行",是因为 fn 要先进队列,而栈上还有同步代码没跑完,事件循环不会插队。
理解了上面的模型,那个经典的"循环里全是 10"的面试题就迎刃而解:
for (var i = 1; i <= 3; i++) {
setTimeout(function () { console.log(i); }, 0);
}
// 输出:4, 4, 4(不是 1, 2, 3)
因为 setTimeout 把三个回调都丢进了 Web API,而循环本身是同步的、瞬间跑完的。等回调真正上栈执行时,i 早已经是循环结束后的 4。var 没有块级作用域,三个闭包共享同一个 i。换成 let(ES6 块级作用域),或用 IIFE 立即捕获,问题就消失。
更重要的洞见:setTimeout(fn, 1000) 的语义是"至少 1000ms 后",不是"恰好 1000ms 后"。如果 1000ms 到了,但栈上正跑着一个耗时的同步任务(比如一个 3000ms 的计算),回调只能继续在队列里干等。延迟会被无限拉长。
【我的延伸】这正是 Chrome 开发者工具里
setTimeout那一栏标红的 "reason: ..." 的来历——它诚实地告诉你这个回调为什么晚了。
Roberts 演讲后半段揭示了一个常被忽略的事实:浏览器的"渲染"(重绘/回流)和 JS 回调一样,也要等栈清空。浏览器大约每 16.6ms 想重绘一次(对应 60fps),但如果主线程被一段慢代码占着,渲染请求就只能排队,用户看到的就是"卡顿、掉帧、拖影"。
他演示了同步处理图片(一次性遍历上百万像素)会让页面冻死,而用 setTimeout 把任务切成小块、每块跑完把控制权还给事件循环,页面就能保持流畅响应——这是后来 requestIdleCallback、时间切片(Time Slicing)乃至 React 的 Concurrent 模式(Fiber 架构)的思想源头。
【我的延伸】Roberts 没讲的:现代浏览器有一个独立的 微任务队列(Microtask Queue),优先级高于回调队列(宏任务)。
Promise.then、MutationObserver、queueMicrotask的回调都进这里。事件循环在每次宏任务执行完后、下一次渲染前,会把微任务队列彻底清空才算完。所以:
console.log('1');
setTimeout(() => console.log('2'));
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1, 4, 3, 2
3 永远在 2 前面,因为微任务抢在宏任务之前执行。这是 Roberts 2014 年演讲时 Promise 尚未标准化、所以没覆盖,却是今天面试必考的点。
flowchart TD
A[JavaScript 代码] --> B[调用栈 Call Stack<br/>单线程 · LIFO · 唯一工作台]
B -->|遇到异步 API| C[Web API<br/>浏览器 C++ 实现<br/>setTimeout / fetch / DOM]
C -->|定时器到 / 请求回 / 事件触发| D{就绪的回调}
D -->|Promise.then / queueMicrotask| E[微任务队列<br/>高优先级 · 每轮清空]
D -->|setTimeout / 事件回调| F[宏任务队列<br/>Callback Queue · FIFO]
E --> G[事件循环 Event Loop]
F --> G
G -->|栈空时搬一个回调上栈| B
G -->|栈空且队列空时| H[浏览器渲染 Render<br/>每 ~16.6ms · 60fps]
H --> B
style B fill:#ffe0b2
style G fill:#c8e6c9
style E fill:#bbdefb
这张图把"一次完整的循环"画清了:同步代码上栈执行 → 遇到异步就外包给 Web API → 就绪的回调分别进入微任务/宏任务队列 → 事件循环在栈空时先掏空微任务,再取一个宏任务,间隙里插一次渲染。理解这条流水线,你就理解了浏览器前端的全部时间观。
看完这个模型,怎么落到日常工程里?我总结了四条能立刻用的原则:
第一,永远别在主线程跑重活。 任何可能超过 50ms 的同步计算(解析大 JSON、复杂图表布局、加密哈希)都会触发"长任务(Long Task)",造成掉帧。把大数据切片处理,用 setTimeout 或 requestIdleCallback 让出主线程;计算密集型任务直接丢给 Web Worker——它是 JS 里获得"真正的另一根栈、真正的并行"的唯一合法途径,通过 postMessage 通信,不会抢 DOM。
第二,分清微任务和宏任务的优先级。 写"任务调度器"或"批量更新"逻辑时,queueMicrotask 会饿死渲染(因为它在每次宏任务后清空,可能无限堆积),而 setTimeout(0) 更安全地让出渲染机会。React 的批量更新机制就深谙此道。
第三,setTimeout(fn, 0) 是"让出控制权"的惯用法,不是"立刻执行"。 别指望它精确计时;需要精确动画用 requestAnimationFrame(与渲染同步),需要去抖用 setTimeout/requestIdleCallback。
第四,理解了单线程,才懂为什么 async/await 不是银弹。 它让异步代码"读起来像同步",但底下的 Promise 链依然跑在单线程上——一个 await 后面跟着 CPU 密集计算,照样卡死主线程。并发 ≠ 并行:Promise.all 是并发地"等待多个 I/O",不是并行地"同时计算"。
认知升级的核心只有一句:JavaScript 的"异步",本质是"延迟 + 回调队列 + 一个不知疲倦的循环",单线程从未改变。
setImmediate)等多个 phase,是后端面试的高频考点。这场 26 分钟的演讲用一个"栈—Web API—队列—循环"的四方模型,讲透了 JavaScript 单线程却"看起来并发"的全部秘密——它是你理解一切前端异步、卡顿、调度的地基。
本文由「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 等场景全覆盖。
