吴文俊
吴文俊
关注前端编码,想要学习开发 Java, Android-iOS App.
重新思考 JavaScript 中的异步循环

重新思考 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 + await1
一次性运行所有,不要求顺序Promise.all() + map()∞(无限)
限制并发p-limitPromisePoolN(自定义)

最后的提示:永远不要在 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

使用正确的模式,可以编写更快、更安全、更可预测的异步代码。