重新审视 JavaScript 中的异步循环

重新审视 JavaScript 中的异步循环(贴合实际业务)


为什么"在循环里用 await"常让系统变慢?

for/for...of 循环里直接 await,会让每一步串行 执行:下一个请求必须等上一个完成,独立的网络调用因此被迫排队,吞吐量下降。相反,在 map() 里用 async不会等待 ,你得到的是一组 Promise,若不进一步汇聚,就会产生"已经结束但任务仍在后台跑"的错觉。citeturn1search1turn1search10turn1search29

结论:要么有意识地串行 (确有顺序或限速要求),要么明确地并行 (如 Promise.all/受控并发);避免模棱两可的写法。citeturn1search1


三种主流执行模式

1) 串行:for...of + await

适用:后一步依赖上一步;需要严格顺序;受供应商 API 严格限速(如每秒 1 次)。

ts 复制代码
for (const id of orderIds) {
  const order = await getOrder(id);        // 依赖顺序
  const enriched = await enrich(order);    // 逐步加工
  await persist(enriched);
}

这类写法最直观、可控、易于断点调试,但对独立 I/O 来说性能最慢。

2) 并行:Promise.all(users.map(...))

适用 :各任务彼此独立,无需保持顺序,例如并发拉取 100 个客户资料批量生成报表

ts 复制代码
const results = await Promise.all(
  customerIds.map(id => fetchCustomerProfile(id))
);

注意 Promise.all() "失败即全部失败":任一子任务拒绝即整体 reject,需要配合边界处理。

更安全的并行:Promise.allSettled()

保留所有结果(成功/失败),适合批处理部分成功可接受的业务(如批量发券、推送)。

ts 复制代码
const settled = await Promise.allSettled(tasks);
const ok = settled.filter(r => r.status === 'fulfilled').map(r => r.value);
const bad = settled.filter(r => r.status === 'rejected').map(r => r.reason);

allSettled() 等待全部任务落定,不会因为单次失败提前退出。

3) 受控并发:p-limit

适用 :既要加速,又要尊重限流/连接数(如支付、物流、第三方风控)。

ts 复制代码
import pLimit from 'p-limit';
const limit = pLimit(5);                 // 最多并行 5 个
const tasks = orderIds.map(id => limit(() => processOrder(id)));
const results = await Promise.all(tasks);

p-limit 通过"令牌桶"式并发上限,避免把上游打垮或把本机线程池占满。

快速选型:顺序for...of极速并行Promise.all限流p-limit


常见误用与坑

  • forEach + asyncforEach 不等待异步回调,外层函数早结束,错误与资源泄露被"吞掉"。请改为 for...ofPromise.all(map(...))。citeturn1search1turn1search29
  • map(async ...) 后不汇聚 :得到的是 Promise[] 而非数据;要 await Promise.all(...)allSettled(...)。citeturn1search1
  • 无上限的"全并发" :容易触发供应商限流、雪崩或本机资源耗尽;请用 p-limit/批处理。

业务级健壮性(必做项)

1) 超时、取消与重试

ts 复制代码
// 简易超时包装
const withTimeout = (p, ms) => new Promise((res, rej) => {
  const t = setTimeout(() => rej(new Error(`Timeout ${ms}ms`)), ms);
  p.finally(() => clearTimeout(t)).then(res, rej);
});

// 结合 AbortController 取消(浏览器/Node18+)
const controller = new AbortController();
const resp = await withTimeout(fetch(url, { signal: controller.signal }), 5000);
// controller.abort(); // 需要时取消

// 指数退避重试(只对可重试错误)
async function retry(fn, times = 3) {
  let i = 0; let err;
  while (i < times) {
    try { return await fn(); } catch (e) {
      err = e; await new Promise(r => setTimeout(r, 2 ** i * 200)); i++;
    }
  }
  throw err;
}

Promise.all/Settled 组合能显著提升批处理成功率与可观测性。

2) 幂等与去重

