在不改变行为的前提下将 Promise 链重构为 Async/Await 语法
如何将 .then() 链转换为易读的 async/await 代码,await 实际上会暂停什么操作,何时应并行执行各步骤,以及 try/catch 如何替代 .catch()。
来源: Async/Await in JavaScript: Writing Cleaner Asynchronous Code
Promise 使 JavaScript 免于陷入嵌套回调的困境,但一系列相互依赖的 .then() 调用依然看起来像复杂的管道结构,而非简单的指令序列。async/await 通过让异步代码呈现出类似普通自上而下执行代码的形式,同时保留其背后的 Promise 机制,从而填补了这一空白。本指南将介绍如何安全地转换 Promise 链、await 实际上会暂停什么操作、何时顺序使用 await 会造成时间浪费,以及错误处理方式如何变化,这样你就可以在不改变代码功能的前提下对其进行重构。
从 then 链转换为线性函数
以下是一个典型的链式结构,其中每一步都依赖于前一步的结果:先加载用户信息,再加载其订单,接着加载这些订单的收据,同时有一个处理程序用于应对任何失败情况。
getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.then(receipts => {
console.log(receipts);
})
.catch(err => handleError(err));
这种做法是正确的,也远优于嵌套回调,但仍然需要通过可视化方式追踪数据流。ES2017标准中引入的async/await正是为了解决这一最后的问题。用相同逻辑重写后的代码如下:
async function loadData(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
console.log(receipts);
} catch (err) {
handleError(err);
}
}
其运行方式与链式结构一致:每一步只有在前一步完成后才会开始,任何错误都会被统一处理在同一个错误处理函数中。变化在于可读性——现在的每个中间值都有了名称,代码流程也从上到下依次呈现。
需始终牢记一点:async/await并不能替代Promise,它只是Promise的语法糖。异步操作仍由Promise来完成,这种语法仅改变了代码的书写与阅读方式。
async关键字带来的变化
在函数前加上async会有两种效果。下面的函数会返回一个普通对象:
async function getUser(id) {
return { id, name: "Aarav" };
}
它始终返回一个承诺对象
尽管getUser返回的是普通对象,但调用它时会得到一个承诺对象,该对象最终会解析为那个普通对象。因此调用者可以像处理其他承诺对象一样来使用它:
getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }
如果函数抛出异常,返回的承诺对象会拒绝执行,而错误不会以同步方式传播。当原本是同步函数的代码被改为async时,人们很容易忘记这一点:所有直接使用该函数返回值的调用者现在都会收到一个承诺对象,因此需要相应调整。
它允许在函数体内使用await
await 可以在 async 函数中使用。现代 JavaScript 还允许在 ES 模块中使用顶层 await,这便于脚本和模块的初始化,但在普通函数中仍然需要 async。
await 实际上暂停了什么
await 表达式会将控制权交还,直到其对应的承诺以某种方式得到解决。如果成功,则该表达式会返回已解决的值;如果失败,则该行会像 throw 语句一样抛出异常。示例中会先输出一条消息,等待用户操作,然后再输出结果:
async function loadUser() {
console.log("Fetching user...");
const user = await getUser(1); // pauses here until getUser's promise settles
console.log("Got user:", user);
}
输出内容按以下顺序显示:
Fetching user...
Got user: { id: 1, name: "Aarav" }
关键在于“暂停”具体意味着什么。只有这个函数会在那一行被挂起,而程序的其余部分,包括其他代码、定时器以及服务器上接收到的请求,都会继续运行。当承诺对象解析后,该函数会从暂停处继续执行,并将解析后的值赋给相应变量。
顺序调用的 awaits 及其隐藏成本
多个 awaiting 会依次连续执行:
async function loadDashboard(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
}
每一行都会在开始下一项操作之前等待当前步骤完成,这样就实现了类似链式 .then() 的效果,但并没有真正的链式结构。当每一步都需要前一步的结果时,这种方式是正确的——比如在还不知道用户信息之前就无法获取订单数据。
当将相同的处理方式应用于彼此无关的操作时,问题就出现了。在下面的控制面板加载器中,用户相关功能、通知以及推荐内容都是相互独立的,但每个请求都会等待前一个请求处理完成:
async function loadDashboard() {
const user = await getUser();
const notifications = await getNotifications();
const recommendations = await getRecommendations();
return { user, notifications, recommendations };
}
总耗时变为这三个请求的处理时间之和,而非最慢的那个请求的时间。
同时启动独立任务
由于这三个调用是相互独立的,你可以立即启动它们,并使用 Promise.all() 等待所有请求处理完毕:
async function loadDashboard() {
const [user, notifications, recommendations] = await Promise.all([
getUser(),
getNotifications(),
getRecommendations()
]);
return { user, notifications, recommendations };
}
这三个请求同时处于处理状态,通过解构可以按与输入数组相同的顺序获取结果。需要注意的一点是:Promise.all()只有当所有承诺都成功时才会满足,而一旦有任何一个承诺失败,它就会立即拒绝,不会等待其余的承诺。如果无论是否出现错误都希望获取所有结果,那么Promise.allSettled()更为合适。关于这些方法的权衡比较,可参阅Promise.all、Promise.race与顺序await的选择。
使用try和catch处理错误
在async/await语法中,熟悉的try/catch块取代了.catch(),因此同步和异步错误最终都使用同一套结构来处理:
async function loadUser(id) {
try {
const user = await getUser(id);
console.log(user);
} catch (err) {
console.log("Failed to load user:", err.message);
}
}
如果 getUser(id) 返回的承诺被拒绝,控制流会直接跳转到 catch 块,这与同步代码抛出错误时的处理方式完全相同。需要注意的是,该函数会处理错误后正常结束,因此其自身的承诺会以 undefined 的值被满足。调用者无法察觉到加载失败的情况。当调用者需要作出响应时,应在记录日志后重新抛出异常,或直接返回明确的结果。
多个步骤使用同一个处理函数
一个 try 块即可覆盖整个 await 序列:
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
} catch (err) {
console.log("Something failed in the dashboard load:", err.message);
}
}
无论哪一步出错,执行都会直接跳转到 catch 块,因此无需为每一步都编写处理程序。这体现了 .catch() 在链式结构末尾所具有的集中处理作用。当不同步骤需要不同的恢复方式时,例如仅重新尝试收据请求,可在该步骤周围嵌套一个范围更小的 try 块。
两种风格的并列对比
以下是同一种加载器的两种实现形式:
// Promises
function loadDashboard(id) {
return getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.catch(err => console.log("Failed:", err.message));
}
// async/await
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return await getReceipts(orders);
} catch (err) {
console.log("Failed:", err.message);
}
}
两个版本都不会改变底层的异步处理逻辑。选择哪种方式取决于哪一种更易于人们阅读和维护,而对于多步骤逻辑来说,async/await通常更具优势。第二个版本中有一个细微之处:在try块内使用return await getReceipts(orders)非常重要。如果写成return getReceipts(orders)而不加await,则会在承诺状态尚未确定时就将其返回,这样一来最后一步出现的拒绝错误就会完全逃出catch块。
常见问题
await会阻塞整个应用程序吗?
不会。它仅暂停包含它的异步函数,而其他代码,包括其他请求,在该函数等待期间仍会继续执行。
可以在异步函数之外使用await吗?
普通函数中不可用。例外情况是 ES 模块中的顶层 await,它可以在模块级别使用,但无法在普通的非异步函数内部使用。
async/await 比 promises 更快吗?
不。它基于 promises 构建,因此更换语法并不会带来速度优势。其好处在于提升可读性和可维护性。真正的性能提升来自于并行执行独立任务,而非该关键字本身。
没有 try/catch 会怎样?
如果正在等待的 promise 被拒绝且没有代码捕获该错误,那么异步函数自身的 promise 也会被拒绝。如果调用方也没有处理这一拒绝情况,运行时就会报告“未处理的拒绝错误”,在当前版本的 Node.js 中这会默认终止进程。应在函数内部使用 try/catch 结构,或在调用该函数的地方使用 .catch() 方法来处理错误。有关更多此类问题,可参阅导致生产环境错误的常见 async/await 误区。
关键要点
async/await只是改变了异步代码的编写方式,而非其运行方式;实际工作仍由 promises 完成。async函数始终会返回一个 promise,因此将函数转换为async类型会改变其对所有调用方的接口定义。
await仅会暂停其所在函数的执行,而不会影响整个程序。Promise.all()或Promise.allSettled()同时启动独立的异步操作。await语句周围使用try/catch结构可以集中处理错误;当调用者需要知晓错误时记得重新抛出异常,并在try块内使用return await。