setTimeout(fn, 0) 为什么不是立刻执行?Promise 的回调为什么总比它快?答案都在事件循环里。这部分内容是异步编程的地基,理解了它,很多"输出顺序"类的问题不需要死记硬背。
三个关键角色
- 调用栈(Call Stack):同步代码在这里执行,后进先出。栈空了才会往下走。
- 宏任务队列(Macrotask):
setTimeout、setInterval、DOM 事件、Ajax 回调、script整体代码块。 - 微任务队列(Microtask):
Promise.then/catch/finally、queueMicrotask、MutationObserver。
循环的规则
整个流程可以压缩成四步,按这个顺序反复执行:
- 执行调用栈里的同步代码(这本身就是一个宏任务)。
- 同步代码跑完,栈变空。
- 清空整个微任务队列——注意是全部执行完,包括执行过程中新产生的微任务。
- 取一个宏任务进入调用栈,回到第 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
逐步拆解:
- 同步阶段依次打印 1 和 5。
setTimeout的回调进宏任务队列,.then的第一个回调进微任务队列。- 栈空,清空微任务:打印 3,此时第二个
.then才被追加进微任务队列,同轮继续取出,打印 4。 - 微任务清空后,取宏任务,打印 2。
几个容易踩的坑
await 后面的代码相当于 then
await 会把后续语句包装成微任务。所以 async 函数里 await 之后的代码,执行时机和 .then 回调是同一档的。
微任务里别塞死循环
因为微任务会被"清空到底",如果在微任务里不断产生新的微任务,页面会彻底卡死,连渲染都没机会执行。宏任务反而不会——每一轮之间浏览器还有机会响应渲染和交互。
setTimeout 的 0 不是 0
即使延时设为 0,实际也要等当前同步代码和微任务全部跑完。而且 HTML 规范里嵌套超过 5 层的定时器,最小延时会强制提升到 4ms。
小结
记住一句话就够了:同步 → 清空所有微任务 → 取一个宏任务 → 循环。遇到复杂场景,先标出每个回调属于哪个队列,再按这个节奏推一遍,基本不会出错。
— 全文完 —