Async 在 forEach、map 等各种迭代中的使用

问题引入

首先假设有这样一个场景,对于一个数组中的所有文件读取内容,同时输出文件内容。

假如我们写出这样的代码(本文示例代码部分参考 Stackoverflow 中的 回答

1
2
3
4
5
6
7
8
async function printFiles () {
  const files = await getFilePaths()

  files.forEach(async (file) => {
    const contents = await fs.readFile(file, 'utf8')
    console.log(contents)
  })
}

可以看到执行是符合预期的,但这其实不是一种正确的写法,因为这个函数forEach 后还是同步的,async 没有起到作用,假如要求在全部读取后输出所有内容,这样的写法就只会输出一个空数组,之后函数就会返回了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function get(i) {
  return Promise.resolve(i)
}
async function printFiles () {
  const files = [1,2,3]
  const ans = []
  files.forEach(async (file) => {
    const contents = await get(file)
    console.log(contents)
    ans.push(contents)
  })
  console.log(ans)
}
printFiles()
// will console: [] 1 2 3

在有些场合可以看见 forEach 中的异步是并行的 这一说法,不过这种说法有一定问题,虽然他的表现是异步的,但是不能依赖这种异步带来的特性,因为这实际上起不到异步预期的效果,就像上文中的例子,假如后续还有相关操作就会受到影响。forEach 只是对于数组中所有的元素执行回调函数,本身并没有返回值,所以想要用 await 之类的方法加以控制也不行。

并行执行

那怎样达到真正的并行执行异步的效果呢,可以利用 async 函数的 Promise 返回值,当 list 中所有的 Promise 都成功返回后再往下执行即可,这就需要借助 map 来利用返回值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function get(i) {
  return Promise.resolve(i)
}
async function printFiles () {
  const files = [1,2,3]
  const ans = []
  await Promise.all(files.map(async (file) => {
    const contents = await get(file)
    console.log(contents)
    ans.push(contents)
  }))
  console.log(ans)
}
printFiles()
// will console: 1 2 3 [1, 2, 3] 

可以看到这时候就符合预期了。还有一些要注意的细节,由于 map 返回结果是一个 Promise 数组,而 await 只适用于单个,所以需要借助 Promise.all 来实现,当然配合 Promise.racePromise.allSettled 等内置的静态方法也能实现对应的效果。

顺序执行

那假如迭代本身也有依赖关系,期望是前一个 async 执行结束后在执行下一个呢?

套用前面 map 代替 forEach 利用返回值来 await 的思路,不难想到通过 reduce 来实现这种效果,因为 reduce 的累加器需要前一个返回的结果,所以会等待 async 执行完毕。

1
2
3
4
5
6
7
8
async function printFiles () {
  const files = await getFilePaths();
  await files.reduce(async (acc, file) => {
    await acc;
    const contents = await fs.readFile(file, 'utf8');
    console.log(contents);
  }, Promise.resolve());
}

这样就能保证顺序执行,由于 reduce 每次执行都会返回一个结果作为累加器作用,我们可以利用 async 隐式返回Promise,每次首先 await 等前一个执行完毕后在进行当前的工作,以保证顺序的一致。

当然在大部分情况下这种场景无需关心相对顺序,只需要保证所有任务异步执行,那就可以使用并行的写法。

除了 forEachmap 这些以外,还有更基本 for...in, for...offor 语句来实现迭代遍历,那么他们行为有什么区别呢?

for-of

1
2
3
4
5
6
7
8
async function printFiles () {
  const files = await getFilePaths();

  for (const file of files) {
    const contents = await fs.readFile(file, 'utf8');
    console.log(contents);
  }
}

for...of 中表现出的行为是顺序执行的,而 forEach 中却没有这种行为,这是因为两种内部实现方式不一样,forEach 上文已经提到只是对于所有的元素执行回调函数,不会等待前一个执行结果的返回,可以参考 ES 规范 中的定义

es_foreach

而在 for...of 中其实引入了迭代器机制,具体细节可以参考 MDN 文档,所以每次都会等待当前当前执行结束后返回 next,这样 await 就能起到作用了。

for

而在 for...infor 中虽然并不使用新的 iterator 来执行,但在内部实现上其实会去等待当前执行的结果,之后再执行下一个,同样参考 ES 规范 中的描述,对于 for 中循环体的执行机制 es_for_body 可以看到 3.b 对于当前执行求 result,3.c 通过 result 判断是否需要跳出当前循环,可以看出 await 会对 for 循环进行作用

for-in

for...in参考规范 es_for_in_state

可以看出 for...infor...of 虽然在使用上有很多感知上的区别,而且社区普遍不推荐使用 for..in,但是两者在循环体的执行机制上是很像的,用的是同一个抽象方法,只是入参有些区别,最大的区别应该在循环头的处理上,for...of 会对 iterator 进行处理,有兴趣可以参考对应规范。 所以在执行 for...in 循环体时也会依赖迭代器进行执行(这里的迭代器是一种规范抽象定义,和 JS 中的具体迭代器实作不是一回事),具体细节可以参考 ForIn/OfBodyEvaluation 的 定义

for...await...of

另外值得一提的是,在 ES2018 中引入了新的 for-await-of 语法,对应的 规范 和当时的 proposal

所以上述的函数也可以改写为

1
2
3
4
5
6
async function printFiles () {
  const files = await getFilePaths()
  for await (const contents of files.map(file => fs.readFile(file, 'utf8'))) {
    console.log(contents)
  }
}

同时该语法配合生成器等有更多的用法,有兴趣可以自行查阅。

polyfill

当然觉得规范阅读起来太过复杂,一个更直观的方法是参考 对应的 polyfill,例如对于 forEach,可以参考 MDN 中给出的 polyfill

其中关键在于

1
2
3
4
5
6
7
8
while (k < len) {
  var kValue;
  if (k in O) {
    kValue = O[k];
    callback.call(T, kValue, k, O);
  }
  k++;
}

可以看到只是执行了这一函数,所以在异步函数的情况下也会继续执行下一个,也就造成了并行的情况。不需要查阅规范也就对于这种现象有了直观的理解。

总结

通过查阅规范其实大致就能理解这种不同迭代之间的区别了,尤其之前对于 forEach 和 for 行为的不一致感到困惑,后来看到规范中抽象的定义就明白这种区别了。

在查阅资料的过程中遇到最多的一种情况就是只说现象,不说原因。比如 如果想要顺序执行不能使用 forEach没必要用 for-of 来顺序执行,其实直接 for 就可以 等等的描述。

当然这是很正常的,比如我只是单纯的需要顺序执行或是并行执行的效果,但是用 forEach 遇到了问题,参考这种结论来保证代码的正确性就好了,不过了解不同迭代方法执行表现出的不同现象在规范中的表现也是一件有趣的事情。

References