相关文章推荐
耍酷的芹菜  ·  [Python] async def & ...·  3 月前    · 
重情义的刺猬  ·  產生 OpenAPI 文件 | ...·  4 周前    · 
踏实的鸡蛋面  ·  android ...·  1 年前    · 
力能扛鼎的饭卡  ·  python ...·  1 年前    · 
火星上的回锅肉  ·  SVG 阴影 | 菜鸟教程·  2 年前    · 

今天研究了一下JS中的异步迭代。

0x00 TL;DR

0x01 为什么不行?

考虑下面这段程序。

const numbers = [1, 2, 3, 4, 5];
numbers.forEach(async value => {
    await someRandomWork();
    console.log(value);
console.log('finished!');

看起来, forEach() 在遍历到数组中每个值时都会 await ,直到遍历完整个数组。但事实并非如此。实际的输出是这样:

finished!
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 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/