我在 forEach 里写了 async/await 处理数组,以为会一项项乖乖等着执行完,结果它根本不等、后面的代码抢先跑了还把报错给悄悄吞了的深度复盘
这是一个让我对 forEach 和异步彻底改观的故事。我要遍历一个数组,对每一项,做一个异步的处理(比如逐个写库、逐个调接口)。我很自然地,用了我最熟悉的 forEach,并且在它的回调里,老老实实地写上了 async 和 await——在我朴素的认知里,这就是"对每一项,异步处理,一项项地、等着执行完"嘛;等 forEach 整个跑完,所有项也就都处理好了,我就可以放心地执行后面的"收尾"代码了。
可运行结果,把我整懵了:第一,顺序全乱了。我以为会"处理完第 1 项、再处理第 2 项……"地一个个来,可日志显示,所有项的处理,几乎是同时开始的,而且我放在 forEach 后面的那句"全部处理完啦"的收尾代码,竟然抢在所有项都还没处理完之前,就提前打印出来了!也就是说,forEach 根本没有"等"那些异步任务执行完,就直接往下走了。第二,更可怕的是,有一项处理时抛了个错,可这个错,没有被我外层的 try/catch 捕获到——它像是凭空消失了,被悄悄地吞掉了,只在控制台留下一句 UnhandledPromiseRejection 的警告。我当时百思不得其解:我明明写了 await 啊,它为什么不等?我的 try/catch 明明包着它啊,错误为什么抓不到?直到我去深究 forEach 的实现机制,才恍然大悟,补上了关于"异步遍历"最重要的一课:问题的核心,在于 Array.prototype.forEach 根本不是为异步设计的——它完全无视你那个 async 回调函数返回的 Promise!forEach 的工作方式是:它挨个调用你的回调函数,但它调用完就立刻进行下一个,不会去看、更不会去 await 回调返回的那个 Promise。我那个 async 回调,被调用时,确实启动了一个异步任务(返回了一个 Promise),但 forEach 对这个 Promise 不管不顾,转头就去调下一个回调了。于是,forEach 会在一瞬间,把所有项的异步任务全部启动(它们并发地在后台跑),然后立刻就"执行完"了它自己(因为它觉得"我把每个回调都调用过了呀")——而此时,那些异步任务,其实一个都还没真正完成!所以,我后面的收尾代码,就抢在它们前面跑了。而错误抓不到,也是同理:那些被 forEach 抛之脑后的 Promise,如果 reject 了,根本不在我 try/catch 的"同步执行流"里,自然就成了无人处理的 UnhandledPromiseRejection。我那个写在 forEach 回调里的 await,只是让那个回调内部等了一下,却完全管不住 forEach 这个"包工头"——它压根不等任何一个回调干完。
故障现场:forEach 无视了你回调返回的 Promise
我把这个"forEach 不等异步"的现场,用代码摊开给你看:
// ✗ 灾难: 在 forEach 里用 async/await, 以为会顺序等待
async function processAll(items) {
try {
items.forEach(async (item) => { // ✗ forEach 无视这个 async 回调返回的 Promise!
await processItem(item); // 这个 await 只在"回调内部"生效
console.log("处理完:", item);
});
console.log("全部处理完啦!"); // ✗ 这句会"抢先"打印! forEach 没等异步任务
} catch (e) {
console.log("捕获到错误:", e); // ✗ 抓不到回调里的错误!
}
}
// 实际执行顺序(乱的):
// "全部处理完啦!" ← 先打印! (forEach 立刻就"完成"了, 没等)
// "处理完: a" ← 之后这些异步任务才陆续完成
// "处理完: b"
// (如果某项报错 → UnhandledPromiseRejection, 外层 try/catch 抓不到)
// 为什么? forEach 的内部逻辑(简化)大致是这样:
// Array.prototype.forEach = function(callback) {
// for (let i = 0; i < this.length; i++) {
// callback(this[i], i, this); // ← 只是"调用"回调
// // ↑ 它不接收、不理会 callback 的返回值(那个 Promise)!
// // 调完就进行下一个 i, 根本不 await。
// }
// }; // for 循环瞬间跑完 → forEach 立刻返回 → 但异步任务还在后台跑着
// 根因: forEach 是为"同步"回调设计的, 它完全忽略回调返回的 Promise。
// 在 async 回调里写 await, 只能让"回调内部"等待, 管不住 forEach 本身。
// → forEach 不会"顺序等待", 而是"瞬间启动所有任务、立刻返回"。
看着这段代码和 forEach 的内部逻辑,我才算真正理解了这个"它为什么不等"的根源。问题的核心,是 Array.prototype.forEach,从设计上,就只是为"同步"回调准备的——它完全忽略你的回调函数返回的值,自然也就完全忽略了 async 回调返回的那个 Promise。看它的内部逻辑就一目了然:forEach 内部,就是一个普通的 for 循环,挨个 调用(call) 你的回调;但它调用完一个,就立刻进行下一个,它既不接收、也不理会回调的返回值——所以,当回调是 async 函数、返回一个 Promise 时,forEach 对这个 Promise 视而不见,转头就去调下一个回调了。这就导致了那两个诡异的现象:第一(不等)——forEach 会在一瞬间,把所有项的回调全部调用一遍(从而瞬间启动了所有的异步任务,它们并发地在后台跑),然后它那个 for 循环就跑完了、forEach 就立刻返回了——可此时,那些被启动的异步任务,其实一个都还没完成!于是,我写在 forEach 后面的"收尾代码",就理所当然地抢在它们前面执行了。我那个 await,只让每个回调内部等了一下自己,却完全管不住 forEach 这个"调度者"——它压根不等任何回调干完。第二(错误吞掉)——那些被 forEach 抛诸脑后的 Promise,如果其中一个 reject 了,这个 rejection,根本不在我外层 try/catch 所能捕获的那个"同步执行流"里(try/catch 早就跟着 forEach 一起执行完、退出了),于是它就成了无人认领的 UnhandledPromiseRejection,被悄悄地吞掉。归根结底:我犯的错,是误以为 forEach 会"感知并等待"我回调里的异步操作,而不知道它其实对回调返回的 Promise 不管不顾。在 async 回调里写 await,看起来很顺理成章,实则是把一个不支持异步等待的 forEach,误当成了一个会顺序等待的循环——结果,它"瞬间启动所有任务、立刻返回",和我期望的"一项项等着执行完",南辕北辙。
第一件事:搞懂 forEach 不 await 回调返回的 Promise
定位到根源,我必须把"为什么 forEach 不支持异步等待"这件事,彻底搞清楚:
forEach 为什么"不等"异步: 它无视回调返回的 Promise
# forEach 的本质: 一个"同步"的遍历方法
# - 它内部就是个 for 循环, 挨个"调用"你的回调。
# - 它"不接收、不使用"回调的返回值(无论返回啥, 包括 Promise)。
# - 它调完一个回调, 立刻调下一个, 中间"不会停下来等"。
# 所以, 当回调是 async 函数时:
# - async 回调被调用 → 立刻返回一个 Promise(此时异步任务才刚开始)。
# - forEach 拿到这个 Promise, 但"扔掉不管", 继续调下一个。
# - 结果: 所有 async 回调"几乎同时被启动"(并发), forEach 瞬间循环完、返回。
# - 而那些 Promise(异步任务), 还在后台默默地跑着没完成。
# 两个后果:
# 1. "不等": forEach 后面的代码, 抢在异步任务完成前就执行了。
# 2. "吞错": 回调里 reject 的 Promise, 没人 await/catch
# → UnhandledPromiseRejection, 外层 try/catch 抓不到。
# 关键认知: "在回调里写 await" ≠ "外层循环会等待这个回调"
# - await 只让"那个 async 回调函数自己内部"暂停等待。
# - 但调用这个回调的人(forEach), 要不要等它, 取决于"调用者"怎么处理返回的 Promise。
# - forEach 选择了"不等"(无视 Promise)。
# 结论: 要"异步地、且能等待地"遍历数组, 不能用 forEach!
# 要用: for...of + await(顺序), 或 Promise.all + map(并发)。(下一节)
原理终于清晰了。forEach 的本质,是一个"同步"的遍历方法:它内部就是个 for 循环,挨个"调用"你的回调;它不接收、不使用回调的返回值(无论返回什么,包括 Promise);它调完一个回调,就立刻调下一个,中间绝不会停下来等。所以,当回调是 async 函数时:async 回调被调用,会立刻返回一个 Promise(此时异步任务才刚刚开始);forEach 拿到这个 Promise,却扔掉不管,继续去调下一个;结果就是,所有的 async 回调,几乎同时被启动(并发执行),forEach 自己则瞬间循环完、立刻返回了——而那些 Promise(真正的异步任务),还在后台默默地跑着、没完成。这就带来了两个后果:其一是"不等"(forEach 后面的代码,抢在异步任务完成前就执行了);其二是"吞错"(回调里 reject 的 Promise,没人 await/catch,成了 UnhandledPromiseRejection,外层 try/catch 抓不到)。这里,我领悟到一个极其关键、也极易被忽略的认知:"在回调里写 await",不等于"外层的循环会等待这个回调"!await,只能让"那个 async 回调函数自己内部"暂停、等待;但调用这个回调的人(也就是 forEach),要不要等它,完全取决于"调用者"如何处理回调返回的那个 Promise——而 forEach,选择了"不等"(无视 Promise)。由此,我得出了那个本该一开始就知道的结论:要"异步地、且能够等待地"遍历一个数组,绝不能用 forEach!要用 for...of + await(顺序执行),或者 Promise.all + map(并发执行)——这,是我用一次"乱序又吞错"的事故,补上的关于异步遍历的、最关键的一课。
第二件事:正解——用 for...of(顺序)或 Promise.all(并发)
搞懂了根因——"forEach 无视回调返回的 Promise、不等待"——正解就清晰了:要"顺序地、一项项等着执行"用 for...of + await;要"并发地全部一起跑、再统一等全部完成"用 Promise.all + map。这两个,才是能真正"等待"异步任务的遍历方式。
// 正解1: 顺序执行(一项项等)—— 用 for...of + await
async function processSequential(items) {
for (const item of items) { // ✓ for...of 支持在循环体里 await
await processItem(item); // ✓ 真的会等这一项完成, 再进行下一项
}
console.log("全部处理完啦!"); // ✓ 这句会在所有项都完成后才执行
}
// 适用: 任务之间有依赖、或要严格按顺序、或想限制并发(别一下全压上)。
// 正解2: 并发执行(全部一起跑, 再等全部完成)—— 用 Promise.all + map
async function processConcurrent(items) {
await Promise.all( // ✓ 等"所有 Promise"都完成
items.map(item => processItem(item)) // map 收集每项的 Promise
);
console.log("全部处理完啦!"); // ✓ 所有项并发跑完后才执行
}
// 适用: 任务相互独立、想并发以提速。注意: 并发量大时可能压垮下游, 要控制并发。
// 错误处理, 两种方式都能正常 try/catch 了:
async function safe(items) {
try {
for (const item of items) await processItem(item); // 或 Promise.all
} catch (e) {
console.log("捕获到错误:", e); // ✓ 现在能抓到了!
}
}
// 对比三种写法:
// forEach + async: ✗ 不等待、并发失控、吞错。别用!
// for...of + await: ✓ 顺序、可等待、可 try/catch。
// Promise.all + map: ✓ 并发、可等待、可 try/catch(任一失败即 reject)。
// 核心: 选 for...of(要顺序)还是 Promise.all(要并发), 按需求来;
// 但无论哪个, 都别再用 forEach 处理异步——它根本不会等。
这套正解,核心是用真正能"等待"异步任务的遍历方式,去替代那个不靠谱的 forEach。正解1(顺序执行)——用 for...of + await:for...of 循环,是支持在循环体里 await 的,而且它会真的停下来等:await processItem(item) 会等这一项完成了,才进行下一项;等整个 for...of 循环结束,所有项也就都按顺序处理完了,后面的收尾代码才会执行。它适用于:任务之间有依赖、需要严格按顺序、或想限制并发(别一下把所有任务全压给下游)的场景。正解2(并发执行)——用 Promise.all + map:先用 map,把每一项映射成一个 Promise(收集起所有的异步任务),再用 await Promise.all(...),去等待"所有这些 Promise 都完成";这样,所有任务是并发地一起跑的(更快),但代码会等到它们全部完成后,才往下走。它适用于:任务相互独立、想并发提速的场景(但要注意,并发量太大时可能压垮下游,必要时要控制并发数)。而一个额外的好处是:这两种方式,都让错误处理回归了正常——因为它们都真正 await 了异步任务,所以外层的 try/catch,就能正常捕获到回调里抛出的错误了(Promise.all 在任一项失败时会立刻 reject)。归根结底,把三种写法一对比就很清楚:forEach + async(不等待、并发失控、吞错——别用!)、for...of + await(顺序、可等待、可 try/catch)、Promise.all + map(并发、可等待、可 try/catch)。选 for...of(要顺序)还是 Promise.all(要并发),按你的需求来;但无论哪个,都别再用 forEach 去处理异步——因为它,根本不会等。
下面这张图,对比了三种遍历异步的方式:
这张图的对比很清楚:左边红色那条,forEach + async 无视回调返回的 Promise、不等待,后面代码抢跑、错误被吞;右边绿色两条,for...of + await(每项等完再下一项,顺序、可等待)和 Promise.all + map(收集 Promise 等全部完成,并发、可等待),都能正确地等待异步任务、也都能正常 try/catch。两条好路的分别只在"顺序还是并发",而和那条 forEach 的红路的根本分野,在于"到底等不等异步任务"。
第三件事:其它"以为会等、其实没等"的异步坑
填平了 forEach 这个坑,我系统排查了一遍:JavaScript 里,还有哪些"以为会等异步、其实没等"的常见坑:
// 其它"以为会等、其实没等"的异步坑:
// 1. forEach / map 里写 async(本文)
// forEach 不等; map 会"返回 Promise 数组"但你得自己 Promise.all 它。
const results = arr.map(async x => await f(x)); // results 是 Promise[]! 不是结果[]
const real = await Promise.all(arr.map(x => f(x))); // ✓ 这样才拿到结果
// 2. 忘了 await 一个返回 Promise 的函数
doAsync(); // ✗ 没 await! 它在后台跑, 你以为它做完了
await doAsync(); // ✓
// 3. 在 .then 链里又开了个没接住的异步
fetchA().then(a => {
fetchB(a); // ✗ 没 return! 这个 Promise 脱离了链, 外面 await 不到它
return fetchB(a); // ✓ return 让它接回链里
});
// 4. setTimeout / 事件回调里的异步, 主流程不会等它
setTimeout(async () => { await x(); }, 0); // 主流程早走了, 不等这个
// 5. 构造函数里做异步(构造函数不能 async)
// class C { constructor() { await init(); } } // ✗ 语法错
// → 用工厂函数 / init 方法 + await
// 6. 顶层代码忘了它在异步上下文外
// (老环境没有 top-level await 时, 直接 await 会报错)
// 共同点: "异步任务有没有被等待", 取决于"有没有人 await/then 它返回的 Promise"。
// Promise 被创建 ≠ 被等待。漂在那没人接的 Promise, 既不被等、错误也没人管。
// 原则: 每一个异步调用, 都想清楚"谁来等它、谁来兜它的错"。
这一排查,让我对 JavaScript 异步的"等待陷阱",有了全面的警觉。除了 forEach,还有一堆"以为会等、其实没等"的坑:map 里写 async(map 会返回一个 Promise 数组,你得自己 Promise.all 它,否则拿到的是一堆 Promise 而不是结果);忘了 await 一个返回 Promise 的函数(它在后台跑,你却以为它做完了);在 .then 链里开了个没 return 的异步(那个 Promise 脱离了链,外面 await 不到它);setTimeout/事件回调里的异步(主流程不会等它);构造函数里做异步(构造函数不能 async,要用工厂函数或 init 方法)。这些坑的共同点是:"一个异步任务有没有被等待",完全取决于"有没有人去 await/then 它返回的那个 Promise"。Promise 被创建,不等于它被等待——一个漂在那里、没人接住的 Promise,既不会被等待,它一旦出错,也没人来处理。由此,我立下一个处理异步的核心原则:对每一个异步调用,都想清楚两件事——"谁来等它(谁 await 它的结果)"、以及"谁来兜它的错(谁 catch 它的失败)"。想清楚了这两点,那些"以为会等、其实没等"的、以及"以为会抓、其实抓不到"的异步坑,就都能在写代码时,被你提前规避掉。
第四件事:顺序还是并发——以及怎么控制并发
填平了 forEach 的坑,我把"异步遍历该选顺序还是并发、并发太猛了怎么办"这件事,系统地梳理了一遍,沉淀成了团队的实践:
// 异步遍历: 顺序 vs 并发 vs 受控并发
// 1. 顺序(for...of + await): 一项接一项
// 优点: 简单、对下游友好(同一时刻只1个)、能严格保证顺序、能用前一项结果。
// 缺点: 慢(总耗时 = 各项之和)。
// 适用: 任务有依赖、要顺序、或下游脆弱不能并发冲击。
for (const x of items) { await f(x); }
// 2. 全并发(Promise.all + map): 一股脑全发出去
// 优点: 快(总耗时 ≈ 最慢的那一项)。
// 缺点: 并发量 = 数组长度! 1万项就1万个并发, 可能压垮下游 / 触发限流 / OOM。
// 适用: 任务独立、数量可控、下游扛得住。
await Promise.all(items.map(x => f(x)));
// 3. 受控并发(最实用): 并发, 但限制"同时最多 N 个"
// 既要并发的快, 又不想压垮下游 —— 用并发池/分批。
async function withConcurrency(items, limit, fn) {
const results = [];
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit); // 一批 limit 个
results.push(...await Promise.all(batch.map(fn))); // 这批并发, 等完再下一批
}
return results;
}
await withConcurrency(items, 5, f); // 同时最多 5 个
// (生产中更常用 p-limit 等成熟库做精细的并发控制)
// 4. 要"全部跑完, 不因一个失败就中断"? 用 Promise.allSettled
const settled = await Promise.allSettled(items.map(f));
// allSettled 不会因某项 reject 就整体失败, 返回每项的成功/失败状态。
// (Promise.all 是"一个失败就立刻整体 reject")
// 选择口诀:
// 有依赖/要顺序/护下游 → for...of(顺序)
// 独立/量小/求快 → Promise.all(并发)
// 独立/量大/护下游 → 受控并发(限流)
// 要每项结果不怕个别失败 → allSettled
这一梳理,让我对"异步遍历"的选择,有了体系化的认识。它其实有三档,各有取舍:顺序(for...of + await)——一项接一项,优点是简单、对下游友好(同一刻只 1 个)、能严格保证顺序、能用上前一项的结果,缺点是慢(总耗时是各项之和),适用于任务有依赖、要顺序、或下游脆弱的场景。全并发(Promise.all + map)——一股脑全发出去,优点是快(总耗时约等于最慢的一项),缺点是并发量等于数组长度(1 万项就是 1 万个并发,可能压垮下游、触发限流、甚至 OOM),适用于任务独立、数量可控、下游扛得住的场景。受控并发(最实用)——并发,但限制"同时最多 N 个":既要并发的快,又不想压垮下游,通过分批或并发池实现(生产中常用 p-limit 这类库),这往往是处理大批量异步任务的最佳选择。此外,如果你希望"全部跑完、不因某一个失败就中断",要用 Promise.allSettled(它不会因某项 reject 就整体失败,而是返回每一项各自的成功/失败状态;而 Promise.all 是"一个失败就立刻整体 reject")。选择的口诀就是:有依赖/要顺序/护下游用 for...of;独立/量小/求快用 Promise.all;独立/量大/护下游用受控并发;要每项结果且不怕个别失败用 allSettled。把这几种方式的取舍,整理成一张表:
| 方式 | 速度 | 并发量 | 适用 |
|---|---|---|---|
| for...of + await | 慢(累加) | 1 | 有依赖/要顺序/护下游 |
| Promise.all + map | 快 | = 数组长度 | 独立/量小/下游能扛 |
| 受控并发(限流) | 较快 | 最多 N 个 | 独立/量大/护下游 |
| Promise.allSettled | 快 | = 数组长度 | 要每项结果不怕个别失败 |
第五件事:别把一个 API 的"长相",当成它的"行为"
这次踩坑,在认知层面给了我最大的纠偏——它让我警惕"望文生义"地使用 API。我把这层反思,沉淀了下来:
认知纠偏: 别凭 API 的"长相"想当然, 要懂它的"行为契约"
# 我的误解(错误的):
# forEach 看起来像个"循环", 我就想当然地以为它会"像 for 循环那样"
# 配合 await 顺序等待。我是凭它的"长相", 而非它的"实际行为"在用它。
# 真相: forEach 的"长相"像循环, 但它的"行为契约"不支持异步等待
# - 它只是"对每项同步调用一次回调", 不处理回调的返回值。
# - 把它和 async/await 组合, 是把它用在了它"行为契约之外"。
# 这是一个普遍的坑: "望文生义"地用 API
# - 看到 forEach 像循环 → 以为能 await(其实不能)。
# - 看到 sort() → 以为按数字排(其实按字符串)。
# - 看到 map(async) → 以为得到结果数组(其实是 Promise 数组)。
# → 我们常凭 API 的"名字/外观"去想象它的行为, 而非了解它"真实的契约"。
# 正确的习惯:
# 1. 用一个 API, 别只看它"叫什么、长什么样", 要搞清它"实际怎么行为"——
# 尤其是它怎么处理返回值、是同步还是异步、有什么边界。
# 2. 对异步, 多问一句: "它会等我的异步回调吗? 它怎么处理我返回的 Promise?"
# 3. 拿不准就查文档 / 写个小实验验证, 别凭外观下结论。
核心: API 的"长相"会骗人, 真正决定行为的是它的"契约/实现"。
别望文生义地用 API——尤其在异步里, 一个想当然就可能让你不等、吞错。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:forEach 长得像一个"循环",于是我就想当然地以为,它会"像 for 循环那样"配合 await 顺序等待——我是凭它的"长相",而不是它的"实际行为",在使用它。可真相是:forEach 的"长相"像循环,但它的"行为契约"并不支持异步等待——它只是"对每项同步地调用一次回调",根本不处理回调的返回值;把它和 async/await 组合,是把它用在了它"行为契约之外"的地方。而这,是一个极其普遍的坑——"望文生义"地用 API:看到 forEach 像循环,就以为能 await(其实不能);看到 sort(),就以为按数字排(其实按字符串);看到 map(async ...),就以为得到结果数组(其实是 Promise 数组)——我们常常凭一个 API 的"名字、外观",去想象它的行为,而不是去真正了解它"实际的契约"。由此,我立下了几条习惯:第一,用一个 API,别只看它"叫什么、长什么样",要搞清它"实际是怎么行为"的——尤其是它怎么处理返回值、是同步还是异步、有什么边界;第二,对异步,要多问一句:"它会等我的异步回调吗?它怎么处理我返回的 Promise?";第三,拿不准时,就查文档、或写个小实验验证,别凭外观就下结论。归根结底:API 的"长相"会骗人,真正决定它行为的,是它的"契约/实现"。别望文生义地用 API——尤其在异步的世界里,一个想当然,就可能让你的代码,陷入"不等、又吞错"的双重陷阱。把"凭长相用"和"懂契约用"两种心态对比成一张表:
| 维度 | 凭长相用(踩坑) | 懂契约用(稳) |
|---|---|---|
| 用 forEach | 像循环就以为能 await | 知道它不处理 Promise |
| 判断依据 | API 的名字/外观 | 它真实的行为契约 |
| 对异步 | 以为都会等 | 问它怎么处理 Promise |
| 拿不准时 | 凭感觉下结论 | 查文档/写实验验证 |
| 典型受害 | forEach/sort/map(async) | 提前规避 |
一套"异步遍历该用什么"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"要异步遍历数组时,该用什么"的决策图,贴在了团队的前端规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:异步遍历数组,第一条铁律就是绝不用 forEach + async;然后,看任务有没有依赖/要不要顺序——要顺序就 for...of + await;任务独立的,再看数组大不大、下游扛不扛得住——量小且下游能扛就 Promise.all 全并发,量大要护下游就用受控并发(限流);如果要每项的结果、且不怕个别失败,就用 Promise.allSettled。这条"先排除 forEach、再按顺序/并发/限流分流"的决策链,现在是我们团队处理每一个异步遍历时的准则。
我立下的几条异步遍历规矩
这次"forEach 不等异步"的踩坑,让我把异步遍历的注意事项,认真地立成了几条规矩:
- 绝不用
forEach处理异步。它无视回调返回的 Promise,既不等待、又吞错。这是第一铁律。 - 要顺序用
for...of + await。支持循环体里 await,真正一项项等待,能 try/catch。 - 要并发用
Promise.all + map。map 收集 Promise、all 等全部完成,一个失败即整体 reject。 - 量大要限流(受控并发)。别让并发量等于数组长度压垮下游,用分批或 p-limit。
- 不怕个别失败用
allSettled。要拿到每一项的成功/失败结果而不整体中断。 - 记牢"回调里 await ≠ 外层会等"。调用者是否等待,取决于它怎么处理返回的 Promise。
- 每个异步调用都想清楚谁等它、谁兜错。Promise 被创建不等于被等待;别让 Promise 漂着没人接。
写在最后
这次"我在 forEach 里写 async/await、它却根本不等还吞了错"的经历,是我在 JavaScript 异步路上,一次很经典、也很受用的成长。它教给我的,远不止"异步遍历别用 forEach"这一条具体的技术经验,更是一种对待 API 的根本态度——别凭它的"长相",去想当然地使用它,而要真正搞懂它的"行为契约"。我那次的坑,根源就在于,forEach 长得像个循环,我便理所当然地以为它会像循环那样配合 await 顺序等待;却不知道,它的契约里,压根就没有"等待异步回调"这一条——它对我返回的 Promise,视而不见。
所以,当你使用任何一个 API、尤其是把它和异步组合的时候,请别只看它"叫什么、长什么样"就想当然地用——而要多花一分钟,搞清楚它实际是怎么行为的:它会不会等待我的异步回调?它如何处理我返回的 Promise?它的边界在哪?就像 forEach,你只要知道"它不处理回调返回的 Promise、根本不会等",就绝不会写出那段"乱序又吞错"的代码,而会自然地改用 for...of 或 Promise.all。从"凭 API 的外观想当然"到"懂它真实的行为契约",从"以为 Promise 创建了就会被等待"到"想清楚每个异步任务谁来等、谁来兜错",是从一个"会写异步语法"的开发,走向一个"真正驾驭异步"的工程师,必经的修炼。愿你写的每一次异步遍历,都等得住、也兜得住;也愿你我,在用每一个 API 时,都拨开它的外观,去看清它真实的契约。共勉。
—— 别看了 · 2026