JavaScript Promise 微任务执行顺序深度解析

6 阅读7分钟

引言

在 JavaScript 异步编程中,Promise 的微任务调度机制是一个重要且容易混淆的概念。本文将通过一个具体的代码示例,深入分析 Promise 微任务的执行顺序,特别是当 .then() 方法返回另一个 Promise 时的特殊行为。

示例代码

javascript

Promise.resolve().then(() => {
    console.log(0)
    return Promise.resolve(4)
}).then((res) => {
    console.log(res)
})
​
Promise.resolve().then(() => {
    console.log(1)
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(5)
}).then(() =>{
    console.log(6)
})

输出结果

text

0
1
2
3
4
5
6

核心概念

微任务队列

  • Promise 回调属于微任务(Microtask)
  • 微任务在当前宏任务执行完毕后、下一个宏任务开始前执行
  • 微任务队列遵循先进先出(FIFO)原则

Promise 链的特殊规则

.then() 方法返回一个 Promise 时,根据 ECMAScript 规范,会触发额外的微任务调度:

  1. 第一个微任务:PromiseResolveThenableJob
  2. 第二个微任务:实际传递解决值

详细执行过程分析

阶段一:初始状态

javascript

// 微任务队列初始状态
微任务队列: [A1, B1]
- A1: 第一个 Promise 链的第一个 .then()
- B1: 第二个 Promise 链的第一个 .then()

阶段二:第一轮执行

执行 A1:

javascript

console.log(0)                    // 输出: 0
return Promise.resolve(4)        // 返回 Promise
// 创建一个微任务 X1 (PromiseResolveThenableJob)

执行 B1:

javascript

console.log(1)                   // 输出: 1
// 返回 undefined, B2 进入微任务队列

此时微任务队列: [X1, B2]

阶段三:处理返回的 Promise

执行 X1:

javascript

// 处理 Promise.resolve(4) 的解决
// 创建微任务 X2 (传递解决值)

此时微任务队列: [B2, X2]

阶段四:继续执行

执行 B2:

javascript

console.log(2)                   // 输出: 2
// 返回 undefined, B3 进入队列

此时微任务队列: [X2, B3]

执行 X2:

javascript

// 将第一个链的第二个 .then() (A2) 放入队列

此时微任务队列: [B3, A2]

阶段五:交替执行

执行 B3:

javascript

console.log(3)                   // 输出: 3
// 返回 undefined, B4 进入队列

此时微任务队列: [A2, B4]

执行 A2:

javascript

console.log(4)                   // 输出: 4
// 第一个 Promise 链结束

此时微任务队列: [B4]

阶段六:收尾执行

执行 B4:

javascript

console.log(5)                   // 输出: 5
// 返回 undefined, B5 进入队列

此时微任务队列: [B5]

执行 B5:

javascript

console.log(6)                   // 输出: 6

关键机制解析

为什么 return Promise.resolve(4) 会导致延迟?

.then() 返回一个 Promise 时,需要等待这个 Promise 完全解决。根据 ECMAScript 规范第 25.4.2.1 和 25.4.2.2 节:

  1. PromiseResolveThenableJob: 第一个微任务,用于调用 Promise 的 then 方法
  2. 传递解决值: 第二个微任务,用于将解决值传递给后续的 .then()

微任务队列可视化

text

时间轴:
t0: [A1, B1]
t1: 执行 A1 → 输出 0, 创建 X1
t2: 执行 B1 → 输出 1, 创建 B2
t3: 队列变为 [X1, B2]
t4: 执行 X1 → 创建 X2
t5: 队列变为 [B2, X2]
t6: 执行 B2 → 输出 2, 创建 B3
t7: 队列变为 [X2, B3]
t8: 执行 X2 → 创建 A2
t9: 队列变为 [B3, A2]
t10: 执行 B3 → 输出 3, 创建 B4
t11: 队列变为 [A2, B4]
t12: 执行 A2 → 输出 4
t13: 队列变为 [B4]
t14: 执行 B4 → 输出 5, 创建 B5
t15: 队列变为 [B5]
t16: 执行 B5 → 输出 6

常见误解澄清

误解一:"所有输出都是顺序的"

  • 错误原因:认为 Promise 链会完全执行完再执行下一个
  • 实际情况:微任务队列交替执行两个 Promise 链的任务

实践建议

1. 避免在 .then() 中返回不必要的 Promise

javascript

// 不推荐
.then(() => {
    return Promise.resolve(someValue)
})

// 推荐(如果不需要异步处理)
.then(() => {
    return someValue
})

