第 27 章

微任务队列:执行顺序的完整证明

能准确预测任意 Promise + async/await + setTimeout 混合代码的输出顺序,说明你对 JavaScript 运行时有精确的心理模型。这种能力不靠背题目,而靠理解微任务队列的状态机行为。

🔹 Level 1 · 你需要知道的

预测输出的三条规则

规则1:同步代码优先 所有同步代码(不含 await 之后的部分)在任何微任务之前执行。

规则2:微任务在下一个宏任务之前全部执行 Promise.thenqueueMicrotaskMutationObserver 的回调都是微任务。每个宏任务(包括脚本的初始执行)结束后,所有积累的微任务都会被清空——包括微任务执行期间新产生的微任务

规则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 )

  1. 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.
  2. ...
  3. Return the Record { [[Job]]: job, [[Realm]]: thenRealm }.

HTML 规范:Microtask Checkpoint 的触发条件

HTML 规范规定,Microtask Checkpoint 不仅在宏任务结束时触发,还在以下情况触发:

  1. 脚本执行结束(包括 <script> 标签内的代码执行完毕)
  2. 某些 Web API 的调用(如 queueMicrotask() 会立即调度,但检查点在当前任务结束时)
  3. fetch() 的内部操作(Response body 处理等)
  4. 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 await operator 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 永远不会执行

实际后果:

陷阱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 更新发生在同一"任务"内的所有状态变化之后,但在下一个宏任务(渲染)之前。

小结

  1. .then 链中每个 .then 的回调是独立的微任务,按顺序入队——不是同时注册多个。
  2. 多个 .then 挂在同一个已 fulfilled Promise(而非链式调用)时,会在同一轮同时注册所有微任务。
  3. MutationObserver 在浏览器中是微任务,优先级与 Promise 接近但可能先于 Promise。
  4. await v(v 为非 thenable 原始值)在 ES2020+ 引擎中只产生 1个微任务,影响与 Promise.resolve() 的相对顺序。
  5. Node.js setImmediate vs setTimeout(0) 的顺序在主模块中不确定,只在 I/O 回调中稳定(setImmediate 先)。
本章评分
4.8  / 5  (4 评分)

💬 留言讨论