单线程,但不按顺序执行
JavaScript 运行在一个线程上。只有一个调用栈,任何时刻都只有一个函数在执行。在同一个 realm 里,你的代码永远不会有两行并行运行。
这听起来很受限,但别忘了 JavaScript 实际在做什么:获取数据、等待用户点击、读取文件。大部分“工作”其实都是在等待。事件循环就是让等待变得廉价的诀窍:你的代码把任务交给浏览器或 Node,转身去做别的事,等结果准备好时再收到通知。
first 和 second 按顺序打印。third 在之后才打印,尽管定时器的延迟是 0。这段间隔就是事件循环在起作用,而弄懂其中的原因正是本页的目的。
调用栈
每次函数调用都会把一个帧压入调用栈。函数返回时,它的帧就被弹出。调用栈就是一个普通的栈:后进先出。
outer() 运行时,Node 先压入 outer,再压入 inner,inner 返回 "done" 时把它弹出,然后弹出 outer。栈又空了。事件循环等待的正是这个“栈为空”的时刻。
同步代码在栈上从头运行到尾,任何异步操作都无法打断它。如果你写了一个 while (true) 循环,栈永远不会清空,页面就会卡死:点击没反应,定时器不触发,promise 回调也不执行。事件循环无事可做,因为它根本轮不到。
异步工作实际在哪里进行
JavaScript 本身并不知道怎样发起网络请求或等待 100 毫秒。这些 API 属于宿主环境,也就是浏览器或 Node。当你调用 setTimeout(fn, 100) 时,会发生以下事情:
- 定时器在宿主环境中注册。
setTimeout立即返回,栈继续运行。- 100 毫秒后,宿主环境把
fn放进一个队列。 - 当栈为空时,事件循环从队列中取出
fn并执行。
定时器的回调要等 for 循环和 console.log("end") 执行完才能运行,因为栈还没有空。定时器给出的是最短延迟,而不是保证。
两个队列:任务和微任务
队列不止一个,而是两个,这个区别能解释大部分让人意外的事件循环行为。
- 任务队列(有时也叫宏任务队列):
setTimeout、setInterval、I/O 回调、UI 事件。 - 微任务队列:promise 回调(
.then、.catch、.finally)、await之后的后续代码,以及用queueMicrotask安排的任何内容。
事件循环遵循的规则是:
- 从任务队列中执行一个任务。
- 清空整个微任务队列:执行每一个微任务,包括清空过程中新安排的微任务。
- 如有需要,进行渲染(在浏览器中)。
- 回到第 1 步。
微任务总是在下一个任务之前执行。这就是下面这段代码让人意外的原因:
输出顺序:sync 1、sync 2、promise、timeout。同步代码先运行,然后栈清空,接着事件循环清空微任务(promise),最后才取出定时器任务(timeout)。
微任务会让任务“饿死”
因为微任务队列会在下一个任务之前被完全清空,一个不断安排新微任务的微任务会让任务队列永远得不到执行:
定时器永远不会触发,因为每个微任务都会再放入一个微任务,队列永远清不空。promise 链是安全的,因为每个 .then 只安排一个后续处理;但手写的微任务循环是个值得了解的坑。
await 是微任务的语法糖
当你 await 一个 promise 时,函数会暂停,剩余部分被安排成一个微任务,在 promise 落定后执行。这没有什么魔法,底层就是 .then。
输出:A、C、B。await 把控制权交还给调用者。console.log("C") 在当前栈上运行。然后微任务队列被清空,demo 的剩余部分继续执行,打印出 B。
阅读异步代码时要记住这一点:await 不会阻塞,它是在让出控制权。
完整示例:给所有操作排序
把所有部分放在一起:
顺序:
1: sync:在栈上运行。6: sync:仍然在栈上。- 栈清空,开始清空微任务队列:
3: promise、5: microtask,然后是4: nested microtask(它在清空过程中被安排,依然会被取出执行)。 - 执行下一个任务:
2: timeout。
最终输出:1, 6, 3, 5, 4, 2。如果你能推出这个结果,就说明你理解了事件循环。
Node 有更多阶段
Node 的事件循环是浏览器模型的超集。它有几个不同的阶段:timers、pending I/O callbacks、poll、check、close,并且在每个阶段之间都会清空微任务。setImmediate 在 check 阶段运行,process.nextTick 则在普通微任务之前运行(它有自己的、优先级更高的队列)。
你不需要一开始就记住这张阶段图。关键结论和浏览器中一样:同步代码先运行完毕,然后清空微任务,接着事件循环再取出下一个排队的回调。
为什么这很重要
一旦理解了这个模型,很多异步代码就不再神秘:
- 一个很长的
for循环会让 UI 卡住,因为事件循环轮不到执行。 setTimeout(fn, 0)可以把工作推迟到当前任务和微任务都完成之后。- 即使 promise 已经 resolve,它的
.then回调看似“立即”执行,实际上仍要等当前同步代码结束。 - 在循环里使用
await会让工作串行执行,因为每次迭代在继续之前都会让出给微任务队列。
调试异步代码,基本上就是在问:“现在栈上有什么,队列里又排着什么?”事件循环就是答案。
下一步:回调
在 promise 和 async/await 出现之前,JavaScript 处理异步工作的唯一工具就是回调:一个交给 API、让它稍后调用的函数。回调至今仍随处可见(事件监听器、Node 的核心 API),理解它们是本章其他内容的基础。
常见问题
JavaScript 中的事件循环是什么?
它是让单线程的 JavaScript 能在不阻塞的情况下执行异步工作的机制。事件循环一直盯着调用栈:栈一空,它就取出下一个排队的回调并执行。定时器、I/O 和 promise 的后续处理最终都会进入队列,由事件循环一次取一个地清空。
为什么 JavaScript 是单线程的?
语言规范为每个 realm 只定义了一个调用栈,所以你的代码运行在单个线程上。并发来自宿主环境(浏览器或 Node):它把工作交给后台 API(定时器、网络、文件 I/O),完成后再把回调放进队列。在同一个上下文里,你永远不会看到两段 JS 同时运行。
微任务和宏任务有什么区别?
微任务来自 promise(.then、await)和 queueMicrotask。宏任务来自 setTimeout、setInterval、I/O 和 UI 事件。每个宏任务执行完后,事件循环会先清空整个微任务队列,再执行下一个宏任务。所以在同一时刻安排的 Promise.resolve().then(...) 总是先于 setTimeout(..., 0) 执行。
为什么延迟 0 毫秒的 setTimeout 不会立即执行?
setTimeout(fn, 0) 的意思不是“马上执行”,而是“把 fn 作为宏任务放进队列,最早在 0 毫秒后执行”。当前的同步代码必须先执行完,微任务队列必须先清空,之后事件循环才会取出你的定时器回调。所以 0 只是下限,而不是承诺。