以下是基于最新浏览器规范(WHATWG/W3C)对事件循环机制的深度解析,帮助你彻底理清执行顺序。
1. 核心结论:微任务优先于宏任务
在浏览器事件循环中,执行顺序遵循以下严格规则:
- 执行当前宏任务(通常是整个
<script>标签内的同步代码)。 - 清空微任务队列:执行所有当前可用的微任务(Promise.then, queueMicrotask, MutationObserver 等),直到队列为空。
- UI 渲染(如果需要):浏览器进行样式计算、布局和绘制。
- 执行下一个宏任务:从宏任务队列(如 setTimeout, setInterval, I/O, UI 交互)中取出一个任务执行。
- 重复步骤 2-4。
因此,setTimeout 作为宏任务,永远会在当前同步代码和所有微任务执行完毕之后,且在下一轮事件循环中才会执行。
2. 为什么会有“宏任务先执行”的错觉?
这种误解通常源于以下两种情况:
情况一:混淆了“主脚本”与“setTimeout”
整个 <script> 标签内的同步代码本身就是一个宏任务(称为“主任务”或“Initial Script”)。
- 当你运行代码时,首先执行的是这个“主宏任务”。
- 在这个主宏任务中,你遇到了
setTimeout,它被放入下一个宏任务队列。 - 你也遇到了
Promise,它被放入当前微任务队列。 - 结果:主宏任务(同步代码)执行完 -> 清空微任务(Promise) -> 执行下一个宏任务(setTimeout)。
- 错觉来源:你可能认为“同步代码”不是宏任务,或者认为
setTimeout应该和同步代码一起执行。但实际上,同步代码是第一个宏任务,而setTimeout是后续的宏任务。
情况二:微任务队列为空
如果当前没有微任务,那么宏任务执行完后,直接进入下一个宏任务。这时看起来像是“宏任务接着宏任务执行”,但这只是因为微任务队列为空,而不是宏任务优先级更高。
3. 经典代码示例解析
让我们通过一个标准示例来验证执行顺序:
javascript
console.log('1. 同步代码 (主宏任务开始)');
setTimeout(() => {
console.log('4. setTimeout (下一个宏任务)');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise.then (微任务)');
});
console.log('2. 同步代码 (主宏任务结束)');
执行流程详解:
-
主宏任务执行:
- 执行
console.log('1...')-> 输出1。 - 遇到
setTimeout:将其回调放入宏任务队列(等待下一轮)。 - 遇到
Promise.resolve().then():将其回调放入微任务队列。 - 执行
console.log('2...')-> 输出2。 - 主宏任务结束。
- 执行
-
清空微任务队列:
- 检查微任务队列,发现有一个 Promise 回调。
- 执行该回调 -> 输出
3。 - 微任务队列清空。
-
UI 渲染(如有必要):
- 浏览器进行页面绘制。
-
执行下一个宏任务:
- 从宏任务队列中取出
setTimeout的回调。 - 执行该回调 -> 输出
4。
- 从宏任务队列中取出
最终输出顺序:
text
1. 同步代码 (主宏任务开始)
2. 同步代码 (主宏任务结束)
3. Promise.then (微任务)
4. setTimeout (下一个宏任务)
结论:微任务(3)在宏任务(4)之前执行。
4. 现代浏览器的事件循环模型(多队列分级)
根据最新的 WHATWG 规范,浏览器不再简单地将所有非微任务统称为“宏任务”,而是细分为多个任务队列,但微任务的高优先级地位不变。
表格
| 队列类型 | 优先级 | 典型任务 | 执行时机 |
|---|---|---|---|
| 微任务队列 | 最高 | Promise.then, queueMicrotask, MutationObserver | 当前宏任务结束后立即清空 |
| 交互队列 | 高 | 用户点击、键盘输入、滚动事件 | 优先于延时任务,确保响应速度 |
| 延时队列 | 中 | setTimeout, setInterval | 微任务清空后,按时间到期顺序执行 |
| 其他队列 | 可变 | fetch (I/O), requestAnimationFrame | 浏览器调度 |
关键点:
- 微任务必须清空:在当前宏任务(包括主脚本、setTimeout 回调、点击事件回调等)执行完毕后,浏览器必须检查并执行完所有微任务,才能进行 UI 渲染或执行下一个宏任务。
- 嵌套微任务:如果在微任务中又产生了新的微任务,它们会被追加到当前微任务队列末尾并继续执行,直到队列为空。这可能导致“微任务饥饿”,阻塞 UI 渲染。