一道高频且经典的面试题,考察对 JavaScript 运行机制,特别是事件循环(Event Loop) 、微任务(Microtask) 和宏任务(Macrotask) 的理解。
console.log('1');
setTimeout(function() {
console.log('2');
new Promise(function(resolve) {
console.log('3');
resolve();
}).then(function() {
console.log('4');
});
}, 0);
new Promise(function(resolve) {
console.log('5');
resolve();
}).then(function() {
console.log('6');
}).then(function() {
console.log('7');
});
setTimeout(function() {
console.log('8');
new Promise(function(resolve) {
console.log('9');
resolve();
}).then(function() {
console.log('10');
});
}, 0);
console.log('11');
第一步:同步代码执行(调用栈)
JavaScript 是单线程的,它首先会顺序执行所有同步代码(位于调用栈中)。
console.log('1')-> 输出1- 遇到第一个
setTimeout,将其回调函数交给Web API(或宿主环境) 计时,计时(0ms)结束后,回调函数被放入宏任务队列。我们记作Macro1。 - 执行
new Promise的构造函数部分,这是同步的。输出5,并立即调用resolve()。因为这个 Promise 已经resolve,它的.then()回调(输出6)被放入微任务队列。我们记作Micro1。这个.then又返回一个新的 Promise,它的.then回调(输出7)会在Micro1执行后才能确定状态,暂时不管。 - 遇到第二个
setTimeout,同理,其回调被放入宏任务队列。我们记作Macro2。 console.log('11')-> 输出11
此时,输出为:1, 5, 11
调用栈已清空。
微任务队列: [Micro1 (输出6)]
宏任务队列: [Macro1 (输出2,3,4), Macro2 (输出8,9,10)]
第二步:清空微任务队列
每当调用栈(Call Stack)被清空,事件循环(Event Loop) 会优先检查微任务队列,并依次执行其中的所有任务,直到微任务队列为空。
-
执行
Micro1,输出6。- 关键点:当第一个
.then(输出6)执行完成后,它返回的 Promise 被resolve。因此,链式调用的第二个.then的回调(输出7)会被立即放入当前的微任务队列末尾。
- 关键点:当第一个
-
微任务队列不为空,继续执行。事件循环取出这个新加入的微任务(
Micro2)执行,输出7。
此时,输出为:1, 5, 11, 6, 7
调用栈再次清空。
微任务队列: [](已完全清空)
宏任务队列: [Macro1, Macro2]
第三步:执行一个宏任务
微任务队列清空后,事件循环会从宏任务队列中取出第一个任务(这里是 Macro1)放入调用栈执行。
- 执行
Macro1的第一行同步代码:console.log('2')-> 输出2 - 执行
new Promise构造函数,输出3,并立即resolve()。这个resolve()将其.then回调(输出4)放入微任务队列。
此时,Macro1的同步代码部分执行完毕。
调用栈再次清空。
微任务队列: [Micro3 (输出4)](注意:这是在执行宏任务过程中产生的微任务)
宏任务队列: [Macro2]
第四步:再次清空微任务队列
重要规则:在每一个宏任务执行完毕后、取出下一个宏任务之前,都必须先清空当前的微任务队列。
- 调用栈清空,事件循环检查微任务队列,发现
Micro3,执行它,输出4。
此时,输出为:1, 5, 11, 6, 7, 2, 3, 4
微任务队列: []
宏任务队列: [Macro2]
第五步:执行下一个宏任务
- 事件循环取出
Macro2执行。console.log('8')-> 输出8 - 执行
new Promise构造函数,输出9,并立即resolve()。将其.then回调(输出10)放入微任务队列。 Macro2同步代码执行完毕,调用栈清空。- 清空产生的微任务:执行输出10的微任务,输出
10。
最终完整输出:1, 5, 11, 6, 7, 2, 3, 4, 8, 9, 10
核心知识点总结
-
同步任务优先:所有同步代码(普通调用、
new Promise构造函数内的代码)按顺序立即执行。 -
异步任务分类:
- 宏任务:
setTimeout,setInterval,setImmediate(Node), I/O, UI rendering。 - 微任务:
Promise.then/.catch/.finally,process.nextTick(Node),MutationObserver。
- 宏任务:
-
事件循环流程:
- 执行全局脚本(作为一个宏任务)。
- 清空调用栈。
- 执行所有位于微任务队列中的任务。
- 渲染页面(如有需要)。
- 从宏任务队列中取出一个任务执行。
- 重复:清空微任务 -> (渲染) -> 取一个宏任务 ...(循环往复)。
流程图
A[开始: 执行整体script<br>(作为一个宏任务)] --> B[执行所有同步代码]
B --> C[调用栈清空]
C --> D{微任务队列<br>是否为空?}
D -- 否 --> E[执行队列中所有微任务<br>(直到队列清空)]
E --> D
D -- 是 --> F{是否需要UI渲染?<br>(浏览器控制)}
F -- 是 --> G[执行UI渲染]
F -- 否 --> H{宏任务队列<br>是否有任务?}
H -- 是 --> I[取出一个宏任务执行]
I --> B
H -- 否 --> J[等待新任务<br>(空闲状态)]
J --> I
G --> H
核心算法伪代码
// 事件循环核心逻辑
while (true) {
// 1. 执行当前宏任务(从宏任务队列取出)
task = getNextMacroTask();
if (task) {
execute(task);
}
// 2. 执行所有微任务(直到队列清空)
while (microtaskQueue.hasTasks()) {
microtask = microtaskQueue.dequeue();
execute(microtask);
}
// 3. 必要时进行UI渲染(浏览器优化控制)
if (isTimeToRender()) {
// 3.1 样式计算
// 3.2 布局
// 3.3 绘制
render();
}
}
-
关键原则:
- 每个宏任务执行完后,都会检查并清空整个微任务队列。
- 微任务队列具有最高优先级。如果在执行微任务过程中产生了新的微任务,会继续执行,直到队列为空。这可能导致宏任务被“饥饿”。