はじめに
とあるメンバーから、表題のようなことを言われました。
どうやら以下のようなコードで、「forEachでawaitを使っているのに処理順がおかしくなる」とのことから、この結論に至ったそうです。
async function testAsync(v) {
await new Promise(resolve => {
setTimeout(resolve, 100);
});
return v + 1;
}
const data = [];
const params = [0, 1, 2];
params.forEach(async v => {
const res = await testAsync(v);
console.log(res);
data.push(res);
});
console.log(data);
// [] 1 2 3
彼としてはawaitによって非同期処理の完了を待ち、結果をdata配列に挿入していき、最終的にdata配列が[1, 2, 3]となる想定だったようですが、実際には空配列になっています。
これを見て「うんそれはそうなるよね・・・」と思いはしたのですが、彼によるとググると同じような話題のページがいくつか引っかかったとのこと。
検索してみると確かにそれなりに件数があり、それに対する解決策などもあるのですが・・・。
そもそもなんでこのコードで動作すると思ったんだ? というところが気になり始めました。
結論としては、どうやら awaitの挙動を間違って認識しているのではないかなと。
そして forEachでasync/awaitが動かないというのは風評被害だということです。
追記
コメントでも指摘いただいたとおり、この状況であればforEachではなくmapのほうが適していますので、皆さんも配列から対になる配列を得たいならmapを使用してください。
彼の主張
彼としては
for (const v of params) {
const res = await testAsync(v);
console.log(res);
data.push(res);
}
console.log(data);
のような「同等のコードにしたところ正常に動作したので、やはりforEachの問題なのでは?」という主張のようです。
なるほど確かに一見同等に見えますが、これは同等ではありません。
どこでつまづいているのか
試しに以下のようなコードで、いくつか質問をしてみました。
(async () => {
async function sampleAsync(v) {
await new Promise(resolve => {
setTimeout(resolve, 100);
});
console.log(v);
}
console.log(1);
await sampleAsync(2); // A
console.log(3);
})();
問1 これだとどういう順番で表示される?
答1 1 2 3
これは理解していそう。
問2 コード内のAのawaitを外したら?
答2 1 3 2
これも大丈夫そう。
問3 動かないコードって、問2のようなことになってるよね?
答3 ???
おや?
問4 awaitってどういう認識してる?
答4 awaitしたところで処理を止めて、Promiseの結果を待つ
一見合ってはいる。
問5 じゃあ答2が 1 3 2 なのはなぜ? sampleAsync内でawaitしてるよ?
答5 それは別の関数内でのことだから・・・あっ!
どうやらここで理解してくれたようです。
彼が提示した「動かないコード」では、forEachのコールバック関数内でawaitを行っていますが、対して「同等だと主張するコード」では、ただforループ内で行っています。
この違いが、ことawaitの挙動としては同等ではないという理由です。1
なぜ勘違いしたのか
どうやら彼は、awaitについて「Promiseの結果が出るまで、後続の処理を待つもの」というだけの、ふわっとした認識だったようです。
これ自体は完全な間違いではないのですが、正確には関数スコープ内での後続の処理を待つものです。
MDNにもちゃんと
await 式は async 関数の実行を一時停止し、 Promise が決定される(すなわち履行または拒否される)まで待ち、履行された後に async 関数の実行を再開します。
と記載されてます。
一時停止する範囲はあくまでasync関数内のみであり、その他の関数スコープに影響は及ぼしません。
及ぼしたらもはや非同期関数として取り扱えないですし。
この「一時停止する範囲」が恐らくクセモノで、「動かないコード」では引数に直接関数式を使用しているため、別関数であるにも関わらずあたかも一連の処理の塊に見えてしまい
params.forEach(async v => {
const res = await testAsync(v);
// testAsyncが解決したらここに到達するよね(これは正しい)
console.log(res);
data.push(res);
});
// awaitしてるから、全て解決するまでここに到達しないよね(これは間違い)
console.log(data);
のような勘違いが発生してしまったようでした。
おわりに
同様の問題に直面したネット上の方々が、彼と同じ理由でつまづいたのかどうかはわかりません。
勘違いはしておらず、「forEachにasync関数を送っても、forEach自体がコールバックをawaitで待たないからダメだ」という意味合いなのかもしれません。2
ただいずれにしても、asyncな関数を送れば、その関数自体はきちんと非同期で動作しているわけですし、「forEachでasync/awaitが使えない」としてしまうのは、ちょっと誤解を生むよなあと思った年の瀬でした。
おまけ
せっかくなので、多少「直感的に書けるような」サンプルも置いておこうと思います。
Array.prototype.forEachAsync = async function(callback, thisArg) {
const promises = [];
this.forEach(function(...args) {
promises.push(callback.call(this, ...args));
}, thisArg);
return await Promise.all(promises);
}
async function sampleAsync(v) {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
return v + 1;
}
const data = [];
const params = [0, 1, 2];
await params.forEachAsync(async v => {
const res = await sampleAsync(v);
console.log(res);
data.push(res);
});
console.log(data);
// 1 2 3 [1, 2, 3]