Menu

JavaScript 事件循环详解:异步代码到底是怎样运行的

理解异步 JavaScript 的思维模型:调用栈、任务队列、微任务队列,以及事件循环如何把它们串在一起。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

单线程,但不按顺序执行

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) 时,会发生以下事情:

  1. 定时器在宿主环境中注册。
  2. setTimeout 立即返回,栈继续运行。
  3. 100 毫秒后,宿主环境把 fn 放进一个队列。
  4. 当栈为空时,事件循环从队列中取出 fn 并执行。

定时器的回调要等 for 循环和 console.log("end") 执行完才能运行,因为栈还没有空。定时器给出的是最短延迟,而不是保证。

两个队列:任务和微任务

队列不止一个,而是两个,这个区别能解释大部分让人意外的事件循环行为。

  • 任务队列(有时也叫宏任务队列):setTimeout、setInterval、I/O 回调、UI 事件。
  • 微任务队列:promise 回调(.then、.catch、.finally)、await 之后的后续代码,以及用 queueMicrotask 安排的任何内容。

事件循环遵循的规则是:

  1. 从任务队列中执行一个任务。
  2. 清空整个微任务队列:执行每一个微任务,包括清空过程中新安排的微任务。
  3. 如有需要,进行渲染(在浏览器中)。
  4. 回到第 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. 1: sync:在栈上运行。
  2. 6: sync:仍然在栈上。
  3. 栈清空,开始清空微任务队列:3: promise、5: microtask,然后是 4: nested microtask(它在清空过程中被安排,依然会被取出执行)。
  4. 执行下一个任务: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 只是下限,而不是承诺。

Coddy 编程语言插图

和 Coddy 一起学编程

开始