学完这篇,你能把一段层层嵌套的回调代码改写成 Promise 版本、再改写成 async/await 版本,并知道每步改写时容易出什么错。
异步编程解决的是一件事:不让耗时的操作把主线程卡住。JS 是单线程的,网络请求、定时器、文件读取都必须异步执行。随着业务复杂度提升,写异步代码的方式经历了三个阶段。
第一步:回调函数——能跑,但很快就失控
最早的做法是把"成功之后做什么"作为函数传进去:
function getUser(id, cb) {
setTimeout(() => cb(null, { id, name: '张三' }), 500);
}
getUser(1, (err, user) => {
if (err) return console.error(err);
console.log(user.name);
});
这里有个约定需要记住:错误优先回调(error-first callback),回调的第一个参数是错误对象,第二个才是结果。Node.js 的 API 至今遵循这个约定。
单层没问题,但一旦出现依赖关系:
getUser(1, (err, user) => {
if (err) return handle(err);
getOrders(user.id, (err, orders) => {
if (err) return handle(err);
getDetail(orders[0].id, (err, detail) => {
if (err) return handle(err);
console.log(detail);
});
});
});
三个依赖就要三层缩进,五个依赖就是"回调金字塔",错误处理还散落在各处。这就是所谓的 Callback Hell。
注意:用 return cb(...) 而不是只写 cb(...),否则出错后代码还会继续往下执行,容易造成"回调被调用两次"的诡异 bug。
第二步:Promise——把嵌套拍平成链式调用
Promise 是一个代表"未来结果"的对象,只有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。状态一旦改变就不可逆。
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve({ id, name: '张三' }), 500);
});
}
getUser(1)
.then(user => getOrders(user.id))
.then(orders => getDetail(orders[0].id))
.then(detail => console.log(detail))
.catch(err => handle(err)); // 一处统一捕获
关键变化有两个:嵌套变成链式,错误统一交给 catch。.then 里 return 的值会自动被包成新的 Promise,所以可以直接串起来。
两把好用的工具:
// 并发执行,全部成功后返回结果数组,耗时取决于最慢的那个
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// 并发执行,结果数组里带 {status, value/reason},不会因为一个失败全挂
const results = await Promise.allSettled([fetchA(), fetchB()]);
注意:.forEach 里 await 不生效,外层拿到的数组是空的。要串行用 for...of,要并发用 Promise.all 配合 map。
第三步:async/await——用同步的写法写异步
async/await 是 Promise 的语法糖,本质没变,只是写法更像同步代码:
async function showDetail() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id);
const detail = await getDetail(orders[0].id);
console.log(detail);
} catch (err) {
handle(err); // 三步的异常都在这里接住
}
}
记住三条规则就够了:
async 函数永远返回 Promise,return 1 等价于 return Promise.resolve(1);
await 后面跟 Promise 时,等的是 resolve 的值;如果 reject,会当场抛异常;
try/catch 能捕获链路上任意一步的错误,不用每步都判 err。
注意:await 会串行执行。上面获取订单和详情的三次请求是串行的,总耗时是三次之和。如果这几个请求互不依赖,用 Promise.all 并发,否则白等。
第四步:用真实场景核对一遍
把回调版改写成 async/await 时,最容易踩的是并发优化:
// 慢:串行,三次请求耗时相加
const user = await getUser(1);
const articles = await getArticles(1);
const notices = await getNotices(1);
// 快:互不依赖,并发发起
const [user, articles, notices] = await Promise.all([
getUser(1), getArticles(1), getNotices(1)
]);
判断标准很简单:后一次请求的参数,是否依赖前一次的结果?不依赖就并发。
注意:async 函数里的异常如果没有 try/catch,会变成 unhandled rejection。在 Node 里可能直接导致进程告警甚至退出,浏览器控制台也会报红。最外层函数建议统一包一层 try/catch。
小结
- 回调:错误优先约定,
return cb(),嵌套一深就成金字塔。
- Promise:三状态不可逆,链式
.then 解决嵌套,统一 .catch 解决错误分散。Promise.all 并发全成功、Promise.allSettled 并发容错。
- async/await:Promise 的语法糖,
await 后接 Promise,用 try/catch 捕获异常。
- 最容易踩的坑:把本可以并发的请求写成串行 await;
forEach 里 await 无效。
- 三者不是替代关系,
async/await 底层仍然是 Promise,混用很正常。