setTimeout(fn, 0) 为什么不是立刻执行?Promise 的回调为什么总比它快?答案都在事件循环里。这部分内容是异步编程的地基,理解了它,很多"输出顺序"类的问题不需要死记硬背。

三个关键角色

  • 调用栈(Call Stack):同步代码在这里执行,后进先出。栈空了才会往下走。
  • 宏任务队列(Macrotask)setTimeoutsetInterval、DOM 事件、Ajax 回调、script 整体代码块。
  • 微任务队列(Microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserver

循环的规则

整个流程可以压缩成四步,按这个顺序反复执行:

  1. 执行调用栈里的同步代码(这本身就是一个宏任务)。
  2. 同步代码跑完,栈变空。
  3. 清空整个微任务队列——注意是全部执行完,包括执行过程中新产生的微任务。
  4. 取一个宏任务进入调用栈,回到第 1 步。
关键在第 3 步:微任务是"一次性清空",宏任务是"每次只取一个"。这就是 Promise 永远快于 setTimeout 的根本原因。

跑一遍例子

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve()
  .then(() => console.log('3'))
  .then(() => console.log('4'));

console.log('5');

// 输出顺序:1  5  3  4  2

逐步拆解:

  • 同步阶段依次打印 15
  • setTimeout 的回调进宏任务队列,.then 的第一个回调进微任务队列。
  • 栈空,清空微任务:打印 3,此时第二个 .then 才被追加进微任务队列,同轮继续取出,打印 4
  • 微任务清空后,取宏任务,打印 2

几个容易踩的坑

await 后面的代码相当于 then

await 会把后续语句包装成微任务。所以 async 函数里 await 之后的代码,执行时机和 .then 回调是同一档的。

微任务里别塞死循环

因为微任务会被"清空到底",如果在微任务里不断产生新的微任务,页面会彻底卡死,连渲染都没机会执行。宏任务反而不会——每一轮之间浏览器还有机会响应渲染和交互。

setTimeout 的 0 不是 0

即使延时设为 0,实际也要等当前同步代码和微任务全部跑完。而且 HTML 规范里嵌套超过 5 层的定时器,最小延时会强制提升到 4ms。

小结

记住一句话就够了:同步 → 清空所有微任务 → 取一个宏任务 → 循环。遇到复杂场景,先标出每个回调属于哪个队列,再按这个节奏推一遍,基本不会出错。

— 全文完 —