支付、发货、开票 等场景,务必给任务加幂等键(如 orderId#step),避免并行与重试导致的重复扣费/重复推送 。这一点与并发模式无关,但与并发越大越重要。(业务通用原则)

3) 可观测性

为每个批次记录:并发度、成功/失败数、耗时分位数、重试次数 ,并对失败样本做原因分类 (5xx/4xx/解析/超时/取消)。(工程最佳实践)


面向海量数据:批处理、流与异步可迭代

  • 批处理 :按 50~200 的 batch 大小切片 + p-limit,通常能在吞吐与稳定性间取到好平衡。
  • for‑await‑of:消费异步可迭代(如分页 API、Node.js 流)。便于"边到边处理",控制内存占用。
  • Array.fromAsync() :把(异步)可迭代转为数组,内部顺序地等待每个元素 ;而 Promise.all并发等待。用于需要"逐个确认、有节奏地拉取"的场景。citeturn1search16
ts 复制代码
// 例:分页抓取→顺序聚合(避免瞬时压垮后端)
async function* pages() { let page = 1; while (true) {
  const res = await fetch(`/invoices?page=${page}`);
  if (!res.ok) break; yield res.json(); page++;
}}
const all = await Array.fromAsync(pages(), p => p); // 顺序消费

Array.fromAsyncPromise.all 的差异与取舍,详见 MDN。


贴合业务的示例片段

A. 受第三方 API 限流的客户资料同步

ts 复制代码
import pLimit from 'p-limit';
const limit = pLimit(8); // 第三方给的窗口:每秒 10 次,这里预留安全边际

const tasks = customers.map(c => limit(() => retry(() => fetchCrm(c.id))));
const settled = await Promise.allSettled(tasks);
const ok = settled.filter(x => x.status === 'fulfilled').length;
const fail = settled.length - ok;

并发受控、失败不拖累整体,失败项可落盘重试。

B. 订单批量开票(保持顺序)

ts 复制代码
for (const id of orderIds) {
  const order = await getOrder(id);
  const pdf = await buildInvoicePdf(order);
  await uploadToStorage(pdf, `${id}.pdf`);
}

订单号与发票号需一一对应、可审计,故选串行

C. 报表并行渲染(失败可容忍)

ts 复制代码
const jobs = views.map(view => renderReport(view));
const result = await Promise.allSettled(jobs);
const okReports = result.filter(x => x.status === 'fulfilled');

某些报表失败不影响其他成功交付,allSettled 收敛更稳妥。

速查清单 / 决策树

  1. 是否必须保持顺序或严格限速? 是 → for...of + await;否 → 2。citeturn1search40
  2. 是否需要尽快完成且任务彼此独立? 是 → Promise.all(map(...));否 → 3。citeturn1search10
  3. 是否担心压垮上游或自身? 是 → p-limit 设定并发上限;否 → 直接 Promise.all
  4. 是否需要"全部结果都要拿到"用于汇总/审计?Promise.allSettled。citeturn1search34
  5. 数据像"流水"般到达?for‑await‑of / Array.fromAsync
相关推荐
起这个名字2 小时前
微前端应用通信使用和原理
前端·javascript·vue.js
QuantumLeap丶2 小时前
《uni-app跨平台开发完全指南》- 06 - 页面路由与导航
前端·vue.js·uni-app
CSharp精选营2 小时前
ASP.NET Core Blazor进阶1:高级组件开发
前端·.net core·blazor
用户90443816324602 小时前
AI 生成的 ES2024 代码 90% 有坑!3 个底层陷阱 + 避坑工具,项目 / 面试双救命
前端·面试
小p2 小时前
react学习6:受控组件
前端·react.js
黑云压城After2 小时前
纯css实现加载动画
服务器·前端·css
鹏多多2 小时前
Web使用natapp进行内网穿透和预览本地页面
前端·javascript
ttod_qzstudio2 小时前
Vue 3 Props 定义详解:从基础到进阶
前端·vue.js
钱端工程师2 小时前
uniapp封装uni.request请求,实现重复接口请求中断上次请求(防抖)
前端·javascript·uni-app