微任务队列:执行顺序的完整证明
能准确预测任意 Promise + async/await + setTimeout 混合代码的输出顺序,说明你对 JavaScript 运行时有精确的心理模型。这种能力不靠背题目,而靠理解微任务队列的状态机行为。
🔹 Level 1 · 你需要知道的
预测输出的三条规则
规则1:同步代码优先
所有同步代码(不含 await 之后的部分)在任何微任务之前执行。
规则2:微任务在下一个宏任务之前全部执行
Promise.then、queueMicrotask、MutationObserver 的回调都是微任务。每个宏任务(包括脚本的初始执行)结束后,所有积累的微任务都会被清空——包括微任务执行期间新产生的微任务。
规则3:await 在当前函数暂停,宏观上等价于 .then
async function f() {
await expr;
// 以下代码 = .then 里的代码,是微任务
afterAwait();
}
3行记忆口诀
同步 → 微任务(循环清空)→ 宏任务(一个)→ 微任务(循环清空)→ ...
最简单的验证
// 微任务类型
Promise.resolve().then(() => console.log('micro-1'));
queueMicrotask(() => console.log('micro-2'));
// 宏任务类型
setTimeout(() => console.log('macro'), 0);
// 同步
console.log('sync');
// 输出:sync → micro-1 → micro-2 → macro
两个微任务按注册顺序执行,都在宏任务之前。
async/await 快速解读
async function a() {
console.log('a1'); // 同步
await b(); // 调用 b,然后暂停 a
console.log('a3'); // 微任务:a 的 continuation
}
async function b() {
console.log('b1'); // 同步(在 a 的 await 表达式执行期间)
// b 返回 undefined(隐式)
}
console.log('start');
a();
console.log('end');
// 输出:start → a1 → b1 → end → a3
关键:await b() 先执行 b()(同步部分),然后暂停 a;控制权返回调用者(输出 end);然后微任务执行 a 的 continuation(输出 a3)。
🔸 Level 2 · 它是怎么运行的
每道题使用三列表格追踪状态:调用栈、微任务队列、宏任务队列。
题目1:setTimeout + Promise.then 混合
setTimeout(() => console.log('T'), 0);
Promise.resolve()
.then(() => console.log('M1'))
.then(() => console.log('M2'));
console.log('S');
执行追踪:
| 步骤 | 调用栈 | 微任务队列 | 宏任务队列 | 输出 |
|---|---|---|---|---|
| 1 | [main] |
[] |
[] |
— |
| 2 | setTimeout 调用 |
[] |
[T_cb] |
— |
| 3 | .then('M1') 注册 |
[M1_cb] |
[T_cb] |
— |
| 4 注意 | .then('M2') 暂未注册:M1_cb 未执行,M2 的 Promise 是 pending |
[M1_cb] |
[T_cb] |
— |
| 5 | console.log('S') |
[M1_cb] |
[T_cb] |
S |
| 6 | 同步结束,清微任务:执行 M1_cb | [] |
[T_cb] |
M1 |
| 7 | M1_cb 返回 undefined,触发 M2 注册 | [M2_cb] |
[T_cb] |
— |
| 8 | 清微任务:执行 M2_cb | [] |
[T_cb] |
M2 |
| 9 | 微任务队列空,执行宏任务 T_cb | [] |
[] |
T |
输出:S → M1 → M2 → T
注意:步骤4说明 .then 链中第2个 .then 在第1个 .then 的回调执行完之后才注册微任务,而不是同时注册两个微任务。
题目2:async/await 的精确微任务位置
async function foo() {
console.log('foo-1');
const result = await Promise.resolve('hello');
console.log('foo-2', result); // 在哪里?
return 'foo-done';
}
async function bar() {
console.log('bar-1');
const result = await foo();
console.log('bar-2', result); // 在哪里?
}
console.log('start');
bar();
console.log('end');
展开等价形式(便于分析):
// bar 展开:
function bar() {
console.log('bar-1');
return foo().then(result => {
console.log('bar-2', result);
});
}
// foo 展开:
function foo() {
console.log('foo-1');
return Promise.resolve('hello').then(result => {
console.log('foo-2', result);
return 'foo-done';
});
}
执行追踪:
| 步骤 | 操作 | 输出 | 微任务队列 |
|---|---|---|---|
| 1 | console.log('start') |
start | [] |
| 2 | 调用 bar(),进入 bar |
— | — |
| 3 | console.log('bar-1') |
bar-1 | [] |
| 4 | 调用 foo(),进入 foo |
— | — |
| 5 | console.log('foo-1') |
foo-1 | [] |
| 6 | Promise.resolve('hello').then(foo_cont) 注册微任务 |
— | [foo_cont] |
| 7 | foo 返回 pending Promise,bar 的 await foo() 暂停 |
— | [foo_cont] |
| 8 | console.log('end') |
end | [foo_cont] |
| 9 | 同步结束。清微任务:执行 foo_cont |
foo-2 hello | [] |
| 10 | foo_cont 返回 'foo-done',foo 的 Promise fulfilled |
— | [bar_cont] |
| 11 | 清微任务:执行 bar_cont |
bar-2 foo-done | [] |
输出:start → bar-1 → foo-1 → end → foo-2 hello → bar-2 foo-done
题目3:多个 .then 的累积微任务
const p = Promise.resolve();
p.then(() => console.log('A'));
p.then(() => console.log('B'));
p.then(() => console.log('C'));
p.then(() => {
console.log('D');
Promise.resolve().then(() => console.log('E'));
});
注意:这里有4个 .then 都是直接挂在同一个已 fulfilled 的 Promise p 上(不是链式调用)。
执行追踪:
| 步骤 | 操作 | 微任务队列 | 输出 |
|---|---|---|---|
| 初始 | 4个 .then 同时注册 |
[A_cb, B_cb, C_cb, D_cb] |
— |
| 1 | 执行 A_cb | [B_cb, C_cb, D_cb] |
A |
| 2 | 执行 B_cb | [C_cb, D_cb] |
B |
| 3 | 执行 C_cb | [D_cb] |
C |
| 4 | 执行 D_cb,内部注册新微任务 | [E_cb] |
D |
| 5 | 执行 E_cb(在同一轮微任务清空中) | [] |
E |
输出:A → B → C → D → E
关键点:D_cb 执行时新注册的 E_cb 加入了当前正在清空的微任务队列末尾,所以在 E 之前没有机会轮到下一个宏任务。
题目4:Promise.resolve(thenable) 的额外微任务
let resolveP;
const p = new Promise(r => { resolveP = r; });
p.then(v => console.log('Handler:', v));
resolveP(Promise.resolve(42)); // resolve 一个 thenable!
Promise.resolve().then(() => console.log('Plain micro'));
执行追踪:
| 步骤 | 操作 | 微任务队列 | 输出 |
|---|---|---|---|
| 1 | resolveP(Promise.resolve(42)) — resolve 遇到 thenable |
[NPRTR_job] |
— |
| 2 | Promise.resolve().then('Plain micro') 注册 |
[NPRTR_job, Plain_cb] |
— |
| 3 | 同步结束。执行 NPRTR_job |
[Plain_cb] |
— |
| 4 | NPRTR_job 调用 inner.then(resolveP, rejectP) |
[Plain_cb, resolveP_cb] |
— |
| 5 | 执行 Plain_cb |
[resolveP_cb] |
Plain micro |
| 6 | 执行 resolveP_cb(42),p 变为 fulfilled |
[Handler_cb] |
— |
| 7 | 执行 Handler_cb |
[] |
Handler: 42 |
输出:Plain micro → Handler: 42
(NPRTR = NewPromiseResolveThenableJob)
对比:如果用 resolveP(42)(直接解析,非 thenable),输出会是 Handler: 42 → Plain micro,因为 Handler 的微任务先入队。
完整综合题:经典面试难题详解
console.log('1');
setTimeout(function () {
console.log('2');
Promise.resolve().then(function () {
console.log('3');
});
}, 0);
new Promise(function (resolve) {
console.log('4');
resolve();
}).then(function () {
console.log('5');
setTimeout(function () {
console.log('6');
}, 0);
});
console.log('7');
执行追踪:
| 步骤 | 调用栈 | 微任务队列 | 宏任务队列 | 输出 |
|---|---|---|---|---|
| 1 | main |
[] |
[] |
1 |
| 2 | setTimeout 注册 | [] |
[cb2] |
— |
| 3 | new Promise 构造函数(同步) | [] |
[cb2] |
4 |
| 4 | .then 注册 cb5 | [cb5] |
[cb2] |
— |
| 5 | console.log('7') | [cb5] |
[cb2] |
7 |
| 6 | 同步结束,清微任务。执行 cb5 | [] |
[cb2] |
5 |
| 7 | cb5 内 setTimeout 注册 | [] |
[cb2, cb6] |
— |
| 8 | 微任务队列空,执行宏任务 cb2 | [] |
[cb6] |
2 |
| 9 | cb2 内 Promise.then 注册 | [cb3] |
[cb6] |
— |
| 10 | cb2 结束,清微任务。执行 cb3 | [] |
[cb6] |
3 |
| 11 | 微任务队列空,执行宏任务 cb6 | [] |
[] |
6 |
输出:1 → 4 → 7 → 5 → 2 → 3 → 6
两个ASCII状态图
图1:微任务队列的动态扩张
同步代码执行阶段:
调用栈: [main]
微任务: []
宏任务: []
→ setTimeout() 宏任务: [T]
→ Promise.then() 微任务: [M1]
→ ...(同步代码继续)
同步结束后,进入微任务清空阶段:
┌─────────────────────────────┐
│ 微任务队列: [M1, M2, M3] │
│ ↓ 取出 M1 │
│ 执行 M1 │
│ (M1 内部可能产生 M4) │
│ 微任务队列: [M2, M3, M4] │
│ ↓ 取出 M2 │
│ 执行 M2 │
│ ... │
│ 直到队列为空 │
└─────────────────────────────┘
然后执行宏任务 T(一个)
然后再次清空微任务队列
...循环
图2:async/await 的调用栈层叠
console.log('start')
│
bar() 调用 ──────────────────────┐
│ │
│ bar 调用栈: [main, bar] │
│ console.log('bar-1') │
│ │
│ foo() 调用 ────────────┐ │
│ │ │
│ foo 调用栈:[main,bar,foo] │
│ console.log('foo-1') │ │
│ await expr → 暂停 foo │ │
│ foo 调用栈弹出 │ │
│ ←──────────────────────┘ │
│ │
│ bar 的 await 收到 pending │
│ Promise,bar 暂停 │
│ bar 调用栈弹出 │
└────────────────────────────┘
│
console.log('end') ← 同步代码继续
│
↓(同步结束)
微任务队列清空:
foo continuation → 输出 'foo-2'
bar continuation → 输出 'bar-2'
🔺 Level 3 · 规范怎么定义的
EnqueueJob 的调用时机
ECMAScript 规范中,EnqueueJob 在以下情况被调用:
1. PromiseReactionJob 入队:
当 Promise 的状态从 pending 变为 fulfilled 或 rejected 时,TriggerPromiseReactions 遍历 reactions 列表,为每个 reaction 调用 EnqueueJob。
2. NewPromiseResolveThenableJob 入队:
当 resolve 函数接收到一个 thenable 时(见上章),调用 EnqueueJob("PromiseJobs", NewPromiseResolveThenableJob, ...) 产生额外的微任务层。
规范原文(§27.2.2.1):
NewPromiseResolveThenableJob ( promiseToResolve, thenable, then )
- Let job be a new Job Abstract Closure with no parameters that captures promiseToResolve, thenable, and then and performs the following steps when called: a. Let resolvingFunctions be CreateResolvingFunctions(promiseToResolve). b. Let thenCallResult be Completion(Call(then, thenable, « resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]] »)). c. If thenCallResult is an abrupt completion, then: i. Perform ? Call(resolvingFunctions.[[Reject]], undefined, « thenCallResult.[[Value]] »). ii. Return unused. d. Return ? thenCallResult.
- ...
- Return the Record { [[Job]]: job, [[Realm]]: thenRealm }.
HTML 规范:Microtask Checkpoint 的触发条件
HTML 规范规定,Microtask Checkpoint 不仅在宏任务结束时触发,还在以下情况触发:
- 脚本执行结束(包括
<script>标签内的代码执行完毕) - 某些 Web API 的调用(如
queueMicrotask()会立即调度,但检查点在当前任务结束时) - fetch() 的内部操作(Response body 处理等)
- WebSocket 消息处理完成后
HTML 规范 §8.1.7.4 描述了触发时机:
The microtask checkpoint must be performed in the following situations:
- When the event loop's current task is null (after any initial microtask processing)
- After a script's ScriptEvaluationJob completes
- Before returning from any "spin the event loop" algorithms
关键: queueMicrotask 本身只是调用 EnqueueJob,并不立即触发检查点;检查点在当前宏任务(或当前同步代码块)结束时由事件循环主动触发。
await 的规范语义演变
ECMAScript 2020 引入了优化(Normative change,V8 v7.2 实现),将 await 从产生 2 个微任务简化为 1 个:
旧规范(ES2018)等价于:
// await v 展开:
Promise.resolve(v).then(PromiseResolveFunction).then(continuation)
// 2个微任务层
新规范(ES2020+)等价于:
// await v 展开(当 v 不是 thenable):
Promise.resolve(v).then(continuation)
// 1个微任务层
// 当 v 是 thenable 时,仍然需要额外处理(通过 PromiseResolve 抽象操作)
规范原文(§27.7 Async Functions,await 语义):
The
awaitoperator is roughly equivalent to:return new Promise(resolve => { Promise.resolve(value).then(resolve); }).then(resumePoint);(Simplified; actual implementation uses internal abstract operations)
新规范的关键变化:当 await 的表达式值已经是一个 native Promise 时,跳过 NewPromiseResolveThenableJob,直接用 PerformPromiseThen 连接 continuation。这个优化使 await nativePromise 只产生1个微任务。
💎 Level 4 · 边界与陷阱
陷阱1:await 等价于几个 .then?(规范历史陷阱)
async function test() {
await 1;
return 'done';
}
const p = test();
Promise.resolve().then(() => console.log('A'));
p.then(() => console.log('B'));
// Node.js 10(旧规范):A → B
// Node.js 12+(新规范):B → A
// 解释:
// 旧规范:await 1 产生2个微任务层,B 在 A 之后
// 新规范:await 1(原始值)只产生1个微任务层,B 在 A 之前
这是一个真实的 Breaking Change,影响了依赖特定微任务顺序的测试套件和框架代码。
陷阱2:微任务里无限递归 queueMicrotask 的后果
let count = 0;
function recurse() {
count++;
if (count < 1000000) {
queueMicrotask(recurse); // 每次产生一个新微任务
} else {
console.log('finally done');
}
}
queueMicrotask(recurse);
setTimeout(() => console.log('macro'), 0);
// setTimeout 的回调在 100万次微任务全部执行完之后才会运行
// 如果去掉终止条件,setTimeout 永远不会执行
实际后果:
- 浏览器 UI 完全冻结(渲染得不到时间片)
- DevTools 可能提示 "long task"
- 在某些浏览器中,过深的微任务嵌套可能触发内存压力
陷阱3:MutationObserver 的微任务时机
const observer = new MutationObserver(() => {
console.log('mutation observed');
});
const div = document.createElement('div');
document.body.appendChild(div);
observer.observe(div, { attributes: true });
Promise.resolve().then(() => console.log('promise'));
div.setAttribute('data-x', '1'); // 触发 MutationObserver
console.log('sync');
// 浏览器输出:sync → mutation observed → promise
// 还是:sync → promise → mutation observed?
实际输出:sync → mutation observed → promise
原因:MutationObserver 的回调在浏览器中被安排为微任务,且它的入队时机是在 DOM 变化发生时(setAttribute 内部调用),早于 Promise.resolve().then 的入队时机。
注意: 这是浏览器特有行为,MutationObserver 的回调优先级可能在 Promise 之前(不同浏览器实现略有差异)。
陷阱4:Node.js setImmediate vs setTimeout(fn, 0) 的不确定顺序
// 在 Node.js 的主模块(顶层)中:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不确定!可能是:
// timeout → immediate
// 或:
// immediate → timeout
原因: Node.js 进入事件循环时,先进入 timers 阶段。setTimeout(fn, 0) 实际上是 setTimeout(fn, 1)(libuv 最小精度),如果进入 timers 阶段时已经过了 1ms,timeout 先执行;否则在当次循环中 timers 阶段没有到期任务,经过 poll 阶段后进入 check 阶段(setImmediate),immediate 先执行。
规律: 在 I/O 回调内部,setImmediate 总是先于 setTimeout:
const fs = require('fs');
fs.readFile('/tmp/test', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出:immediate → timeout(在 I/O 回调内,setImmediate 稳定优先)
});
陷阱5:用 queueMicrotask 实现比 setTimeout(0) 更早的调度
// 场景:需要在当前同步代码执行完后、但在下一个宏任务之前执行某些操作
// 方案1:setTimeout(0) — 宏任务,延迟最多数毫秒
setTimeout(() => updateUI(), 0);
// 方案2:queueMicrotask — 微任务,在当前宏任务结束后立即执行
queueMicrotask(() => updateUI());
// 方案3:Promise.resolve().then — 等价于 queueMicrotask
Promise.resolve().then(() => updateUI());
实际应用场景:Vue 3 的 nextTick 内部实现:
// Vue 3 源码(简化)
let resolvedPromise = Promise.resolve();
export function nextTick(fn?: () => void): Promise<void> {
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(fn) : p;
}
Vue 的响应式系统在状态变化时,通过 queueFlush(内部用 Promise 微任务)调度 DOM 更新,确保 DOM 更新发生在同一"任务"内的所有状态变化之后,但在下一个宏任务(渲染)之前。
小结
.then链中每个.then的回调是独立的微任务,按顺序入队——不是同时注册多个。- 多个
.then挂在同一个已 fulfilled Promise(而非链式调用)时,会在同一轮同时注册所有微任务。 MutationObserver在浏览器中是微任务,优先级与 Promise 接近但可能先于 Promise。await v(v 为非 thenable 原始值)在 ES2020+ 引擎中只产生 1个微任务,影响与Promise.resolve()的相对顺序。- Node.js
setImmediatevssetTimeout(0)的顺序在主模块中不确定,只在 I/O 回调中稳定(setImmediate 先)。