forEach()でも他の配列メソッドでも、asyncは正しく動く。ただ、配列メソッドに渡した関数が要素順に実行されなくなり、混乱が生じやすい。なるべくなら使わない方が無難だ。MDNにも非推奨と書いてある。

最小限のコードで挙動を見る

ts
async function wait(time: number) {
  return await new Promise<void>((resolve) =>
    setTimeout(() => {
      console.log(time);
      resolve();
    }, Math.floor(Math.random() * 5))
  );
}

[0, 1, 2, 3, 4].forEach(async (n) => await wait(n));
/**
 * 出力:
 * 0
 * 3
 * 4
 * 1
 * 2
 **/

awaitしているのだから0 1 2 3 4になって欲しいところだが、この場合の出力結果は実行するたびに異なる。

同期的に処理されない理由

配列メソッドは、端から順番に(=前の要素に適用された関数が終了した後に)次の要素を走査していくのが暗黙の了解になっている。ただ、asyncをつけた関数は精神と時の部屋みたいになる。具体的には、内側と外側と時間がズレる。

これを配列メソッドに渡すと、配列の要素ごとに精神と時の部屋ができあがり、それぞれの関数の終了タイミングが予測困難になってしまう。

例えば配列.map(async ()=> await 非同期関数())みたいにすると、まず精神と時の部屋の配列、つまりPromiseの配列ができ上がり、そこから処理の軽い順に実行が終わっていく。

値が返される配列メソッドならまだやりようはあるが、forEach()のように投げっぱなしのメソッドだと制御が面倒だ。これが理由で、配列メソッドの中でも特にforEach()asyncキーワードをつけた関数を渡すのは避けたほうが良い、とされている。

※以下のサンプルコードでは、簡便のためにawaitキーワードをトップレベルに記述している。これはモジュールの場合にしか機能しない。非モジュールで利用する場合は、async関数の中で使う必要がある。

代案1:Array.fromAsync()

複数の非同期関数を順番に実行したい場合、Array.fromAsync()という静的メソッドをを使うのが現状もっとも手軽な方法かと思う。

js
console.log("はじまりはじまり");
await Array.fromAsync([0, 1, 2, 3, 4], async (n) => {
  await wait(n);
});
console.log("おしまい");
/**
 * 出力:
 * はじまりはじまり
 * 0
 * 1
 * 2
 * 3
 * 4
 * おしまい
 **/

代案2:配列.reduce()

配列.reduce()メソッドは、一つ前の結果を関数に渡してくれる。非同期関数の場合はPromiseが渡されるため、これをawaitすることで順次処理を実現できる。

js
console.log("はじまりはじまり");
await [0, 1, 2, 3, 4].reduce(async (p, n) => {
  await p;
  return wait(n);
}, Promise.resolve()); // これは最初の関数の`await p`のために必要。
console.log("おしまい");
/**
 * 出力:
 * はじまりはじまり
 * 0
 * 1
 * 2
 * 3
 * 4
 * おしまい
 **/

代案3:for…of

複数の非同期関数を順次実行しようとするのが問題の根幹である。ひとつのasyncの中でawaitすれば問題は解決する。

js
console.log("はじまりはじまり");
for (let n of [0, 1, 2, 3, 4]) {
  await wait(n);
}
console.log("おしまい!");
/**
 * 出力:
 * はじまりはじまり
 * 0
 * 1
 * 2
 * 3
 * 4
 * おしまい!
 **/

おしまい。

参考資料