forEach内でawaitはできない(JavaScript、TypeScript)

322
NO IMAGE

先日、TypeScriptでの実装で、すごく基本的なところでハマってしまったので、メモとして残しておきます。

TypeScript(JavaScript)でforEach内でawaitを記述したら、エラー(「'await' 式は、非同期関数内と、モジュールのトップレベルでのみ許可されます。」)となりました。

エラーとなったコードはこんなやつです。


const millisecs = [1000, 2000, 3000]
millisecs.forEach(millisec => {
  await sleep(millisec)
  console.log(millisec)
})
console.log('forEach完了')

sleep()関数はこんなもので、引数で指定した時刻(ミリ秒)処理を中断します。


async function sleep (millisec: number): Promise {
  return new Promise(resolve => {
    setTimeout(resolve, millisec)
  })
}

Visual Studio Codeのクイックフィックスを利用したところ、「含まれている関数にasync修飾子を追加します」が候補にあったので、これを選択すると下記のようにコードが修正されました。


const millisecs = [1000, 2000, 3000]
millisecs.forEach(async millisec => { // クイックフィックスによってasyncが追加された
  await sleep(millisec)
  console.log(millisec)
})
console.log('forEach完了')

なるほど、asyncの付け忘れだったかと思い納得したのですが、実行してみると下記のような予想外の結果となりました。

forEach完了
1000 // 1秒後
2000 // さらに1秒後
3000 // さらに1秒後

予想していた結果はこうです。

1000 // 1秒後
2000 // さらに2秒後
3000 // さらに3秒後
forEach完了

調べてみたところ、mdn web docsにforEach は同期関数を期待します。との記載を見つけました。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach

ということで、forEach内に渡すコールバック関数は、同期関数でないとダメなようです。

繰り返し処理で非同期関数を扱いたい場合は、下記のようにfor...ofPromise.all()を使うということになります。


const millisecs = [1000, 2000, 3000]

// for...ofを使った場合
for (const millisec of millisecs) {
  await sleep(millisec)
  console.log(millisec)
}

// Promise.all()を使った場合
await Promise.all(millisecs.map(async millisec => {
  await sleep(millisec)
  console.log(millisec)
}))

console.log('forEach完了')

ただし、Promise.all()を使った場合はコールバック関数が並列で処理されるため、結果は以下のようになります。ご注意ください(@arimoo さんご指摘ありがとうございます)。

1000 // 1秒後
2000 // さらに1秒後
3000 // さらに1秒後
forEach完了

さらに、今回はsleepする時間に差があるためコールバック関数が順番に呼ばれているように見えますが、実際にはPromise.all()の場合、3回呼ばれるコールバック関数は並列で処理されるため、順序性は保証されない点をご注意ください(@ktz_alias さんご指摘ありがとうございます)。

目次

まとめ

今回の問題は非常に基本的ですが、結構ハマる人が多いようです。

forEachに限らず、コールバック関数がawaitされているかどうかはわからないので、コールバック関数には非同期関数を渡さないのが安全そうです。