前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

await 在 forEach 中不生效的原因与四种替代写法

首页2019-09-01 16:20:43Front-End
JavaScriptasync/awaitPromise异步编程

在 forEach 的回调里写了 await,代码看着一点问题没有,跑起来顺序全乱,end 还跑到了所有结果前面。更糟的是它不报错,本地数据量小的时候甚至看不出异常,等到线上批量处理几百条数据才发现结果对不上。

这篇把这件事从根上讲一遍:为什么 forEach 接不住 await,四种替代写法各自的执行顺序和并发度是什么,错误处理上有什么区别,以及数据量大的时候怎么控制并发不把下游打挂。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • forEach 里的 await 为什么不生效,根因是什么
  • for...of、Promise.all、for await...of、reduce 四种写法的执行顺序对比
  • 串行和并发怎么选,并发度失控会带来什么后果
  • 四种写法在错误处理上的差异,哪种会静默吞掉异常
  • 怎么手写一个带并发上限的批处理
  • 有哪些 lint 规则能在写代码的时候就拦住这类问题

# 一、场景

function test() {
	let arr = [3, 2, 1]
	arr.forEach(async item => {
		const res = await fetch(item)
		console.log(res)
	})
	console.log('end')
}

function fetch(x) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			resolve(x)
		}, 500 * x)
	})
}

test()
@前端进阶之旅: 代码已经复制到剪贴板

期望的打印顺序是:

3
2
1
end
@前端进阶之旅: 代码已经复制到剪贴板

结果打印顺序居然是:

end
1
2
3
@前端进阶之旅: 代码已经复制到剪贴板

两处对不上。end 跑到了最前面,而三个结果的顺序按耗时从短到长排列,和数组里的顺序没关系。

原因

forEach 只支持同步代码,它并不会去处理异步的情况。

# 二、根因,forEach 把回调的返回值丢掉了

先说结论:forEach 不是「不支持 await」,是它压根不看回调返回了什么。

async 函数被调用时会立即返回一个 Promise,函数体执行到第一个 await 就挂起,把控制权交回去。所以上面那段代码的真实执行过程是这样:

forEach 依次调用三次回调,每次调用都拿到一个 pending 的 Promise,然后直接扔掉,接着调下一次。三次调完,forEach 返回 undefined,同步执行 console.log('end')。这时候三个 setTimeout 都已经在跑了,各自到点了才 resolve,所以 500ms 的先打、1500ms 的最后打。

规范里 Array.prototype.forEach 的定义就是一个纯粹的 for 循环加 Call(callbackfn, ...),调用结果没有任何地方被使用。你可以理解成它长这样:

