事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序
为什么
setTimeout明明是 0 毫秒,却要等到最后才执行?
Promise.then和setTimeout到底谁先执行?面试官总爱问的"事件循环",今天一篇帮你彻底打通。
目录
- 从一道经典面试题说起
- 事件循环是什么?为什么要它?
- 宏任务(MacroTask)与微任务(MicroTask)
- 事件循环的工作流程(附流程图)
- 用代码验证每一步
- [async/await 在事件循环中的特殊表现](#async/await 在事件循环中的特殊表现 "#6-asyncawait-%E5%9C%A8%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF%E4%B8%AD%E7%9A%84%E7%89%B9%E6%AE%8A%E8%A1%A8%E7%8E%B0")
- [Node.js 与浏览器事件循环的区别](#Node.js 与浏览器事件循环的区别 "#7-nodejs-%E4%B8%8E%E6%B5%8F%E8%A7%88%E5%99%A8%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF%E7%9A%84%E5%8C%BA%E5%88%AB")
- [经典面试题集锦 + 解析](#经典面试题集锦 + 解析 "#8-%E7%BB%8F%E5%85%B8%E9%9D%A2%E8%AF%95%E9%A2%98%E9%9B%86%E9%94%A6--%E8%A7%A3%E6%9E%90")
- 总结
1. 从一道经典面试题说起
js
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
问:以上代码的输出顺序是什么?
很多人会脱口而出:1 2 3 4 或 1 4 2 3。
正确答案是:1 4 3 2
为什么?这正是事件循环的规则导致的。
2. 事件循环是什么?为什么要它?
JavaScript 是单线程语言,同一时间只能做一件事。但我们需要处理用户点击、网络请求、定时器等异步操作。如果这些操作都排队执行,那网络请求的 200ms 就会让整个页面卡住。
事件循环就是 JS 引擎用来调度同步代码、异步回调、I/O 操作的一套机制,它让 JS 既能保持单线程,又能高效处理异步。
生活类比:
- 你有一个"待办清单"(任务队列)。
- 你先处理手头的事(同步代码)。
- 手头的事干完了,就去清单里看看有没有新的待办。
- 如果有,就取出来做,做完再去看清单......
- 这个"反复看清单并取任务执行"的过程,就是事件循环。
3. 宏任务(MacroTask)与微任务(MicroTask)
在事件循环中,任务被分为两种:宏任务 和微任务。它们的执行优先级不同。
3.1 宏任务(MacroTask)
- 每次从任务队列中取出的一个任务,称为一个宏任务。
- 常见宏任务 :
- 整体代码块(script)
setTimeout、setInterval- I/O 操作
- UI 渲染
setImmediate(Node.js)requestAnimationFrame(浏览器)
3.2 微任务(MicroTask)
- 在当前宏任务执行完成后、下一个宏任务开始前执行的任务。
- 常见微任务 :
Promise.then/catch/finallyMutationObserver(浏览器)queueMicrotaskprocess.nextTick(Node.js,优先级高于普通微任务)
3.3 优先级总结
微任务队列 > 宏任务队列
每个宏任务执行完后,会立即清空当前所有的微任务,然后再去取下一个宏任务。
4. 事件循环的工作流程(附流程图)
用文字描述:
- 执行一个宏任务(最开始执行的是全局脚本代码)。
- 执行过程中如果遇到微任务,就把它添加到微任务队列。
- 当前宏任务执行完毕,检查微任务队列。
- 依次执行微任务队列中的所有任务(直到清空)。
- 执行必要的 UI 渲染(浏览器)。
- 从宏任务队列中取出下一个宏任务,重复步骤 1。
流程图(纯文本版):
markdown
┌─────────────────────────┐
│ 开始执行宏任务(script) │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ 同步代码执行,遇到微任务入队 │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ 宏任务执行完毕 │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ 清空当前所有微任务 │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ (可能执行 UI 渲染) │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ 取出下一个宏任务 │
└─────────────────────────┘
5. 用代码验证每一步
示例 1:基本顺序
js
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
console.log('同步');
// 输出:同步 → 微任务 → 宏任务
示例 2:微任务中注册新微任务
js
Promise.resolve().then(() => {
console.log('微任务1');
Promise.resolve().then(() => console.log('微任务2'));
});
console.log('同步');
// 输出:同步 → 微任务1 → 微任务2
关键:微任务队列会一次性清空,新添加的微任务也会在当前轮次执行完。
示例 3:宏任务中注册微任务
js
setTimeout(() => {
console.log('宏任务');
Promise.resolve().then(() => console.log('宏任务中的微任务'));
}, 0);
Promise.resolve().then(() => console.log('外层微任务'));
// 输出:外层微任务 → 宏任务 → 宏任务中的微任务
6. async/await 在事件循环中的特殊表现
async/await 是 Promise 的语法糖,但它在事件循环中的行为有细微的陷阱。
js
async function foo() {
console.log('2');
await bar(); // ← 这里 await 后面的代码相当于 Promise.then
console.log('4');
}
async function bar() {
console.log('3');
}
console.log('1');
foo();
console.log('5');
// 输出:1 2 3 5 4
分析:
- 同步:
1、2、3、5 await bar()后面的console.log('4')相当于Promise.resolve(bar()).then(() => console.log('4')),因此它进入微任务队列。- 当前宏任务执行完后,清空微任务,输出
4。
陷阱 :很多人以为 await 会阻塞,其实它只是把后续代码包装成微任务,并不会阻塞主线程。
7. Node.js 与浏览器事件循环的区别
浏览器和 Node.js 的事件循环在实现上有所不同,下面列出主要差异(Node.js 版本 11+ 后已尽量与浏览器对齐,但仍有一些区别)。
| 特性 | 浏览器 | Node.js(v11+) |
|---|---|---|
| 宏任务类型 | setTimeout、setInterval、I/O、UI 渲染 |
setTimeout、setInterval、I/O、setImmediate、process.nextTick(特殊微任务) |
| 微任务执行时机 | 每个宏任务之后清空微任务 | 基本一致,但 process.nextTick 优先级高于 Promise.then |
| 循环阶段 | 简单:宏任务 → 微任务 → 渲染 | 多阶段:timers → pending callbacks → idle → poll → check → close |
Node.js 的事件循环有更多阶段,但作为前端开发者,你主要知道 setTimeout 和 Promise 的行为与浏览器基本一致即可。
8. 经典面试题集锦 + 解析
题目 1
js
setTimeout(() => console.log(1), 0);
Promise.resolve().then(() => console.log(2));
Promise.resolve().then(() => {
console.log(3);
setTimeout(() => console.log(4), 0);
});
console.log(5);
输出 :5 2 3 1 4
解释:
- 同步
5。 - 微任务依次
2、3(执行3时注册了一个宏任务4)。 - 宏任务
1、4。
题目 2(混合 async/await)
js
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
console.log('script end');
输出 :
script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
逐步拆解:
- 同步:
script start - 调用
async1,输出async1 start await async2()执行async2,输出async2,然后await后续代码(async1 end)入微任务- 继续执行同步,
new Promise立即执行输出promise1,resolve后的then入微任务 - 同步
script end - 当前宏任务结束,清空微任务队列:
async1 end、promise2 - 下一个宏任务
setTimeout输出
题目 3(Node.js 中的 process.nextTick)
js
setTimeout(() => console.log('timeout'), 0);
process.nextTick(() => console.log('nextTick'));
console.log('start');
Node.js 输出:start → nextTick → timeout
process.nextTick 优先级高于 Promise.then,属于特殊的微任务。
9. 总结
事件循环是 JavaScript 异步执行的核心机制。记住三句话:
- 先同步,后异步:同步代码优先执行,异步回调在队列中等待。
- 微任务 > 宏任务:每个宏任务执行完后,必须清空所有微任务。
await不阻塞:它只是把后续代码包装成微任务。
实践建议:
- 日常开发中,优先用
async/await,不用手动管理then。 - 需要并发请求时用
Promise.all。 - 记住常见宏/微任务的分类,面试时不再慌张。
如果你能独立分析上面所有例子的输出,恭喜你,事件循环这一关你已经通关了!