异步编程的"语法糖":为什么 async/await 甜到犯规

22 阅读6分钟

Async/Await:更优雅的异步编程方式

从 Promise 到 Async/Await 的演进

先看经典的异步代码:

function A(){
    return new Promise((resolve,reject) => {
        setTimeout(() => {
            console.log('异步A完成');
            resolve()
        }, 1000);
    })
}

function B(){
    return new Promise((resolve,reject) => {
        setTimeout(() => {
            console.log('异步B完成');
            resolve()
        },500);
    })
}

function C(){
    return new Promise((resolve,reject) => {
        setTimeout(() => {
            console.log('异步C完成');
            resolve()
        },100)
    })
}

async function foo() { // 加了async就等同于return了一个promise
    await A() // await会阻塞后续代码,将后续代码推入微任务队列
    console.log(1);
    await B()
    await C()
}
foo()
// console.log(foo()); // 打印Promise { undefined }

让我们先看看提供的 async/await 示例:

async function foo() {
    await A() // 等待A完成
    console.log(1);
    await B() // 等待B完成
    await C() // 等待C完成
}
foo()

这段代码清晰表达了"先执行A,然后打印1,再执行B,最后执行C"的顺序逻辑,几乎就像写同步代码一样直观。

如果没有 Async/Await,代码会怎样?

如果用纯 Promise 实现相同的逻辑,代码会变成这样:

function foo() {
    return A()
        .then(() => {
            console.log(1);
            return B();
        })
        .then(() => {
            return C();
        });
}
foo();

或者更紧凑但可读性稍差的写法:

function foo() {
    return A()
        .then(() => console.log(1))
        .then(B)
        .then(C);
}

Async/Await 的优势

1. 代码更简洁直观

  • async/await:看起来像同步代码,从上到下顺序执行
  • Promise链:需要嵌套或链式调用,结构相对复杂

2. 错误处理更简单

async/await 可以使用传统的 try/catch:

async function foo() {
    try {
        await A();
        console.log(1);
        await B();
        await C();
    } catch (err) {
        console.error('出错:', err);
    }
}

对比 Promise 的错误处理:

function foo() {
    return A()
        .then(() => console.log(1))
        .then(B)
        .then(C)
        .catch(err => console.error('出错:', err));
}

虽然看起来差异不大,但在复杂逻辑中,try/catch 的结构更易于管理。

3. 调试更方便

async/await 代码在调试时:

  • 可以像同步代码一样设置断点
  • 调用栈更清晰
  • 不需要处理 Promise 的"跳跃式"执行流程

4. 条件逻辑更易表达

考虑以下需要条件判断的场景:

async function foo(shouldRunB) {
    await A();
    console.log(1);
    if (shouldRunB) {
        await B();
    }
    await C();
}

用 Promise 实现会变得复杂:

function foo(shouldRunB) {
    return A()
        .then(() => console.log(1))
        .then(() => shouldRunB ? B() : null)
        .then(() => C());
}

深入理解 Async/Await

async 函数的特点

  1. 总是返回一个 Promise
  2. 函数内部可以使用 await 关键字
  3. 如果返回非 Promise 值,会自动包装为 resolved Promise

await 的工作机制

  1. 暂停 async 函数的执行
  2. 等待 Promise 解决(resolve 或 reject)
  3. 恢复 async 函数的执行
  4. 返回解决后的值(如果是 reject 则抛出异常)

何时使用 Promise 而不是 Async/Await

虽然 async/await 很强大,但在某些场景下 Promise 仍然更适合:

  1. 并行操作:使用 Promise.all

    async function foo() {
        const [resultA, resultB] = await Promise.all([A(), B()]);
        // ...
    }
    
  2. 更高级的组合:如 Promise.racePromise.any

  3. 需要手动控制 Promise 时:如取消操作、进度通知等

巩固

下面我们通过这段代码来深入理解 JavaScript 的Async/Await语法糖和事件循环机制,特别是 async/await 与 Promise 的执行顺序:

console.log('script start')

