技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

当 async/await 遇上 forEach

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/99-当async await遇上forEach
本文目录6 个章节
  1. 前情提要
  2. 问题提出:forEach 遇到 async/await 会发生什么
  3. JavaScript 中的循环遍历
  4. 解决问题
  5. 结论
  6. 参考链接

当 async/await 遇上 forEach

Category(分类): JavaScript Status: 已整理

前情提要

这是在做格式化时遇到的一个问题。感谢 avenwuerasermeng 两位前辈的回复和指导,下面保留原文的问题结构,但把结论改得更精确:不是 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 使用可迭代协议遍历值,适合 breakcontinuereturnawait。它与 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 辅助函数,但调用处必须 awaitreturn 它:

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 需求完善它。这里是教学示意,不是完整通用并发库。

结论

  1. forEach() 会同步调用 async 回调,并丢弃回调返回的 Promise。
  2. await 只暂停当前 async 函数,不能暂停外层的 forEach()
  3. 需要串行处理时使用 for...of,并在调用链中 awaitreturn
  4. 需要全部并发时使用 Promise.all(items.map(mapper)),需要保留每项结果时考虑 allSettled()
  5. 任务的“并发重叠”不等于 JavaScript 回调在同一线程上并行执行;CPU 并行需要 Worker 或 Node worker_threads 等独立 agent。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS