今天研究了一下JS中的异步迭代。
0x00 TL;DR
- Array.prototype.forEach()可以传入异步回调函数,但是不能- await整个循环。
- Promise.all()和- Array.prototype.map()结合使用可以- await整个循环,- Promise.resolve()是并行的。
- await Array.prototype.reduce()和- for...of结合使用可以- await整个循环,- Promise.resolve()是顺序执行的。
0x01 为什么不行?
考虑下面这段程序。
const numbers = [1, 2, 3, 4, 5];
numbers.forEach(async value => {
    await someRandomWork();
    console.log(value);
})
console.log('finished!');看起来,forEach()在遍历到数组中每个值时都会await,直到遍历完整个数组。但事实并非如此。实际的输出是这样:
finished!
1
2
3
4
5很明显,console.log()跑到了最前面。不过仔细观察,可以发现await和console.log()并不在同一层。要知道,await是“shallow”的,里面的await只会suspend它所在的函数,不会对外层产生效果。JavaScript for impatient programmers中提到:
If we are inside an async function and want to pause it via await, we must do so directly within that function; we can’t use it inside a nested function, such as a callback. That is, pausing is shallow.
如果想在async函数内部“暂停”它,只能在函数的最顶层使用await,否则就没有效果。
看到这里,解决方案也就呼之欲出了。只要让Promise与后续语句成为同一层,让它们之间产生关联,就能await整个loop过程。
0x02 方案一:Promise.all()
Promise.all()可以将多个Promise合并成一个,这正好是我们想要的。因为需要一个Promise[],此时forEach()就不太实用了,map()明显是一个更好的选择。将代码改写成以下形式:
async function mapAsync() {
  await Promise.all(numbers.map(async i => {
    await someRandomWork();
    console.log(await getCurrentTime(), i);
  }))
  console.log('mapAsync finished!')
}运行,得到以下结果:
00:00:03.941 1
00:00:03.941 2
00:00:03.941 3
00:00:03.941 4
00:00:03.941 5
mapAsync finished!为了方便观察,我在输出中增加了一个timer;同时,在someRandomWork()中进行一次for (let i = 0; i < 2e9; i++)来模拟一段时间的阻塞。此时,console.log()和Promise.all()的await来到了同一层,整个迭代的所有Promise都被resolve之后,Promise.all()才被resolve,从时间码也能看出过程是并行的。mapAsync finished!的输出也在其他的输出之后,符合预期。
0x03 方案二:reduce()
既然可以通过map()和Promise.all()让所有Promise并行resolve,有没有办法让它们被顺序resolve呢?当然有。说到顺序,很自然就想到了reduce()。把代码改写成以下形式:
async function reduceAsync() {
  await numbers.reduce(async (promise, i) => {
    await promise;
    await someRandomWork();
    console.log(await getCurrentTime(), i);
  }, Promise.resolve());
  console.log('reduceAsync finished!')
}reduce()的第二个参数Promise.resolve()帮助我们启动这个美妙的过程。回到异步回调函数,await promise;确保上一个Promise fulfilled之后才接着进行后续的过程,直到遍历数组中的每一个元素。而传入async函数的reduce()本身最后返回一个Promise,把这个链式反应带到最外层。最后,运行的结果如下:
00:00:01.020 1
00:00:01.962 2
00:00:02.590 3
00:00:03.217 4
00:00:03.844 5
reduceAsync finished!通过时间码可以看出,使用reduce()的情况下,Promise是被顺序resolve的。
0x04 方案三:for...of
上面两个方案都是用了高阶函数。实际上,for...of语句也可以当作“支持异步的.forEach()”来使用。使用以下代码:
async function forOfAsync() {
  for (const i of numbers) {
    await someRandomWork();
    console.log(await getCurrentTime(), i);
  }
  console.log('forOfAsync finished!')
}运行后可以得到以下结果:
00:00:00.992 1
00:00:01.932 2
00:00:02.560 3
00:00:03.187 4
00:00:03.814 5
forOfAsync finished!可以看出结果和reduce()是类似的。如果用Babel编译上述JavaScript代码,会发现for...of被转换成了for语句,可见普通的循环在此处也是可行的。这也很合理,毕竟这里的await并没有被函数包裹。
for (var _i = 0, _numbers = numbers; _i < _numbers.length; _i++) {
  const i = _numbers[_i];
  await someRandomWork();
}
console.log("finished!");0x05 for...of和for await...of
如果用for await...of替换上面代码中的for...of,会发现运行结果没有什么变化。原因是,虽然带了“await”,for await...of并不是用在这个场景中的。MDN这样介绍:
When afor await...ofloop iterates over an iterable, it first gets the iterable's[@@asyncIterator]()method and calls it, which returns an async iterator. If the @asyncIterator method does not exist, it then looks for an[@@iterator]()method, which returns a sync iterator.
for await...of可以迭代async iterable,这是它和for...of在功能上最直接的区别。而且,当迭代sync iterable,并且迭代变量是async variable时,for await...of会返回resolved values,而for...of只会返回一系列Promise。下面这个例子展示了它们在面对sync iterable时的区别(完整代码):
const numberPromises = [
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3),
  Promise.resolve(4),
  Promise.resolve(5)
]
async function forOfAsync() {
  for (const i of numberPromises) {
    await someRandomWork();
    console.log(await getCurrentTime(), i);
  }
  console.log('forOfAsync finished!')
}
async function forAwaitOfAsync() {
  for await (const i of numberPromises) {
    await someRandomWork();
    console.log(await getCurrentTime(), i);
  }
  console.log('forAwaitOfAsync finished!')
}
async function main() {
  console.log(`\nfor...of test`);
  await forOfAsync();
  console.log(`\nfor await...of test`);
  await forAwaitOfAsync();
}
main();
// expected output:
//
// for...of test
// 00:00:00.988 Promise { 1 }
// 00:00:01.933 Promise { 2 }
// 00:00:02.562 Promise { 3 }
// 00:00:03.189 Promise { 4 }
// 00:00:03.816 Promise { 5 }
// forOfAsync finished!
// for await...of test
// 00:00:00.627 1
// 00:00:01.254 2
// 00:00:01.882 3
// 00:00:02.509 4
// 00:00:03.136 5
// forAwaitOfAsync finished!此外,MDN上的这个例子展示了for await...of迭代async iterable的行为。
0x06 如果你在意性能的话
你可能会注意到Promise.all()比顺序执行的方案稍微慢一点。如果你在意性能的话,可以试试这个map和for...of的例子。在笔者的环境(M1,Node.js v16.17.0-arm64)下,这个例子展现出了相当有趣的差异。
参考链接: