人人都会AI编程

async/await 语法糖:同步写法的异步代码

更新时间:2026-07-11

Callback 让我们能够处理异步结果,但层层嵌套的写法容易让代码演变成灾难性的“回调地狱”。Promise 通过链式调用解决了嵌套问题,但过多的 .then().catch() 仍然会让逻辑显得分散、不直观。从 ES2017 开始,JavaScript 引入了 async/await,它并不是一套新的异步机制,而是建立在 Promise 之上的语法糖——让你可以用书写同步代码的方式来组织异步流程,同时保留非阻塞的执行特性。

async/await 的本质

  • async 关键字用于声明一个函数是异步的。调用 async 函数时,它始终会返回一个 Promise 对象。如果函数内部返回一个非 Promise 值,它会自动被包裹成一个 resolved 的 Promise。
  • await 关键字只能在 async 函数内部使用。它会暂停当前 async 函数的执行,等待右侧的 Promise 完成(resolved 或 rejected),然后恢复执行并返回结果或抛出异常。

关键在于:await 只暂停了当前 async 函数的执行,不会阻塞事件循环或主线程。 在暂停期间,JavaScript 引擎可以自由地去执行其他任务(如处理新的请求、执行其他定时器等)。这正是它与传统同步阻塞调用的根本区别。

从 Promise 链到 async/await

假设我们有一个需求:从数据库查询用户,然后根据用户 ID 获取其订单列表,最后计算订单总金额。用 Promise 链式调用大概是这样的:

function getTotalAmount(userId) {
  return getUser(userId)
    .then(user => getOrders(user.id))
    .then(orders => orders.reduce((sum, o) => sum + o.amount, 0))
    .catch(err => {
      console.error('获取数据失败:', err);
      return 0;
    });
}

逻辑虽然串联起来了,但阅读顺序和思维流不完全一致:数据是自上而下传递的,但我们的视线需要在 .then 之间跳跃。如果用 async/await 重写:

async function getTotalAmount(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const total = orders.reduce((sum, o) => sum + o.amount, 0);
    return total;
  } catch (err) {
    console.error('获取数据失败:', err);
    return 0;
  }
}

这段代码从上到下读起来就像普通的同步函数:先拿到用户,再拿订单,再计算总和。每一步 await 都等待异步操作完成,然后把结果赋给变量。错误处理则可以直接使用我们熟悉的 try/catch 结构,不需要在 Promise 链的最后添加 .catch

这就是 async/await 最核心的价值:保持代码平坦、顺序清晰,降低认知负担。

错误处理的最佳实践

使用 async/await 时,错误处理有两种常见模式:

模式一:try/catch 包裹多个 await

async function processRequest(req, res) {
  try {
    const data = await validateInput(req.body);
    const result = await saveToDatabase(data);
    res.json({ success: true, id: result.id });
  } catch (err) {
    // 统一捕获 try 块内任何 await 抛出的异常
    res.status(500).json({ error: err.message });
  }
}

适用于多个连续的异步操作共享相同的错误处理逻辑。

模式二:针对单个 await 单独处理

async function fetchConfig() {
  const response = await fetch('/config').catch(err => {
    console.warn('配置加载失败,使用默认值:', err);
    return null;
  });
  return response || defaultConfig;
}

当你需要为某个特定的异步操作提供降级方案(fallback)时,可以利用 Promise 的 .catch 直接在 await 位置处理错误,避免外围 try/catch 过于臃肿。

需要注意的是:忘记写 try/catch 会导致未捕获的 Promise 拒绝,在 Node.js 中可能触发 unhandledRejection 进程级事件,极端情况下甚至导致进程退出。因此,务必在合适的位置捕获错误。

async/await 中的并发控制

await 会顺序等待,这有时不是我们想要的。当两个异步操作彼此独立时,顺序等待会不必要地延长总耗时。

错误示例(顺序执行,耗时累加):

const user = await getUser(userId);       // 耗时 200ms
const profile = await getProfile(userId); // 耗时 150ms,总耗时 350ms

这两个操作互不依赖,完全可以并发执行。做法是利用 Promise.all 组合多个 Promise,然后 await 这个组合:

