先日、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<void> {
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...of
かPromise.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
されているかどうかはわからないので、コールバック関数には非同期関数を渡さないのが安全そうです。