Event Loop 面试通关:从原理到口述再到实战
一、readme.md 知识点梳理
readme.md 围绕 事件循环(Event Loop) 展开,核心可归纳为以下五层:
第一层:单线程困境
"每个页面都有一个渲染进程,启动一个主线程,负责的任务特别多,而且还是单线程。"
JS 是单线程语言(一个调用栈),同一时刻只能做一件事。如果同步等待耗时操作(网络/定时器),页面会卡死。这是 Event Loop 诞生的根本原因。
第二层:消息队列 + 事件循环
"渲染主线程会频繁接收到来自于 IO 线程的一些任务......以消息的方式。"
渲染主线程既要解析 DOM、计算样式、布局、绘制,又要执行 JS。其他线程(网络进程、IO 线程)通过消息队列投递任务,主线程用 Event Loop 轮询处理。
第三层:两种队列
| 宏任务 (Macro Task) | 微任务 (Micro Task) | |
|---|---|---|
| 来源 | setTimeout、setInterval、I/O、UI 渲染、<script> 整体 |
Promise.then、MutationObserver、async/await、queueMicrotask |
| 执行策略 | 每次只取 一个 执行 | 每次 全部清空(包括执行中新产生的) |
| 优先级 | 低 | 高(插入在宏任务之间) |
第四层:一轮 Event Loop 的完整流程
javascript
执行一个宏任务(script 就是第一个)
↓
清空所有微任务(Promise.then、await 后续、MutationObserver...)
↓
(可选)UI 渲染 / 动画帧 / 垃圾回收
↓
取下一个宏任务 ← 回到顶部
第五层:关键结论
"同步代码优先执行 → 微任务插队执行(优先级高于宏任务) → 最后才轮到宏任务。"
二、面试话术(逐句可用)
当面试官问:"说说你对 Event Loop 的理解"
标准回答模板:
JavaScript 是单线程的,为了防止耗时任务阻塞主线程,浏览器引入了事件循环机制。任务分为同步和异步,异步又分为宏任务和微任务。一个宏任务执行完后,会把微任务队列全部清空,然后再取下一个宏任务,形成循环。
宏任务包括 script 整体代码、setTimeout、setInterval、I/O 和 UI 渲染;微任务包括 Promise.then、MutationObserver、await 后续代码和 queueMicrotask。
执行顺序是:同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务,以此类推。
当面试官问:"宏任务和微任务的区别是什么?"
有三个核心区别:
第一,来源不同:宏任务由宿主环境(浏览器/Node)提供,微任务由 JS 引擎自身(Promise、MutationObserver)产生。
第二,执行策略不同 :每次事件循环只执行一个 宏任务,但会一次性清空微任务队列,包括执行过程中新产生的微任务。
第三,优先级不同:微任务优先级更高------每执行完一个宏任务,都会立即清空微任务队列,然后才可能进行 UI 渲染,再取下一个宏任务。
当面试官问:"async/await 在 Event Loop 中是怎么执行的?"
async函数在遇到await之前的代码是同步 执行的。await后面的表达式也是同步求值的,但await之后的代码会被包装成 微任务 放入队列,效果等价于Promise.resolve().then(() => { ... })。因此
await后的代码不会立即执行,而是要等当前同步代码执行完、并且在当前微任务队列中排队等待。
当面试官问:"为什么 MutationObserver 是微任务而不是宏任务?"
因为 DOM 变化的响应需要尽快执行。如果 MutationObserver 是宏任务,两次 DOM 修改之间可能插入其他宏任务甚至 UI 渲染,导致观测延迟和数据不一致。放在微任务队列中可以保证在同一次事件循环内、下一个宏任务之前就拿到所有变更。
三、1.html 执行顺序分析
代码回顾
html
<script>
console.log('同步代码 1'); // ①
setTimeout(() => { // → 宏任务1
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('setTimeout 1 内部微任务');
});
}, 0);
const promise1 = new Promise((resolve) => {
console.log('Promise 构造函数'); // ②
resolve();
console.log('Promise 构造函数内 resolve 后'); // ③
});
promise1.then(() => { // → 微任务A
console.log('Promise.then 1');
setTimeout(() => { // → 宏任务3
console.log('Promise.then 1 内部 setTimeout');
}, 0);
});
async function asyncFn() {
console.log('async 函数同步部分'); // ④
await Promise.resolve(); // → 后续作为微任务B
console.log('await 后微任务');
}
asyncFn();
console.log('同步代码 2'); // ⑤
queueMicrotask(() => { // → 微任务C
console.log('queueMicrotask 微任务');
});
const observer = new MutationObserver(() => { // → 微任务D
console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1');
</script>
分步推演
阶段一:同步代码(第一个宏任务 <script>)
| 序号 | 代码 | 行为 |
|---|---|---|
| ① | console.log('同步代码 1') |
打印 同步代码 1 |
| --- | setTimeout(..., 0) |
回调进入 宏任务队列(记为 MT-1),不执行 |
| ② | new Promise(executor) |
executor 是同步的:打印 Promise 构造函数 |
| --- | resolve() |
Promise 状态变为 resolved,.then 回调进入 微任务队列(记为 mT-A) |
| ③ | executor 内 resolve 后 | 打印 Promise 构造函数内 resolve 后 |
| ④ | asyncFn() 调用 |
进入函数,打印 async 函数同步部分 |
| --- | await Promise.resolve() |
Promise.resolve() 同步完成;await 将后续代码作为微任务入队(记为 mT-B) |
| ⑤ | console.log('同步代码 2') |
打印 同步代码 2 |
| --- | queueMicrotask(...) |
回调进入微任务队列(记为 mT-C) |
| --- | div.setAttribute(...) |
触发 MutationObserver,回调进入微任务队列(记为 mT-D) |
同步阶段输出:
javascript
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
阶段二:清空微任务队列
微任务队列(FIFO):mT-A → mT-B → mT-C → mT-D
| 顺序 | 微任务 | 打印内容 |
|---|---|---|
| ⑥ | mT-A (promise1.then) |
Promise.then 1 |
| --- | 内部 setTimeout(..., 0) |
回调进入宏任务队列(记为 MT-2) |
| ⑦ | mT-B (await 后续) |
await 后微任务 |
| ⑧ | mT-C (queueMicrotask) |
queueMicrotask 微任务 |
| ⑨ | mT-D (MutationObserver) |
MutationObserver 微任务 |
阶段三:执行下一个宏任务 MT-1
| 顺序 | 代码 | 打印内容 |
|---|---|---|
| ⑩ | MT-1: setTimeout 1 |
setTimeout 1 |
| --- | Promise.resolve().then(...) |
回调进入微任务队列(记为 mT-E) |
MT-1 结束 → 立即清空微任务:
| 顺序 | 微任务 | 打印内容 |
|---|---|---|
| ⑪ | mT-E | setTimeout 1 内部微任务 |
阶段四:执行下一个宏任务 MT-2
| 顺序 | 代码 | 打印内容 |
|---|---|---|
| ⑫ | MT-2: Promise.then 1 内部 setTimeout |
Promise.then 1 内部 setTimeout |
最终输出(完整顺序)
javascript
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout
一张图总结整个流程
xml
<script> 宏任务开始
│
├─ 同步代码 1 ← 打印 ①
├─ setTimeout 注册 → MT-1 入队
├─ Promise 构造函数 ← 打印 ②
├─ Promise 构造函数内 resolve 后 ← 打印 ③
├─ promise1.then 注册 → mT-A 入队
├─ async 函数同步部分 ← 打印 ④
├─ await 后续 → mT-B 入队
├─ 同步代码 2 ← 打印 ⑤
├─ queueMicrotask 注册 → mT-C 入队
├─ MutationObserver 注册 → mT-D 入队
│
├─ ★ 清空微任务 ★
│ ├─ mT-A: Promise.then 1 ← 打印 ⑥ | MT-2 入队
│ ├─ mT-B: await 后微任务 ← 打印 ⑦
│ ├─ mT-C: queueMicrotask ← 打印 ⑧
│ └─ mT-D: MutationObserver ← 打印 ⑨
│
├─ ★ 取下一个宏任务 MT-1 ★
│ ├─ setTimeout 1 ← 打印 ⑩
│ └─ ★ 清空微任务 ★
│ └─ mT-E: 内部微任务 ← 打印 ⑪
│
└─ ★ 取下一个宏任务 MT-2 ★
└─ 内部 setTimeout ← 打印 ⑫
面试点睛:这个例子中的三个坑
-
new Promise的 executor 是同步的 ------Promise 构造函数和resolve 后都在同步阶段打印,很多人误以为 Promise 全都是异步。 -
await之后是微任务 ------async 函数同步部分是同步打印的,但await 后微任务排队到了微任务阶段,比Promise.then 1还晚(因为await注册时机在promise1.then之后)。 -
MutationObserver 和 queueMicrotask 同级竞争------它们都是微任务,谁先注册谁先执行。这里 queueMicrotask 在前,MutationObserver 在后,但在同一个微任务清空轮次内依次执行。