在现代前端开发中,JavaScript 的异步执行机制是开发者必须掌握的核心概念之一。尤其是事件循环(Event Loop) 、宏任务(Macrotask) 和 微任务(Microtask) 的执行顺序,常常出现在面试题中,也直接影响代码的运行结果。本文将结合一段经典面试题代码和其执行流程图,深入剖析这些核心概念,并帮助你彻底理解 JavaScript 的异步执行机制。
🔍 一、引言:为什么这道题如此重要?
这是一道经典的前端面试题,几乎每年都会出现在大厂笔试或面试点中。它综合考察了:
- JavaScript 的单线程特性
- 事件循环(Event Loop)机制
- 宏任务(Macrotask)与微任务(Microtask)的区别
- Promise 和
setTimeout的执行时机
掌握这道题,意味着你真正理解了 JavaScript 的“灵魂”。
🧠 二、核心概念回顾
1. 调用栈(Call Stack)
- 存放当前正在执行的函数。
- 执行完一个函数后,从栈顶弹出。
2. 宿主环境(Host Environment)
- 浏览器或 Node.js 提供的运行环境。
- 负责处理定时器、DOM 操作、网络请求等异步操作。
- 将异步任务放入 宏任务队列 或 微任务队列。
3. 任务队列
| 队列 | 类型 | 典型例子 |
|---|---|---|
| 宏任务队列 | Macrotask | setTimeout, setInterval, I/O, UI 渲染 |
| 微任务队列 | Microtask | Promise.then, MutationObserver, process.nextTick |
4. 事件循环(Event Loop)
-
不断检查调用栈是否为空。
-
若为空,则:
- 从微任务队列取出所有任务并执行(清空);
- 从宏任务队列取出一个任务执行;
- 重复上述过程。
✅ 关键规则:
- 微任务优先于宏任务;
- 每次宏任务结束后,必须先清空所有微任务,再进入下一个宏任务;
- 即使微任务后入队,也会在当前宏任务结束后立即执行。
📊 三、代码分析
console.log(1)
setTimeout(() => {
console.log(2)
const p = new Promise(resolve => resolve(3))
.then(result => console.log(result))
}, 0)
const p = new Promise(resolve => {
setTimeout(() => {
console.log(4)
}, 0)
resolve(5)
})
p.then(result => console.log(result))
const p2 = new Promise(resolve => resolve(6))
p2.then(result => console.log(result))
console.log(7)
🎯 四、执行流程详解(分步走)
我们结合以下结构图来分析:
[调用栈] ←→ [宿主环境(浏览器)]
↓
[微任务队列]
↑
[宏任务队列]
第一步:执行初始同步代码(宏任务)
这是整个脚本的第一个宏任务,属于“script”级别的宏任务。
-
console.log(1)→ 输出1 -
setTimeout(...)→ 创建定时器,回调函数进入 宏任务队列 -
new Promise(...)构造函数执行:- 内部
setTimeout(...)→ 又一个宏任务,进入 宏任务队列 resolve(5)→ 触发.then(),但回调是微任务,进入 微任务队列
- 内部
-
p.then(...)→ 微任务,进入 微任务队列 -
p2 = new Promise(...)→ 同步执行,resolve(6) -
p2.then(...)→ 微任务,进入 微任务队列 -
console.log(7)→ 输出7
✅ 初始宏任务执行完毕,调用栈为空。
⚠️ 注意:虽然
p.then和p2.then在代码中写在console.log(7)前面,但它们的回调不会在7之前执行!因为微任务要等当前宏任务完全结束才运行。
此时输出:
1
7
第二步:执行微任务队列(第一次)
微任务队列中有两个任务(按注册顺序):
p.then(...)→console.log(5)p2.then(...)→console.log(6)
→ 输出:
5
6
✅ 微任务全部执行完毕,微任务队列清空。
第三步:执行下一个宏任务(第一个 setTimeout)
从宏任务队列取出第一个任务:
console.log(2)
const p = new Promise(resolve => resolve(3))
.then(result => console.log(result))
console.log(2)→ 输出2Promise.resolve(3).then(...)→ 新增一个微任务(打印3),进入 微任务队列
✅ 这个宏任务执行完毕。
第四步:执行新产生的微任务
微任务队列中有一个任务:
console.log(3)→ 输出3
第五步:执行最后一个宏任务(打印4的 setTimeout)
从宏任务队列取出第二个任务:
1console.log(4)
→ 输出 4
✅ 最终输出顺序
1
7
5
6
2
3
4
🧩 五、关键知识点总结
| 知识点 | 说明 |
|---|---|
| 初始 script 是宏任务 | 整个脚本首次执行是一个宏任务,同步代码连续执行 |
| 微任务不打断同步代码 | 即使 .then() 写在 console.log(7) 前面,也不会提前执行 |
| 微任务优先级更高 | 每次宏任务结束后,先清空所有微任务,再进入下一个宏任务 |
| Promise.then 是微任务 | 不会阻塞主线程,但会在当前宏任务结束后立即执行 |
| setTimeout 是宏任务 | 即使延时为 0,也必须等待当前宏任务完成 |
🔄 六、事件循环图示解释
结合你提供的图示:
text
编辑
1[调用栈] ←→ [宿主环境(浏览器)]
2 ↓
3 [微任务队列]
4 ↑
5[宏任务队列]
- 调用栈:执行当前函数;
- 宿主环境:负责创建定时器、处理 DOM 等;
- 宏任务队列:存放
setTimeout、setInterval等; - 微任务队列:存放
Promise.then、MutationObserver等; - 事件循环:不断检查调用栈是否为空,若为空则先执行微任务,再执行宏任务。
🧪 七、如何验证?
你可以直接运行这段代码:
js
编辑
1console.log(1);
2setTimeout(() => {
3 console.log(2);
4 Promise.resolve(3).then(console.log);
5}, 0);
6new Promise(resolve => {
7 setTimeout(() => console.log(4), 0);
8 resolve(5);
9}).then(console.log);
10Promise.resolve(6).then(console.log);
11console.log(7);
实际输出:
text
编辑
11
27
35
46
52
63
74
✅ 结语
这道题之所以难,是因为它考验你对 事件循环机制的深刻理解,尤其是:
- 微任务与宏任务的执行顺序;
- 同步与异步的边界;
Promise与setTimeout的区别。
只要你记住一句话:
“宏任务执行完 → 清空所有微任务 → 再执行下一个宏任务”
就能轻松应对类似题目。
如果你还没完全理解,建议你画出执行流程图,一步步模拟每个任务的加入与执行。
🎯 小测验:你能预测下面这段代码的输出吗?
js
编辑
1console.log('start');
2Promise.resolve().then(() => console.log('promise1'));
3setTimeout(() => console.log('timeout1'), 0);
4Promise.resolve().then(() => console.log('promise2'));
5setTimeout(() => console.log('timeout2'), 0);
6console.log('end');
答案:start -> end -> promise1 -> promise2 -> timeout1 -> timeout2
✅ Tips
事件循环的执行过程以画图的形式最好理解:
能够清晰的展示微任务与宏任务的执行顺序,所以这里建议大家在理解代码的过程中画画图,实在不理解的这里也给大家推荐b站上面的一个视频:www.bilibili.com/video/BV192…