当 async/await 遇上 forEach
Category(分类): JavaScript Status: 已整理
前情提要
这是在做格式化时遇到的一个问题。感谢 avenwu 和 erasermeng 两位前辈的回复和指导,下面保留原文的问题结构,但把结论改得更精确:不是 await 失效,而是 forEach() 不消费异步回调返回的 Promise。
先看两种写法:
const report = async () => {
for (let i = 0, len = arr.length; i < len; i += 1) {
await asyncFn(arr[i])
}
}
const report = async () => {
arr.forEach(async (item) => {
await asyncFn(item)
})
}
第一种写法在同一个 async 函数中逐项 await,所以后一个操作要等前一个操作完成才开始。第二种写法中,forEach() 仍然同步地逐项调用回调;每个 async 回调执行到未完成的 await 后返回一个 Promise,但 forEach() 忽略这个返回值并立刻返回 undefined。
因此,第二种写法会快速发起多个异步操作,等待阶段可能重叠。这里更准确的词是并发发起/重叠,不是 JavaScript 主线程同时执行多个回调的线程级并行。浏览器或 Node 的 I/O 可以在底层重叠,但同一个 JavaScript agent 中的回调仍然一次执行一个。
问题提出:forEach 遇到 async/await 会发生什么
看一个自包含的例子:
const delay = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms)
})
async function multi(value) {
await delay(10)
return value * 2
}
async function reportBad(items) {
items.forEach(async (item) => {
const result = await multi(item)
console.log('完成', result)
})
console.log('reportBad 已返回')
}
reportBad([1, 2, 3]).catch(console.error)
reportBad() 返回的 Promise 很快就会兑现,因为它只等待了 forEach() 的同步调用,而没有等待三个回调 Promise。完成日志稍后才出现;如果 multi() 拒绝,拒绝发生在被丢弃的回调 Promise 上,也不会自动传播给 reportBad()。
调用方写 await reportBad(items) 也不能补救,因为 reportBad() 没有返回或等待那些 Promise。
forEach 的概念示意
Array.prototype.forEach() 的规范算法会保存开始时的长度,对每个存在的索引调用回调,并丢弃回调返回值。下面只演示关键点,不是完整 polyfill,不要拿它替换原生方法:
function forEachConcept(array, callback, thisArg) {
const object = Object(array)
const length = object.length >>> 0
for (let index = 0; index < length; index += 1) {
if (index in object) {
callback.call(thisArg, object[index], index, object)
}
}
}
它没有完整实现规范中的长度转换、泛型对象、可调用性检查和所有异常细节;重点只有这一句:
callback.call(thisArg, value, index, object)
回调返回的 Promise 没有被保存、等待或组合。
JavaScript 中的循环遍历
for
普通 for 适合需要索引、提前退出或精细控制的场景:
for (let index = 0; index < items.length; index += 1) {
console.log(items[index])
}
for-in
for-in 枚举对象及其原型链上的可枚举字符串属性。对数组来说,它还可能枚举额外属性,得到的键也是字符串,因此通常不适合遍历数组值:
for (const key in items) {
console.log(key, items[key])
}
forEach
forEach() 适合同步回调。它没有 break/continue,返回值也不会成为新数组:
items.forEach((value) => {
console.log(value)
})
for-of
for-of 使用可迭代协议遍历值,适合 break、continue、return 和 await。它与 forEach() 的稀疏数组细节也不完全相同,因此不要称两者在所有情况下完全等价:
for (const value of items) {
console.log(value)
}
解决问题
方式一:串行处理,使用 for...of
如果业务要求严格按顺序处理,直接在 async 函数里写 for...of:
async function serial(items, mapper) {
const results = []
for (const [index, item] of items.entries()) {
results.push(await mapper(item, index, items))
}
return results
}
async function testSerial() {
const nums = [1, 2, 3]
const results = await serial(nums, multi)
console.log(results)
}
testSerial().catch(console.error)
也可以保留一个独立的 asyncForEach 辅助函数,但调用处必须 await 或 return 它:
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index += 1) {
if (index in array) {
await callback(array[index], index, array)
}
}
}
async function test() {
const nums = [1, 2, 3]
await asyncForEach(nums, async (value) => {
const result = await multi(value)
console.log(result)
})
}
test().catch(console.error)
如果 callback 拒绝,asyncForEach() 和 test() 会沿 Promise 链拒绝,调用方可以用 try...catch 或 .catch() 处理。这里的循环是独立函数,不需要修改 Array.prototype,也不会和其他库的同名扩展冲突。
方式二:全部并发发起,使用 Promise.all
如果任务彼此独立,希望尽快发起并等待全部结果,可以用 map() 收集 Promise,再交给 Promise.all():
async function concurrent(items, mapper) {
return Promise.all(
items.map((item, index) => mapper(item, index, items)),
)
}
async function testConcurrent() {
const nums = [1, 2, 3]
const results = await concurrent(nums, multi)
console.log(results)
}
testConcurrent().catch(console.error)
Promise.all() 的结果按输入顺序排列,不按完成先后排列;任意一个输入拒绝时它会快速拒绝,但已经启动的其他操作不会自动取消。需要取消网络请求时,应让底层 API 配合 AbortSignal,不能指望 Promise.all() 替你取消。
方式三:希望全部完成,使用 Promise.allSettled
如果要收集每项的成功或失败,而不是遇到第一个拒绝就结束:
async function testAllSettled() {
const nums = [1, 2, 3]
const outcomes = await Promise.allSettled(
nums.map((value) => multi(value)),
)
console.log(outcomes)
}
testAllSettled().catch(console.error)
结果中的每一项会是 { status: 'fulfilled', value } 或 { status: 'rejected', reason }。这很适合批量任务的汇总报告。
方式四:限并发
Promise.all() 会一次性调用全部 mapper。如果任务数量很大,可以写一个简单的 worker pool,限制同时运行的任务数:
async function mapLimit(items, limit, mapper) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit 必须是正整数')
}
const source = Array.from(items)
const results = new Array(source.length)
let nextIndex = 0
let failed = false
let firstError
async function worker() {
while (!failed) {
const index = nextIndex
nextIndex += 1
if (index >= source.length) return
try {
results[index] = await mapper(source[index], index, source)
} catch (error) {
if (!failed) {
failed = true
firstError = error
}
return
}
}
}
const workerCount = Math.min(limit, source.length)
await Promise.all(
Array.from({ length: workerCount }, () => worker()),
)
if (failed) throw firstError
return results
}
async function testLimit() {
const values = await mapLimit([1, 2, 3, 4, 5], 2, async (value) => {
await new Promise((resolve) => setTimeout(resolve, 20))
return value * 10
})
console.log(values)
}
testLimit().catch(console.error)
这个示意保持结果顺序,最多安排 limit 个 mapper。首个错误后不会再安排新的任务,但已经开始的底层操作仍可能继续;真正的取消需要 API 支持。生产项目还应根据任务类型、重试、超时和 AbortController 需求完善它。这里是教学示意,不是完整通用并发库。
结论
forEach()会同步调用 async 回调,并丢弃回调返回的 Promise。await只暂停当前 async 函数,不能暂停外层的forEach()。- 需要串行处理时使用
for...of,并在调用链中await或return。 - 需要全部并发时使用
Promise.all(items.map(mapper)),需要保留每项结果时考虑allSettled()。 - 任务的“并发重叠”不等于 JavaScript 回调在同一线程上并行执行;CPU 并行需要 Worker 或 Node
worker_threads等独立 agent。