async function async1() {
    await async2() // 注意这里的执行顺序
    console.log('async1 end')
}

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

async1()

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

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

console.log('script end')

代码执行顺序解析

1. 同步代码执行阶段

首先执行所有同步代码:

  1. console.log('script start') → 输出 "script start"
  2. 调用 async1()
    • 进入 async1 函数,遇到 await async2()
    • 调用 async2(),执行 console.log('async2 end') → 输出 "async2 end"
  3. 遇到 setTimeout,将其回调函数放入宏任务队列
  4. 执行 new Promise 的构造函数部分 console.log('Promise') → 输出 "Promise"
    • 立即 resolve(),将第一个 .then() 放入微任务队列
  5. console.log('script end') → 输出 "script end"

此时输出顺序:

script start
async2 end
Promise
script end

2. 微任务队列处理

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

  1. 第一个微任务:Promise 的第一个 .then() 回调
    • console.log('promise1') → 输出 "promise1"
    • 这个回调返回 undefined,相当于返回一个 resolved Promise
    • 将第二个 .then() 放入微任务队列
  2. 第二个微任务:await async2() 之后的代码
    • 相当于 Promise.resolve(undefined).then(() => { console.log('async1 end') })
    • 但是浏览器优化后,这个微任务会排在 promise1 之后
  3. 第三个微任务:Promise 的第二个 .then() 回调
    • console.log('promise2') → 输出 "promise2"

此时输出顺序增加:

promise1
async1 end
promise2

3. 宏任务队列处理

最后处理宏任务队列:

  1. setTimeout 的回调函数执行
    • console.log('setTimeout') → 输出 "setTimeout"

完整执行顺序总结

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

关键概念解释

1. 任务队列优先级

  • 微任务队列(Microtask queue):

    • Promise 的 .then()/.catch()/.finally() 回调
    • async 函数中 await 后面的代码
    • queueMicrotask() 添加的任务
    • 优先级高于宏任务,会在当前宏任务执行完后立即执行所有微任务
  • 宏任务队列(Macrotask queue):

    • setTimeout/setInterval
    • I/O 操作
    • UI 渲染
    • 事件回调

2. Async/Await 的底层实现

await 实际上会被转换为 Promise 链:

async function async1() {
    await async2()
    console.log('async1 end')
}
// 等价于
function async1() {
    return Promise.resolve(async2()).then(() => {
        console.log('async1 end')
    })
}

3. 为什么 "async1 end" 在 "promise1" 之后?

虽然 await 会创建微任务,但浏览器引擎通常会优化执行顺序:

  1. 当前同步代码中的 Promise 回调会先进入微任务队列
  2. await 产生的微任务会后进入队列
  3. 微任务队列按先进先出顺序执行

4. await不是相当于.then吗,为什么不用进入微任务队列

现代浏览器会对 await 做特殊优化:

  • 早期实现:每个 await 会生成2个微任务
  • 现代优化:V8 引擎会减少不必要的微任务
  • 但核心规则不变:await 之后的代码一定会作为微任务执行

常见误区

  1. 认为 await 会阻塞整个线程

    • 实际上只暂停当前 async 函数的执行
    • 不会阻塞其他同步代码或事件循环
  2. 忽略微任务的执行顺序

    • 同一层级的微任务有明确的执行顺序
    • 不是所有异步代码都平等
  3. 混淆宏任务和微任务

    • setTimeout 是宏任务
    • Promise 和 await 是微任务
    • 微任务会在宏任务之前执行

理解这些执行顺序细节对于调试复杂异步代码和避免竞态条件非常重要。这也是 JavaScript 面试中经常考察的重点内容。

总结

Async/await 是建立在 Promise 之上的语法糖,它:

  • 让异步代码看起来像同步代码
  • 减少了嵌套,提高了可读性
  • 简化了错误处理
  • 改善了调试体验

对于大多数异步编程场景,async/await 都能提供更清晰、更易维护的代码结构。它代表了 JavaScript 异步编程的现代最佳实践,是每个 JS 开发者都应该掌握的技能。