Array.prototype.myForEach = function (cb, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if (i in this) {
      cb.call(thisArg, this[i], i, this)  // 返回值丢弃
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

map 就不一样,它会把每次调用的返回值收集进新数组。这个差别正是下面第一种解法能成立的原因。

顺带说一句,这个坑不止 forEach 有。filter、some、every、sort 的回调传 async 函数一样出问题,而且更隐蔽:filter(async x => ...) 的回调永远返回一个 Promise 对象,Promise 对象恒为真值,所以过滤条件永远成立,一条都不会被过滤掉。这个我踩过,排查了半天才反应过来问题出在 async 上。

# 三、解决办法

# 3.1 第一种是使用 Promise.all 的方式

async function test() {
	let arr = [3, 2, 1]
	await Promise.all(
		arr.map(async item => {
			const res = await fetch(item)
			console.log(res)
		})
	)
	console.log('end')
}
@前端进阶之旅: 代码已经复制到剪贴板

这样可以生效的原因是 async 函数肯定会返回一个 Promise 对象,调用 map 以后返回值就是一个存放了 Promise 的数组,把这个数组传入 Promise.all 就能等它们全部结束。

但这种方式并不能达成我们要的效果。它保证的是「end 一定在所有请求完成之后打印」,不保证三个结果之间的顺序。三个 fetch 是同时发出去的,谁先回来谁先打印,所以还是 1、2、3。

如果你要的是结果数组有序,那 Promise.all 反而是最合适的,因为它的 resolve 值严格按传入顺序排列,和完成先后无关:

const results = await Promise.all(arr.map(item => fetch(item)))
// results 一定是 [3, 2, 1] 对应的结果,顺序不会乱
@前端进阶之旅: 代码已经复制到剪贴板

分清楚两件事:执行是并发的,结果是有序的。很多人把这两个混在一起想,才觉得 Promise.all 的行为反直觉。

如果你希望内部的 fetch 是顺序完成的,可以选择第二种方式。

# 3.2 另一种方法是使用 for…of

async function test() {
	let arr = [3, 2, 1]
	for (const item of arr) {
		const res = await fetch(item)
		console.log(res)
	}
	console.log('end')
}
@前端进阶之旅: 代码已经复制到剪贴板
  • 这种方式相比 Promise.all 要简洁得多,并且可以实现开头我想要的输出顺序
  • 但这时候你是否又多了一个疑问?为啥 for...of 内部就能让 await 生效呢
  • 因为 for...of 内部处理的机制和 forEach 不同,forEach 是直接调用回调函数,for...of 是通过迭代器的方式去遍历
async function test() {
	let arr = [3, 2, 1]
	const iterator = arr[Symbol.iterator]()
	let res = iterator.next()
	while (!res.done) {
		const value = res.value
		const res1 = await fetch(value)
		console.log(res1)
		res = iterator.next()
	}
	console.log('end')
}
@前端进阶之旅: 代码已经复制到剪贴板

以上代码等价于 for...of,可以看成 for...of 是以上代码的语法糖。

关键在于 await 现在写在当前这个 async 函数体内,而不是写在另一个回调函数里。await 只能挂起它所在的那个 async 函数,forEach 的回调是一个独立的 async 函数,挂起的是回调自己,外层的 forEach 循环该转还是转。for...of 展开成 while 循环之后,await 挂起的是 test 本身,所以整个循环都停下来等。

这条规则同样适用于 for 和 while。用 for...of 只是因为它写起来最顺手,普通 for (let i = 0; ...) 里 await 一样能生效。

# 3.3 for await…of,用来处理异步迭代源

for await...of 是 ES2018 加的,它和 for...of 的区别在于会对每次迭代拿到的值做一次 await:

async function test() {
  const arr = [3, 2, 1]
  for await (const res of arr.map(item => fetch(item))) {
    console.log(res)
  }
  console.log('end')
}
@前端进阶之旅: 代码已经复制到剪贴板

这段的输出是 3、2、1、end,有序。但要看清楚一个细节:arr.map(...) 在进入循环之前就把三个 Promise 全部创建好了,也就是三个请求是同时发出去的,for await...of 只是按顺序去取结果。它和 for...of + await 的行为不一样,后者是发完一个等一个再发下一个。

需要串行发请求就用 for...of,需要并发发但要按顺序消费结果就用 for await...of。

它真正不可替代的场景是异步迭代器,比如读 Node 的可读流、消费分页接口、处理 ReadableStream:

// Node 里逐行读大文件,内存占用恒定
for await (const line of rl) {
  await handle(line)
}
@前端进阶之旅: 代码已经复制到剪贴板

这类源的数据是一批批到的,总量事先不知道,没法先 map 成数组再 Promise.all。

# 3.4 reduce 串起来的写法

还有一种写法,用 reduce 把 Promise 串成一条链:

await arr.reduce(async (prev, item) => {
  await prev
  const res = await fetch(item)
  console.log(res)
}, Promise.resolve())
@前端进阶之旅: 代码已经复制到剪贴板

效果和 for...of 一样是串行的。我把它列出来主要是因为面试里会问,实际项目里我不建议用,因为 await prev 这一行漏掉就静默变成并发,而且出错时的调用栈比 for...of 难看得多。能用 for...of 就用 for...of。

# 四、并发度控制

上面两个极端各有各的问题。for...of 串行最安全,但一百条数据就是一百个来回,慢得没法接受;Promise.all 全并发最快,但一百条数据同时打过去,下游接口大概率给你限流或者直接 502。

实际项目里要的是中间态,比如「同时最多跑 5 个」。生产上我一般直接用 p-limit 这个包:

import pLimit from 'p-limit'

const limit = pLimit(5)
const results = await Promise.all(
  arr.map(item => limit(() => fetch(item)))
)
@前端进阶之旅: 代码已经复制到剪贴板

不想引依赖的话,手写一个 worker pool 也就十几行。思路是开固定数量的「工人」,每个工人从共享游标取下一个任务,取完为止:

async function mapWithLimit(list, limit, fn) {
  const results = new Array(list.length)
  let cursor = 0

  async function worker() {
    while (cursor < list.length) {
      const i = cursor++          // 取任务并占位
      results[i] = await fn(list[i], i)
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, list.length) },
    () => worker()
  )
  await Promise.all(workers)
  return results
}
@前端进阶之旅: 代码已经复制到剪贴板

用起来是这样:

fe
  • 一、场景
  • 二、根因,forEach 把回调的返回值丢掉了
  • 三、解决办法
    • 3.1 第一种是使用 Promise.all 的方式
    • 3.2 另一种方法是使用 for…of
    • 3.3 for await…of,用来处理异步迭代源
    • 3.4 reduce 串起来的写法
  • 四、并发度控制
  • 五、错误处理,四种写法差别最大的地方
  • 六、怎么选
  • 总结
  • 参考

← React Hooks 详解,useState useEffect useContext 与 useReducerReact children 详解,Children 工具方法与 cloneElement 实践 →