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 上) |
实际项目中的应用建议
- 在所有可能的地方使用 async/await 替代纯 Promise 链,提升代码可维护性。
- 将 async 函数看作返回 Promise 的普通函数:可以继续用
.then()或与其他 Promise 组合,不必在所有调用处都await。 - 避免混合使用 async/await 和旧式的回调 API。如果必须使用回调,用
util.promisify将其转为 Promise,再await。 - 注意性能开销:每个
await背后都有 Promise 和微任务的调度开销,但对大部分 I/O 密集型应用而言,这种开销完全可以接受,相比代码清晰度的收益是值得的。 - 在 Express/Koa 等框架中,中间件可以是 async 函数,但要确保错误被捕获并传递给错误处理中间件(Express 4 不会自动捕获 async 函数的错误,需要使用 express-async-errors 或显式 try/catch)。
async/await 是 Node.js 异步编程中最具表达力的工具,它将我们从回调嵌套和冗长 then 链中解放出来,使得异步代码的逻辑一目了然。在掌握了它的原理和正确用法之后,你可以自信地用它来构建清晰、健壮、高性能的服务端应用。