重新思考 JavaScript 中的异步循环
在循环中使用 await 看起来很直观,直到你的代码默默停滞或运行得比预期的慢。如果你曾经想知道为什么你的 API 调用是一个接一个地运行而不是一次性全部运行,或者为什么 map() 和 await 的组合不符合你的预期,请坐下来聊聊吧。
问题:在 for 循环中等待
假设你要逐个获取用户列表:
const users = [1, 2, 3];
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
这可以工作,但是它是顺序运行的:fetchUser(2) 不会开始,直到 fetchUser(1) 完成。如果顺序很重要,这没问题,但对于独立的网络调用来说,这是低效的。
不要在 map() 内部使用 await,除非你真的想这样做
一个常见的困惑点是在 map() 内部使用 await 而不处理结果 promise:
const users = [1, 2, 3];
const results = users.map(async (id) => {
const user = await fetchUser(id);
return user;
});
console.log(results); // [Promise, Promise, Promise] – NOT 实际的用户数据
这在语法和行为上都是可以的(它返回一个 promise 数组),但并不符合许多人的预期。它不会等待 promise 完成。
要并行运行调用并获得最终结果:
const results = await Promise.all(users.map((id) => fetchUser(id)));
现在所有请求都并行运行,results 包含实际获取的用户数据。
只要有一个调用失败 Promise.all() 状态立即变为失败
当使用 Promise.all() 时,一次 rejected就会导致整个操作失败:
const results = await Promise.all(
users.map((id) => fetchUser(id)), // fetchUser(2) 可能会抛出错误
);
如果 fetchUser(2) 抛出错误(例如 404 或网络错误),整个 Promise.all 调用将拒绝,并且不会返回任何结果(包括成功的那些)。
注意:
Promise.all()在第一个错误时失败,丢弃其他结果。剩余的promise仍然运行,但除非你单独处理每一个,否则只报告第一个失败请求。
更安全的替代方案
使用 Promise.allSettled()
const results = await Promise.allSettled(users.map((id) => fetchUser(id)));
results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('✅ User:', result.value);
} else {
console.warn('❌ Error:', result.reason);
}
});
当你想要处理所有结果时使用它,即使有些失败了。
在映射函数内部处理错误
const results = await Promise.all(
users.map(async (id) => {
try {
return await fetchUser(id);
} catch (err) {
console.error(`Failed to fetch user ${id}`, err);
return { id, name: 'Unknown User' }; // 后备值
}
}),
);
这还可以防止未处理的 promise rejected,这在更严格的环境中(如带有 --unhandled-rejections=strict 的 Node.js)可能会触发警告或导致进程崩溃。
现代解决方案
使用 for...of + await(顺序执行)
当下一个操作依赖于前一个操作的结果时,或者当 API 速率限制要求时使用:
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
或者如果你不在 async 函数上下文中:
(async () => {
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
})();
- 保持顺序
- 适用于速率限制或批处理
- 对于独立请求较慢
使用 Promise.all + map()(并行执行)
当操作是独立的并且可以同时执行时使用:
const usersData = await Promise.all(users.map((id) => fetchUser(id)));
- 对于网络密集型或 CPU 独立的任务要快得多
- 一次拒绝会导致整个批处理失败(除非已处理)
使用 Promise.allSettled() 或内联 try/catch 来实现更安全的批处理。
对于短的、CPU 密集型任务,并行性可能不会产生明显的差异。但对于 I/O 密集型操作(如 API 调用),并行性可以显著减少总执行时间。
节流并行(受控并发)
当你需要速度但必须尊重 API 限制时,使用像 p-limit 这样的节流工具:
import pLimit from 'p-limit';
const limit = pLimit(2); // 一次运行 2 个获取操作
const limitedFetches = users.map((id) => limit(() => fetchUser(id)));
const results = await Promise.all(limitedFetches);
- 在并发性和控制之间取得平衡
- 防止使外部服务过载
- 添加依赖
如果想看看
await在函数之外的行为,请查看关于在 ES 模块中使用顶级await的文章。
并发级别
| 目标 | 模式 | 并发 |
|---|---|---|
| 保持顺序,逐个运行 | for...of + await | 1 |
| 一次性运行所有,不要求顺序 | Promise.all() + map() | ∞(无限) |
| 限制并发 | p-limit、PromisePool 等 | N(自定义) |
最后的提示:永远不要在 forEach() 中使用 await
这是一个常见的陷阱:
users.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user); // ❌ 未等待
});
循环不会等待你的 async 函数。这些获取操作在后台运行,对完成时间或顺序没有任何保证。
注意:
forEach不等待异步回调。函数可能在异步工作完成之前完成,从而导致静默 bug 和错误遗漏。
相反,使用:
for...of+await用于顺序逻辑Promise.all()+map()用于并行逻辑
🙋🏻♂️ 准备好了解更多了吗?
寻找更函数化的方式来处理异步迭代?
Array.fromAsync()专为消费流和生成器等异步数据源而设计。
快速回顾
JavaScript 的异步模型很强大,但在循环内部使用 await 需要有明确的目的。关键是:根据需求构建异步逻辑。
- 顺序 →
for...of - 速度 →
Promise.all() - 安全性 →
allSettled()/try-catch - 平衡 →
p-limit等
使用正确的模式,可以编写更快、更安全、更可预测的异步代码。
本文作者 Matt Smith,转载请注明来源链接:
原文链接: https://allthingssmitty.com/2025/10/20/rethinking-async-loops-in-javascript/
本文链接: https://tie.pub/blog/rethinking-async-loops-in-javascript/