const [user, profile] = await Promise.all([
  getUser(userId),
  getProfile(userId)
]);
// 总耗时 ≈ max(200ms, 150ms) = 200ms

对于需要同时发起多个请求但部分失败也要继续的场景,可以使用 Promise.allSettled

const results = await Promise.allSettled([
  fetchServiceA(),
  fetchServiceB(),
  fetchServiceC()
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`服务${index}返回:`, result.value);
  } else {
    console.error(`服务${index}失败:`, result.reason);
  }
});

await 与事件循环:不要阻塞主线程

虽然 await 让代码看起来像同步执行,但它并不会阻塞事件循环。下面这个例子可以验证:

async function run() {
  console.log('A');
  await sleep(1000); // sleep 返回一个 Promise,在 1 秒后 resolve
  console.log('B');
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

run();
console.log('C');
// 输出顺序: A, C, B

run 在遇到 await 时暂停,立即返回一个未完成的 Promise,主线程继续执行后面的 console.log('C')。一秒钟后,定时器触发,sleep 的 Promise resolved,run 恢复执行,打印 B。这种非阻塞特性使得 async/await 完全适配 Node.js 的事件循环模型。

然而,有一点需要警惕:await 暂停期间,主线程并没有被占用,但如果你在 async 函数内部进行了长时间的同步计算,仍然会阻塞事件循环。 例如:

async function heavyTask() {
  const data = await fetchData(); // 异步等待,主线程空闲
  // 下面是 CPU 密集计算,阻塞事件循环
  for (let i = 0; i < 1e9; i++) {
    // heavy computation
  }
  return process(data);
}

这种写法会让服务在计算期间完全无法响应其他请求。解决办法是要么将计算拆分为多个异步步骤(用 setImmediate 分段),要么使用 worker_threads 将计算任务分发到后台线程。

顶层 await(Top-level await)

在 Node.js 14.8+ 的 ES Modules 中,await 可以直接用在模块的顶层,而不必包裹在 async 函数中:

// config.mjs
import { readFile } from 'fs/promises';

const raw = await readFile('./config.json', 'utf-8');
export const config = JSON.parse(raw);

这在初始化模块时非常实用,但要注意它会延迟模块的加载直到 Promise 完成,因此也可能延长应用的冷启动时间。在 CommonJS 模块中(require),顶层 await 不被支持,这也是选择 ESM 的一个考量因素。

async/await 与传统异步模式的对比总结

| 特性 | Callback | Promise | async/await |
|------|----------|---------|-------------|
| 可读性 | 差(回调地狱) | 中等(链式调用) | 好(同步风格) |
| 错误处理 | 回调参数或 try/catch 无效 | .catch 链 | try/catch |
| 并发控制 | 需要第三方库(async.js) | Promise.all 等 | Promise.all + await |
| 调试 | 困难(调用栈不清晰) | 一般 | 友好(完整的同步调用栈) |
| 学习曲线 | 低 | 中 | 低(建立在 Promise 上) |

实际项目中的应用建议

  1. 在所有可能的地方使用 async/await 替代纯 Promise 链,提升代码可维护性。
  2. 将 async 函数看作返回 Promise 的普通函数:可以继续用 .then() 或与其他 Promise 组合,不必在所有调用处都 await
  3. 避免混合使用 async/await 和旧式的回调 API。如果必须使用回调,用 util.promisify 将其转为 Promise,再 await
  4. 注意性能开销:每个 await 背后都有 Promise 和微任务的调度开销,但对大部分 I/O 密集型应用而言,这种开销完全可以接受,相比代码清晰度的收益是值得的。
  5. 在 Express/Koa 等框架中,中间件可以是 async 函数,但要确保错误被捕获并传递给错误处理中间件(Express 4 不会自动捕获 async 函数的错误,需要使用 express-async-errors 或显式 try/catch)。

async/await 是 Node.js 异步编程中最具表达力的工具,它将我们从回调嵌套和冗长 then 链中解放出来,使得异步代码的逻辑一目了然。在掌握了它的原理和正确用法之后,你可以自信地用它来构建清晰、健壮、高性能的服务端应用。