Node.js 的一切都围绕异步展开。但"写异步"这件事本身,在过去十年经历了好几次大的进化。理解这条演进路线,不只是怀旧,而是能帮你看懂为什么现在的写法是这样、以及那些进阶坑为什么会出现。
第一阶段:回调与回调地狱
最早,Node 的异步全靠回调函数。单个回调没问题,但一旦多个异步操作有先后依赖,代码就会向右"缩进成金字塔":
fs.readFile('a.txt', 'utf8', (err, a) => {
if (err) return handle(err)
fs.readFile('b.txt', 'utf8', (err, b) => {
if (err) return handle(err)
db.query('SELECT ...', (err, rows) => {
if (err) return handle(err)
sendMail(rows, (err) => {
if (err) return handle(err)
console.log('终于完成了') // 已经缩进了五层
})
})
})
})这就是臭名昭著的回调地狱(Callback Hell)。它的问题不只是难看:
- 每一层都要重复写
if (err),错误处理散落各处 - 逻辑顺序和代码缩进强绑定,改顺序就是噩梦
- 没法用
try/catch,没法用return值
第二阶段:Promise 把嵌套拍平
Promise 用"链式调用"取代了嵌套。它代表一个"将来才有结果的值",有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败),且状态一旦确定就不可变。
上面的金字塔被拍平成一条链:
readFilePromise('a.txt')
.then((a) => readFilePromise('b.txt'))
.then((b) => db.query('SELECT ...'))
.then((rows) => sendMail(rows))
.then(() => console.log('完成'))
.catch((err) => handle(err)) // 一个 catch 兜住整条链的错误关键进步:错误处理统一了。链上任何一环 reject,都会直接跳到 .catch,不用每层都写 if (err)。
第三阶段:async/await 让异步读起来像同步
Promise 虽好,但 .then 链长了依然不够直观。async/await 是建立在 Promise 之上的语法糖,让异步代码读起来跟同步一样:
async function run() {
try {
const a = await readFilePromise('a.txt')
const b = await readFilePromise('b.txt')
const rows = await db.query('SELECT ...')
await sendMail(rows)
console.log('完成')
} catch (err) {
handle(err) // 熟悉的 try/catch 回来了
}
}注意两点:
async函数总是返回一个 Promiseawait后面跟一个 Promise,会"暂停"函数执行直到它 resolve,但不会阻塞主线程(事件循环照常处理别的任务)- 错误处理回归到大家最熟悉的
try/catch
到这里,异步代码终于写得"像人话"了。但 async/await 用顺手之后,新的坑也来了。
进阶坑一:该并发的别写成串行
await 最容易被滥用的地方,就是把本可以并发的操作写成了串行:
// ❌ 串行:三个请求互不依赖,却排队等,总耗时 = 三者之和
const user = await getUser()
const posts = await getPosts()
const comments = await getComments()这三个请求之间没有依赖关系,完全可以同时发出。用 Promise.all 并发,总耗时降到"最慢的那一个":
// ✅ 并发:同时发出,总耗时 = max(三者)
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
])判断标准:后一个操作是否依赖前一个的结果。不依赖,就该并发。
进阶坑二:Promise.all 一败俱败
Promise.all 有个特性:只要有一个 reject,整体立刻 reject,其余结果你就拿不到了。如果你想"无论成败都拿到每个结果",用 Promise.allSettled:
const results = await Promise.allSettled([
getUser(),
getPosts(), // 假设这个失败了
getComments(),
])
// allSettled 不会因为一个失败就丢掉全部
results.forEach((r) => {
if (r.status === 'fulfilled') console.log('成功', r.value)
else console.log('失败', r.reason)
})几个 Promise 组合方法的区别值得记牢:
| 方法 | 行为 |
|---|---|
Promise.all |
全部成功才成功,一个失败就整体失败 |
Promise.allSettled |
等全部完成,返回每个的成功/失败结果,不会整体失败 |
Promise.race |
返回最先完成的(无论成功失败),常用于超时控制 |
Promise.any |
返回最先成功的,全部失败才失败 |
进阶坑三:forEach 里的 await 是假的
这是 async/await 最隐蔽的坑之一。forEach 不会等待里面的 async 回调:
// ❌ 看起来在等,其实没等。"全部处理完"会在任何一个真正完成前就打印
items.forEach(async (item) => {
await process(item)
})
console.log('全部处理完') // 骗人的原因是 forEach 根本不理会回调的返回值(Promise)。要顺序处理,用 for...of:
// ✅ 顺序执行,真正等每个完成
for (const item of items) {
await process(item)
}
console.log('全部处理完')要并发处理,用 map 生成 Promise 数组再 Promise.all:
// ✅ 并发执行
await Promise.all(items.map((item) => process(item)))进阶坑四:无限并发会打爆下游
Promise.all(items.map(...)) 很爽,但如果 items 有一万条,你会同时发出一万个请求,瞬间打爆数据库或被对方限流。生产中需要限制并发数:
// 简易的并发池:同时最多 limit 个在跑
async function asyncPool(limit, items, fn) {
const results = []
const executing = new Set()
for (const item of items) {
const p = Promise.resolve().then(() => fn(item))
results.push(p)
executing.add(p)
p.finally(() => executing.delete(p))
if (executing.size >= limit) {
await Promise.race(executing) // 等任意一个腾出位置
}
}
return Promise.all(results)
}
// 同时最多 5 个并发
await asyncPool(5, urls, (url) => fetch(url))生产里也可以直接用成熟的库(如
p-limit)来做并发控制,原理与上面一致。关键是别让并发数失控。
小结
- 异步写法一路演进:回调 → Promise → async/await,本质都是在管理"将来的值"
- 回调地狱的痛点是嵌套深、错误处理散;Promise 用链式 + 统一
catch解决;async/await 让代码读起来像同步 - 进阶四大坑:
- 不依赖的操作别串行,用
Promise.all并发 - 想"一败不俱败"用
Promise.allSettled,分清all/allSettled/race/any forEach不等 await,顺序用for...of,并发用map + Promise.all- 大批量任务要限制并发数,别打爆下游
- 不依赖的操作别串行,用
写好异步不是会用 await 就行,而是清楚每个操作之间的依赖关系,在"串行、并发、限并发"之间做对选择。