浏览器的事件循环机制笔记

0 阅读4分钟

以下是基于最新浏览器规范(WHATWG/W3C)对事件循环机制的深度解析,帮助你彻底理清执行顺序。

1. 核心结论:微任务优先于宏任务

在浏览器事件循环中,执行顺序遵循以下严格规则:

  1. 执行当前宏任务‌(通常是整个 <script> 标签内的同步代码)。
  2. 清空微任务队列‌:执行所有当前可用的微任务(Promise.then, queueMicrotask, MutationObserver 等),直到队列为空。
  3. UI 渲染‌(如果需要):浏览器进行样式计算、布局和绘制。
  4. 执行下一个宏任务‌:从宏任务队列(如 setTimeout, setInterval, I/O, UI 交互)中取出一个任务执行。
  5. 重复步骤 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. 同步代码 (主宏任务结束)');

执行流程详解:

  1. 主宏任务执行‌:

    • 执行 console.log('1...') -> 输出 1
    • 遇到 setTimeout:将其回调放入‌宏任务队列‌(等待下一轮)。
    • 遇到 Promise.resolve().then():将其回调放入‌微任务队列‌。
    • 执行 console.log('2...') -> 输出 2
    • 主宏任务结束‌。
  2. 清空微任务队列‌:

    • 检查微任务队列,发现有一个 Promise 回调。
    • 执行该回调 -> 输出 3
    • 微任务队列清空。
  3. UI 渲染‌(如有必要):

    • 浏览器进行页面绘制。
  4. 执行下一个宏任务‌:

    • 从宏任务队列中取出 setTimeout 的回调。
    • 执行该回调 -> 输出 4

最终输出顺序:

text
1. 同步代码 (主宏任务开始)
2. 同步代码 (主宏任务结束)
3. Promise.then (微任务)
4. setTimeout (下一个宏任务)

结论‌:微任务(3)在宏任务(4)之前执行。


4. 现代浏览器的事件循环模型(多队列分级)

根据最新的 WHATWG 规范,浏览器不再简单地将所有非微任务统称为“宏任务”,而是细分为多个任务队列,但‌微任务的高优先级地位不变‌。

表格

队列类型优先级典型任务执行时机
微任务队列最高Promise.thenqueueMicrotaskMutationObserver当前宏任务结束后立即清空
交互队列用户点击、键盘输入、滚动事件优先于延时任务,确保响应速度
延时队列setTimeoutsetInterval微任务清空后,按时间到期顺序执行
其他队列可变fetch (I/O), requestAnimationFrame浏览器调度

关键点:

  • 微任务必须清空‌:在当前宏任务(包括主脚本、setTimeout 回调、点击事件回调等)执行完毕后,浏览器‌必须‌检查并执行完所有微任务,才能进行 UI 渲染或执行下一个宏任务。
  • 嵌套微任务‌:如果在微任务中又产生了新的微任务,它们会被追加到当前微任务队列末尾并继续执行,直到队列为空。这可能导致“微任务饥饿”,阻塞 UI 渲染。