forEachの中でawaitしても待たない|MDNの例では、合計14のはずが0になる
forEachのコールバックにasyncを付けても、forEachはPromiseを待たない。
順番に処理するならfor...of、まとめて待つならPromise.all。
失敗したときの扱いと、使い分けの目安までまとめた。
forEach のコールバックに async を付けて、中で await を書いても、forEach は待ってくれない。
MDNの例では、合計が14になるはずのコードが、0を表示して終わる。
順番に処理したいなら for...of、まとめて待ちたいなら Promise.all に書き換える、というのがこの記事の答えだ。
「await を書いたのに待っていない」という症状は、書いた側からすると理不尽に見える。
でも壊れているのは await ではない。
forEach という道具が、もともと待つように作られていないだけだ。
forEach は、コールバックが返す Promise を見ていない
MDNの forEach のページには、はっきり書いてある。
forEach は同期関数を期待していて、プロミスを待たない。
async を付けた関数は、呼ばれるとすぐに Promise を返す。
forEach はその Promise を受け取っても何もしないで、次の要素へ進む。
配列の最後まで呼び終わった時点で、forEach 自体は終わってしまう。
MDNに載っている例は、次のようなコードだ。
const ratings = [5, 4, 5];
let sum = 0;
const sumFunction = async (a, b) => a + b;
ratings.forEach(async (rating) => {
sum = await sumFunction(sum, rating);
});
console.log(sum); // 14 のつもりが 0console.log が動いた時点では、三つの await はどれもまだ終わっていない。
だから sum は初期値の 0 のままだ。
await は「その関数の中」を止めるだけで、外側の forEach までは止めない。
ここを押さえておくと、似た症状にも見当がつく。
順番に一つずつ処理したいなら for...of
前の処理が終わってから次へ進みたいときは、for...of に書き換える。
for...of はループそのものなので、中の await でループごと止まる。
async function total(ratings) {
let sum = 0;
for (const rating of ratings) {
sum = await sumFunction(sum, rating);
}
return sum; // 14
}外側の関数に async が要るのを忘れやすい。
await は async 関数の中でしか書けないからだ。
呼び出す側も await total(ratings) のように受け取らないと、今度はそこで同じことが起きる。
一つずつ順に待つので、件数が多いと時間はかかる。
サーバーへの登録処理のように、順番が大事だったり、同時に投げると相手に負担がかかったりする処理は、僕はこちらを選ぶ。
まとめて待つなら Promise.all と map
順番はどうでもよくて、全部終わったことだけ分かればいいなら、map で Promise の配列を作って Promise.all に渡す。
const results = await Promise.all(
urls.map(async (url) => {
const res = await fetch(url);
return res.json();
})
);
// results は urls と同じ順番で並ぶ処理は同時に走るので、for...of より早く終わることが多い。
結果の配列は、終わった順ではなく元の配列の順番で返ってくる。
ここは MDN の Promise.all のページにも書いてある挙動だ。
気をつけたいのは失敗したときだ。
Promise.all は、渡した Promise のどれか一つが拒否されると、その時点で全体が拒否になる。
一件の失敗で他の結果も受け取れなくなるのが困る場面では、Promise.allSettled を使うと、成功と失敗を一件ずつ確かめられる。
forEach の「止められない」も同じ根っこ
forEach には、もう一つよく聞く不満がある。
途中で break できないことだ。
MDNには、例外を投げる以外に forEach を止める方法はない、と書かれている。
待てないことと、止められないことは、どちらも「forEach は最後まで全部呼ぶだけの道具」という一点から出てくる。
途中で止めたい、途中で待ちたい、と思った時点で、僕は for...of に戻すことにしている。
some や find のように、条件に合ったら止まる配列メソッドを使う手もある。
どれを使うかの目安
僕の考えでは、迷ったら次の三つで分ければいい。
- 同期の処理を全部の要素にかけるだけ → forEach で十分
- 前の処理の結果を待ってから次へ進みたい → for...of + await
- 全部終わればよく、早く終わらせたい → map + Promise.all(失敗も個別に見たいなら allSettled)
await を書いたのに待たないときは、まず「その await を囲んでいる関数は誰が呼んでいるか」を見る。
呼んでいるのが forEach なら、答えはもう出ている。
クリックしたイベントが親要素まで届いてしまう話はイベントバブリングの記事に、ボタンを一回押しただけで申し込みが二件届いた話は二重送信の記事に書いた。
どちらも「書いたとおりに動いていないように見えて、実は仕様どおりだった」話だ。
出典
ほかにも書いています
受託・note・スタンプこういう仕組みを作る相談を受けています
毎日の更新・データ集め・集計の自動化/3万円〜収益がいくらだったか、どの施策が外れたか
note / 個人の視点で掘り下げていますスタンプラリーに挑戦する
行った場所は、現地チェックインで記録に残せます※受託の相談とnoteは、運営者が個人で受けている・書いているものです。