2. 理解微任务延迟

当遇到异步操作顺序问题时,考虑:

  • .then() 是否返回了 Promise?
  • 返回的 Promise 是否需要额外的微任务处理?

3. 使用 async/await 提高可读性

javascript

async function example() {
    console.log(0)
    await Promise.resolve()
    const res = await Promise.resolve(4)
    console.log(res)
}
​
async function example2() {
    console.log(1)
    await Promise.resolve()
    console.log(2)
    await Promise.resolve()
    console.log(3)
    await Promise.resolve()
    console.log(5)
    await Promise.resolve()
    console.log(6)
}

浏览器兼容性说明

不同 JavaScript 引擎在 Promise 微任务调度上可能存在差异:

  • V8 (Chrome/Node.js): 遵循最新 ECMAScript 规范
  • SpiderMonkey (Firefox): 可能有细微差异
  • JavaScriptCore (Safari): 实现可能略有不同

总结

Promise 的微任务调度机制是 JavaScript 异步编程的核心。理解 return Promise.resolve() 会引入额外微任务这一特性,对于调试复杂的异步代码至关重要。通过本文的分析,我们可以看到:

  1. 微任务队列管理着所有 Promise 回调的执行顺序
  2. 返回 Promise 的 .then() 需要两个额外微任务来处理
  3. 多个 Promise 链会交替执行,而不是一个链完全执行完再执行另一个

掌握这些细节有助于编写更可靠、可预测的异步 JavaScript 代码。

面试真题

根据下面的代码给出输出顺序,并解释

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

async1();

new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});

console.log('script end');

最终的输出顺序为:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

执行流程分析

1. 同步执行阶段(调用栈)

执行顺序代码输出说明
1console.log('script start')script start同步代码立即执行
2setTimeout(...)-将回调函数添加到宏任务队列
3async1() 调用-进入 async1 函数
3.1console.log('async1 start')async1 startasync1 中的同步代码
3.2await async2()-调用 async2 函数
3.2.1console.log('async2')async2async2 中的同步代码
3.3await 后的代码-被转换为微任务,添加到微任务队列
4new Promise(...)-创建 Promise 实例
4.1console.log('promise1')promise1Promise 构造函数的同步代码
4.2resolve()-立即解析 Promise
4.3.then(...)-将回调函数添加到微任务队列
5console.log('script end')script end最后的同步代码

2. 微任务执行阶段

同步代码执行完毕后,事件循环开始处理微任务队列:

执行顺序微任务来源输出说明
1await async2() 后的代码async1 endasync1 函数中 await 后面的代码
2Promise 的 .then() 回调promise2Promise 解析后的回调函数

3. 宏任务执行阶段

微任务队列清空后,事件循环处理宏任务队列:

执行顺序宏任务来源输出说明
1setTimeout 回调setTimeout定时器回调函数

关键概念解释

事件循环 (Event Loop)

JavaScript 使用单线程事件循环模型处理异步操作,主要包括:

  1. 调用栈 (Call Stack) :执行同步代码
  2. 微任务队列 (Microtask Queue) :Promise 回调、async/await 转换的代码
  3. 宏任务队列 (Macrotask Queue) :setTimeout、setInterval、I/O 操作等

执行优先级

  1. 同步代码(调用栈)
  2. 所有微任务(直到微任务队列为空)
  3. 一个宏任务
  4. 重复步骤 2-3

async/await 转换

javascript

// 原始代码
await async2();
console.log('async1 end');

// 近似转换为
async2().then(() => {
  console.log('async1 end');
});

最终输出顺序

text

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

内存与队列状态变化

初始状态

  • 调用栈:[全局上下文]
  • 微任务队列:[]
  • 宏任务队列:[]

同步代码执行后

  • 调用栈:[]
  • 微任务队列:[async1 end回调promise2回调]
  • 宏任务队列:[setTimeout回调]

微任务执行后

  • 调用栈:[]
  • 微任务队列:[]
  • 宏任务队列:[setTimeout回调]

宏任务执行后

  • 调用栈:[]
  • 微任务队列:[]
  • 宏任务队列:[]

常见陷阱与注意事项

  1. Promise 构造函数是同步执行的,只有 .then().catch() 和 .finally() 的回调是异步的
  2. await 会暂停当前 async 函数的执行,将后续代码作为微任务
  3. 微任务在下一轮事件循环前执行,宏任务在下一轮事件循环执行
  4. 多个微任务会一次性全部执行,直到微任务队列为空