2025-03-08

forEach内でasync awaitはなぜ使えないのか

Vue.js
Vue.jsの関数forEach内でasync awaitを使用している処理がうまく動作していませんでした。 調べてみると、「forEachでasync awaitは使うな」ということを見たので、その理由を考えてみました。

結論

forEach内でasync/awaitを使用すると、awaitが期待通りに機能せず、非同期処理が並列実行されます。そのため、処理の順序が保証されず、意図しない動作になる可能性があるため、「forEachでasync awaitは使うな」です。

実行環境

  • Vue2

同期処理と非同期処理

同期処理とは

同期処理は、コードが上から順番に実行され、前の処理が完了しない限り次の処理が実行されない仕組みです。

非同期処理とは

非同期処理は、処理の完了を待たずに次の処理が進む仕組みです。(例:APIリクエストやタイマー処理)

Promiseとは

Promiseは非同期処理の結果を表すオブジェクトで、resolveされると成功、rejectされると失敗となります。async/awaitPromiseを使って非同期処理を直感的に書くための構文です。参考▼

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
引用:

今回の例

今回は以下のような「メニューの表示を制御する処理」の実装部分で、async awaitを使用していました。
script.js
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);
  }
 }
});
tempMenuは以下のような配列です。
tempMenu
tempMenu = [
 { menuCode: "MainMenu1" },
 {
   menuCode: "MainMenu2",
   submenus: [
    { menuCode: "SubMenu2_1" },
    { menuCode: "SubMenu2_2" }
   ]
 },
 {
   menuCode: "MainMenu3",
   submenus: [
    { menuCode: "SubMenu3_1" },
    { menuCode: "SubMenu3_2" }
   ]
 },
 { menuCode: "MainMenu4" }
]

何が起きているのか?

forEachPromiseを返さないため、awaitを使用してもforEach自体の処理が同期的に進んでしまいます。その結果、awaitで非同期処理を待っている間に forEach のループが進行し、意図した順序で処理されない可能性があります。
今回の例の場合、forEachの中で以下のようなことが起きていたと考えられます。

1. forEachステップ1:ROOT_A("MainMenu2")開始

  1. rootMenuIndex = 1 → if文の中に入る
  2. "MainMenu2"のsubmenus forステップ1:"SubMenu2_1"開始
  3. await this.isDisplaySubmenu(submenu, menu)を待たずに、"MainMenu2"のsubmenus forステップ2:"SubMenu2_2"開始 ・・・(A)
  4. await this.isDisplaySubmenu(submenu, menu)を待たずに、"MainMenu2"のsubmenus forステップ終了 ・・・(B)
  5. newSubmenuには何も入っておらず、tempMenu.splice(rootMenuIndex, 1);が実行される
ROOT_A("MainMenu2")がtempMenuから欠落
tempMenu
tempMenu = [
 { menuCode: "MainMenu1" },
 {
   menuCode: "MainMenu3",
   submenus: [
    { menuCode: "SubMenu3_1" },
    { menuCode: "SubMenu3_2" }
   ]
 },
 { menuCode: "MainMenu4" }
]

2. forEachステップ1:ROOT_B("MainMenu3")開始

  1. rootMenuIndex = 1 → if文の中に入る
  2. "MainMenu3"のsubmenus forステップ1:"SubMenu3_1"開始
  3. このタイミングで(A)の処理が終了した場合、newSubMenuに"SubMenu2_1"が追加される
  4. このタイミングで(B)の処理が終了した場合、newSubMenuに"SubMenu2_2"が追加される
  5. await this.isDisplaySubmenu(submenu, menu)を待たずに、"MainMenu2"のsubmenus forステップ2:"SubMenu3_2"開始
  6. await this.isDisplaySubmenu(submenu, menu)を待たずに、"MainMenu2"のsubmenus forステップ終了
  7. newSubmenuには"SubMenu2_1"、"SubMenu2_2"が入っている
tempMenu[rootMenuIndex].submenus = newSubmenu;が実行される。
tempMenu
tempMenu = [
 { menuCode: "MainMenu1" },
 {
   menuCode: "MainMenu3",
   submenus: [
    { menuCode: "SubMenu2_1" }, // ←違うメインメニューのサブメニュー
    { menuCode: "SubMenu2_2" }  // ←違うメインメニューのサブメニュー
   ]
 },
 { menuCode: "MainMenu4" }
]

期待値

isDisplaySubmenuの非同期処理が順番に実行されて、最終的に以下のようなメニュー構造となることを期待していました。
tempMenu
tempMenu = [
 { menuCode: "MainMenu1" },
 {
   menuCode: "MainMenu2",
   submenus: [
    { menuCode: "SubMenu2_1" },
    { menuCode: "SubMenu2_2" }
   ]
 },
 {
   menuCode: "MainMenu3",
   submenus: [
    { menuCode: "SubMenu3_1" },
    { menuCode: "SubMenu3_2" }
   ]
 },
 { menuCode: "MainMenu4" }
]

解決策

for await...ofを使用

for await...ofは各ループの非同期的部分を同期的部分と同様に処理するため、forEachのように非同期処理が並列に動作して順序が崩れることがありません。参考▼
script.js
(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);
      }
    }
  }
})();
他にも、Promise.allを用いた解決方法もあります。参考▼

asyncの前にある( と末尾の()の意味

細かいですがasyncの前にある( と末尾の()について気になったので調べてみました。 これには、async/awaitをすぐに実行するための2つの要素が含まれています。
  • (async () => { ... })asyncを含む無名関数を定義
  • ()() → 定義した関数をすぐに実行:参考▼
以上です。 便利だからという理由で安易にasync/awaitを使うとこのようなことが起きてしまうので、きちんとした理解と注意が必要だと感じました。

参考記事