JavaScript のループ内で await したい

いきなりタイトルに反しますが、大抵 ESLint の no-await-in-loop や no-restricted-syntax の対象になるので、並列に処理して問題無い場合は Promise.all を使い、ループ内の await を避けるリファクタリングをしましょう。

forEach では await が使えない

まず、Array.prototype.forEach によるループで await は諦めましょう。
MDN の解説にあるコード例の通り、await しても意図した動作になりません。

実行する非同期処理

挙動を確認する非同期処理は次の通り。

  1. 非同期処理の開始時にコンソール出力
  2. 非同期処理が完了後(指定時間経過後)にコンソール出力
  3. 非同期処理の結果として、現在待った時間と、次に待つ時間を返す

という、順次実行する必要がある(並列に処理できない)場合を考えてみます。

const asyncSub = (n) => new Promise((resolve) => {
  console.log(`${n} start.`);
  setTimeout(() => {
    console.log(`${n} resolve.`);
    resolve({ current: n, next: n - 3 });
  }, n);
});

期待する挙動

まずは期待する処理をループ無しで実行してみます。

const exec = async () => {
  const r1 = await asyncSub(8);
  console.log(`${r1.current} end.`);

  const r2 = await asyncSub(r1.next);
  console.log(`${r2.current} end.`);

  const r3 = await asyncSub(r2.next);
  console.log(`${r3.current} end.`);
};
exec();

結果は当然期待どおり、順番に処理されます。

8 start.
8 resolve.
8 end.
5 start.
5 resolve.
5 end.
2 start.
2 resolve.
2 end.

then で処理する

いきなりループ内でも await でも無いんかい。
まぁ、処理がループしてますので。
await は…できてません。
結果自体は期待通りですが、読みやすさは今ひとつ。

const exec = (n) => {
  asyncSub(n)
    .then((r) => {
      console.log(`${r.current} end.`);
      if (r.next > 0) {
        exec(r.next);
      }
    });
};
exec(8);

while で処理する

結果は期待通りですが、ESLint で no-await-in-loop の対象になるので、コメントで指摘を除外します。
ESLint のドキュメント上も、このように順序が関係するループなら除外してOKの様です。

When Not To Use It
In many cases the iterations of a loop are not actually independent of each-other. For example, the output of one iteration might be used as the input to another. Or, loops may be used to retry asynchronous operations that were unsuccessful. Or, loops may be used to prevent your code from sending an excessive amount of requests in parallel. In such cases it makes sense to use await within a loop and it is recommended to disable the rule via a standard ESLint disable comment.

(多くの場合、ループの反復は実際には互いに独立していません。たとえば、ある反復の出力が別の反復への入力として使用される場合があります。または、ループを使用して、失敗した非同期操作を再試行することもできます。または、ループを使用して、コードが過剰な量のリクエストを並行して送信するのを防ぐことができます。このような場合await、ループ内で使用するのが理にかなっており、標準のESLint無効化コメントを使用してルールを無効にすることをお勧めします。by Google翻訳)

https://eslint.org/docs/rules/no-await-in-loop
const exec = async () => {
  let s = ({ next: 8 });
  while (s.next > 0) {
    // eslint-disable-next-line no-await-in-loop
    s = await asyncSub(s.next);
    console.log(`${s.current} end.`);
  }
};
exec();

for で処理する

while と変わりません。

const exec = async () => {
  for (let s = ({ next: 8 }); s.next > 0;) {
    // eslint-disable-next-line no-await-in-loop
    s = await asyncSub(s.next);
    console.log(`${s.current} end.`);
  }
};
exec();

for await で処理する(1)

結果は期待通りですが、イテレータを作る労力や、前の非同期処理の結果を引き継ぐ部分も微妙ですし、ESLint に no-restricted-syntax の対象になります。
for で処理した方が分かりやすいですね。for await の良い使い所って何なんだろうか。

const exec = async () => {
  let n = 8;
  const asyncIterable = {
    [Symbol.asyncIterator]() {
      return {
        next() {
          if (n > 0) {
            return asyncSub(n)
              .then((r) => ({ value: r, done: false }));
          }
          return Promise.resolve({ done: true });
        },
      };
    },
  };
  for await (const r of asyncIterable) {
    n = r.next;
    console.log(`${r.current} end.`);
  }
};
exec();

Promise.all で処理する

基本的にはコレに置き換えられないか考えることになると思いますが、

  • 前の非同期処理の結果に関係なく用意できて
  • 並列に実行されても良く
  • 全非同期処理の完了後に結果が処理できれば良い

必要があるので、今回例にした非同期処理では無理です。

const exec = async () => {
  const procs = [8, 5, 2]
    .map((n) => asyncSub(n));
  const ret = await Promise.all(procs);
  ret.map(r => console.log(`${r.current} end.`));
};
exec();

結果は次の通り、並列に処理されてますね。

8 start.
5 start.
2 start.
2 resolve.
5 resolve.
8 resolve.
8 end.
5 end.
2 end.

処理が済んだものから結果を返して終わってます。
結果の処理は、全ての非同期処理結果が終わってからになっています。

for await で処理する(2)

次のようにした for wait は Promise.all と「結果は同じ」でした。
ESLint で no-restricted-syntax を指摘されるので Promise.all が良いでしょう。
というか「結果が同じ」になることが分かりづらいので、Promise.all にした方が良いでしょう。
どの辺りを分かりづらく感じるかというと

  • 非同期処理が並列に実行される
    → 非同期処理を配列に用意している時点から非同期処理は始まっている
  • 全非同期処理が済んでから結果が処理される(ループ内の処理がされる)

所です。

const exec = async () => {
  const procs = [8, 5, 2]
    .map((n) => asyncSub(n));
  for await (const r of procs) {
    console.log(`${r.current} end.`);
  }
};
exec();

非同期処理を await で待つとき、ループなどで勘違いを起こさないようにしたいですね。

プロを目指す人のためのTypeScript入門 (Amazon)

コメント