【JavaScript】forEachと非同期処理が噛み合わなかった話
株式会社アドバンテッジリスクマネジメント DX開発2部の古堅です。
今回は、不具合の解消を通じて学んだ繰り返し処理と非同期処理の競合について書きます。
背景
Vue.jsで実装したアプリのメニュー構造について、特定条件下で意図しない構造になるという不具合に遭遇しました。
メニューリストを繰り返し処理(forEach
)を用いて作成しており、その内部で非同期処理(async/await
)をしていることが原因でしたが、なぜforEach
内でasync/await
がうまく機能しないのか、最初は理解できませんでした。
本記事が、同様の事象に悩んでいる方(と未来の自分)にとって、参考になれば幸いです。
forEach
内のasync/await
がうまく機能しないのか?)
結論(なぜ先に結論だけをまとめると、
forEach
内でasync/await
を使用しても、処理を順番に待ってくれるわけではなく、非同期のまま並行して処理されてしまうので、意図と違う結果になる可能性がある。
です。以下で事例と合わせて見ていきます。
実行環境
- Vue2
用語の整理(事前知識)
事例を見る前に、今回登場する用語の説明です。
■ 同期処理・非同期処理
同期処理は、コードが上から順番に実行され、前の処理が完了しない限り次の処理が実行されない仕組みです。一方、非同期処理は、処理の完了を待たずに次の処理が進む仕組みです。
非同期処理の例には、APIリクエストやタイマー処理などがあります。
■ Promise
Promise
は非同期処理の結果を表すオブジェクトです。処理が成功するとresolve
、失敗するとreject
を返します。async/await
はPromise
を使って非同期処理を直感的に書くための構文です。
(参考:await - JavaScript | MDN)
■ forEach
forEach
メソッドは、配列の繰り返し処理を行うJavaScriptの関数です。forEach
は同期関数であることが期待されてます。
forEach()
は同期関数を期待します。プロミスを待ちません。forEach
のコールバックとしてプロミス(または非同期関数)を使用する場合は、その意味合いを理解しておくようにしてください。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 // 実際の出力: 0
(引用:Array.prototype.forEach() - JavaScript | MDN)
↑ console.log(sum)
は、forEach
の非同期処理が完了する前に実行されるため、sum
は更新されていません。
具体的事例(今回の不具合について)
ここからは、私が直面した不具合について、何が起きていたのかをコードと共に見ていきます。
不具合内容
メニューリストを作成する処理を通したとき、以下2つの問題が発生する不具合が生じました。
メニューリストの作成(全メニューを表示するユースケースにて) | |
---|---|
期待値 | ![]() |
不具合 | ![]() |
「set menu width filter」部分は以下のようなスクリプトです。forEach
を用いてメニューリストの表示制御を繰り返し処理しています。
ポイントとなるのは、forEach
内でawait
を用いてthis.isDisplaySubmenu()
を非同期処理で実行している箇所です。
const ROOT_MENU = {
ROOT_A: { MENUCODE: "MainMenu2" },
ROOT_B: { MENUCODE: "MainMenu3" }
}
// setting submenu with filter
Object.values(ROOT_MENU).forEach(async root => {
const rootMenuIndex = tempMenu.findIndex(
menuItem => menuItem.menuCode === root.MENUCODE
);
if (rootMenuIndex > -1) {
const newSubmenu = [];
for (const submenu of tempMenu[rootMenuIndex].submenus) {
if (await this.isDisplaySubmenu(submenu, menu)) {
newSubmenu.push(submenu);
}
}
if (newSubmenu.length) {
tempMenu[rootMenuIndex].submenus = newSubmenu;
} else {
tempMenu.splice(rootMenuIndex, 1);
}
}
});
原因(何が起きているのか?)
forEach
はPromise
を返さないため、await
を使用してもその処理が終わるのを待ってくれません。そのせいで、ループの中の非同期処理がまだ終わっていないのに、次のループがどんどん進んでしまいます。結果として、処理の順番がバラバラになったり、意図しない動きになることがありえます。
先ほどあげた2つの問題はどちらもこれで説明ができ、今回のケースの場合、以下のような流れで不具合が発生したと考えられます。
forEach
ステップ1:"MainMenu2"の処理開始
1. - rootMenuIndex = 1 となり、最初のif文の中に入る
- "MainMenu2"のsubmenus
for
ステップ1:"SubMenu2_1"
の処理開始 -
await this.isDisplaySubmenu(submenu, menu)
を待たずに、"MainMenu2"のsubmenusfor
ステップ2:"SubMenu2_2"
の処理開始 ・・・(A) -
await this.isDisplaySubmenu(submenu, menu)
を待たずに、"MainMenu2"のsubmenusfor
ステップが終了 ・・・(B) -
newSubmenu
には何も入らず、tempMenu.splice(rootMenuIndex, 1);
が実行される
forEach
ステップ1:"MainMenu3"の処理開始
2. - rootMenuIndex = 1 となり、最初のif文の中に入る
- "MainMenu3"のsubmenus
for
ステップ1:"SubMenu3_1"
の処理開始 - このタイミングで(A)の処理が終了し、
newSubMenu
に"SubMenu2_1"が追加される - このタイミングで(B)の処理が終了し、
newSubMenu
に"SubMenu2_2"が追加される -
await this.isDisplaySubmenu(submenu, menu)
を待たずに、"MainMenu2"のsubmenusfor
ステップ2:"SubMenu3_2"
の処理開始 -
await this.isDisplaySubmenu(submenu, menu)
を待たずに、"MainMenu2"のsubmenusfor
ステップが終了 -
newSubmenu
には"SubMenu2_1"、"SubMenu2_2"が入り、tempMenu[rootMenuIndex].submenus = newSubmenu;
が実行される。
解決策
forEach
ではなく、for await...of
を使用して繰り返し処理を行うことで今回の不具合を解消しました。for await...of
は各ループの非同期的部分を同期的部分と同様に処理するため、forEach
のように非同期処理が並列に動作して順序が崩れることはありません。(参考:for await...of - JavaScript | MDN)
また、非同期処理をすぐに実行させるため、即時実行関数(参考:IIFE (即時実行関数式) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN)を利用しました。
(async () => {
for await (const root of Object.values(ROOT_MENU)) {
const rootMenuIndex = tempMenu.findIndex(
menuItem => menuItem.menuCode === root.MENUCODE
);
if (rootMenuIndex > -1) {
const newSubmenu = [];
for (const submenu of tempMenu[rootMenuIndex].submenus) {
if (await this.isDisplaySubmenu(submenu, menu)) {
newSubmenu.push(submenu);
}
}
if (newSubmenu.length) {
tempMenu[rootMenuIndex].submenus = newSubmenu;
} else {
tempMenu.splice(rootMenuIndex, 1);
}
}
}
})();
Array.fromAsync()
による解決
【追記】 Array.fromAsync()
を使うと、非同期反復可能なオブジェクトに対して、非同期処理を順番に実行することができます。この関数を用いても今回の問題の解消が可能です。(参考:Array.fromAsync() - JavaScript | MDN)
Array.fromAsync(Object.values(ROOT_MENU), async root => {
const rootMenuIndex = tempMenu.findIndex(
menuItem => menuItem.menuCode === root.MENUCODE
);
if (rootMenuIndex > -1) {
const newSubmenu = [];
for (const submenu of tempMenu[rootMenuIndex].submenus) {
if (await this.isDisplaySubmenu(submenu, menu)) {
newSubmenu.push(submenu);
}
}
if (newSubmenu.length) {
tempMenu[rootMenuIndex].submenus = newSubmenu;
} else {
tempMenu.splice(rootMenuIndex, 1);
}
}
});
まとめ
今回の不具合を通して、繰り返し処理と非同期処理を組み合わせる際には、その振る舞いを正しく理解して適切な構文を選ぶ必要があるということを学びました。
「なぜか期待した動きにならない」というときには、同期・非同期の観点からのアプローチも武器として持っておこうと思います。
ここまで読んでいただきありがとうございました!
Discussion
Array.fromAsync() でもよさそう感ある。
コメントいただきありがとうございます🙇♂️
キャッチできていなかったので、教えてくださり大変助かります。記事の中に追記させていただきました。
Array.fromAsync()の方がfor await...ofよりも分かりやすく、既存のコードからの変更も少ないので、今回の解決策として適していると感じました。
ただし、 Array.fromAsync() は 最終的に配列に変換する為のメソッドですので、無限イテレータとかを食わせる場合は不適切なのに注意が必要です。
なお、 proposal の async iterator helper が Stage4 になれば 下記の様に
Object.values(array).values().toAsync().forEach(...)
の形に収まる予定です。Object.values():Array → Array.prototype.values():Iterator → Iterator.prototype.toAsync():AsyncIterator → AsyncIterator.prototype.forEach():Promise