【翻译】重新思考JavaScript中的异步循环

0 阅读4分钟

原文链接:allthingssmitty.com/2025/10/20/…

作者:Matt Smith

在循环中使用 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 actual user data

从语法和行为上来说(它返回一个承诺数组),这段代码是有效的,但并非多数人预期的那样工作。它不会等待承诺解析完成。

要并行执行调用并获取最终结果:

const results = await Promise.all(users.map(id => fetchUser(id)));

现在所有请求都并行运行,结果包含实际获取的用户。

Promise.all() 会快速失败,即使只有一次调用失败

使用 Promise.all() 时,单次拒绝会导致整个操作失败:

const results = await Promise.all(
  users.map(id => fetchUser(id)) // fetchUser(2) might throw
);

如果 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' }; // fallback value
    }
  })
);

这同时能防止未处理的 Promise 拒绝,在 Node.js 等采用 --unhandled-rejections=strict 参数的严格环境中,此类拒绝可能触发警告或导致进程崩溃。

现代解决方案

使用 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密集型任务,并行处理可能不会带来明显差异。但对于API调用等I/O密集型操作,并行处理可显著缩短总执行时间。

限流并行处理(受控并发)

当需要提升速度但必须遵守API限制时,请使用限流工具如p-limit

import pLimit from 'p-limit';

const limit = pLimit(2); // Run 2 fetches at a time
const limitedFetches = users.map(id => limit(() => fetchUser(id)));

const results = await Promise.all(limitedFetches);
  • 并发与控制之间的平衡
  • 防止外部服务过载
  • 增加依赖性

💡 深入探索

若想了解 await 在函数外部的具体行为,请参阅我关于在 ES 模块中使用顶级 await 的文章。

并发级别

目标模式并发性
保持顺序,依次执行for...of + await1
同时执行,无顺序Promise.all() + map()∞ (无界) ✅
限制并发性p-limitPromisePool, etc.N (自定义)

最后一个技巧:永远不要在 forEach() 中使用 await

这是个常见陷阱:

users.forEach(async id => {
  const user = await fetchUser(id);
  console.log(user); // ❌ Not awaited
});

该循环不会等待async函数执行完毕。这些请求在后台运行,无法保证完成时间或顺序。

⚠️ 注意:forEach 不会等待异步回调。你的函数可能在异步任务完成前就结束,导致隐性错误和遗漏的异常。

建议替代方案:

  • for...of + await 实现顺序逻辑
  • Promise.all() + map() 实现并行逻辑

🙋🏻‍♂️ 准备好深入学习?

想用更函数式的方式处理异步迭代?Array.fromAsync() 专为处理流和生成器等异步数据源而设计。

快速回顾

JavaScript 的异步模型功能强大,但在循环中使用 await 需要刻意为之。关键在于:根据需求构建异步逻辑。

  • 顺序 → for...of
  • 速度 → Promise.all()
  • 安全性 → allSettled() / try-catch
  • 平衡性 → p-limit

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