await整个循环?——JavaScript中的异步迭代方案

今天研究了一下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()跑到了最前面。不过仔细观察,可以发现awaitconsole.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...offor await...of

如果用for await...of替换上面代码中的for...of,会发现运行结果没有什么变化。原因是,虽然带了“await”,for await...of并不是用在这个场景中的。MDN这样介绍:

When a for await...of loop 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)下,这个例子展现出了相当有趣的差异。


参考链接:

  1. javascript - Using async/await with a forEach loop - Stack Overflow
  2. Async functions • JavaScript for impatient programmers (ES2022 edition)
  3. tc39/proposal-async-iteration: Asynchronous iteration for JavaScript
  4. for await...of - JavaScript | MDN

本文链接:

https://www.direcore.xyz/archives/35/
1 + 1 =
快来做第一个评论的人吧~