はじめに
とあるメンバーから、表題のようなことを言われました。
どうやら以下のようなコードで、「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]