🧩

【JavaScript】forEachと非同期処理が噛み合わなかった話

に公開
3

株式会社アドバンテッジリスクマネジメント DX開発2部の古堅です。
今回は、不具合の解消を通じて学んだ繰り返し処理と非同期処理の競合について書きます。

背景

Vue.jsで実装したアプリのメニュー構造について、特定条件下で意図しない構造になるという不具合に遭遇しました。
メニューリストを繰り返し処理(forEach)を用いて作成しており、その内部で非同期処理(async/await)をしていることが原因でしたが、なぜforEach内でasync/awaitがうまく機能しないのか、最初は理解できませんでした。

本記事が、同様の事象に悩んでいる方(と未来の自分)にとって、参考になれば幸いです。

結論(なぜforEach内のasync/awaitがうまく機能しないのか?)

先に結論だけをまとめると、

forEach内でasync/awaitを使用しても、処理を順番に待ってくれるわけではなく、非同期のまま並行して処理されてしまうので、意図と違う結果になる可能性がある。

です。以下で事例と合わせて見ていきます。

実行環境

  • Vue2

用語の整理(事前知識)

事例を見る前に、今回登場する用語の説明です。

■ 同期処理・非同期処理

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

非同期処理の例には、APIリクエストやタイマー処理などがあります。

■ Promise

Promiseは非同期処理の結果を表すオブジェクトです。処理が成功するとresolve、失敗するとrejectを返します。async/awaitPromiseを使って非同期処理を直感的に書くための構文です。
(参考: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()を非同期処理で実行している箇所です。

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);
  }
 }
});

原因(何が起きているのか?)

forEachPromiseを返さないため、awaitを使用してもその処理が終わるのを待ってくれません。そのせいで、ループの中の非同期処理がまだ終わっていないのに、次のループがどんどん進んでしまいます。結果として、処理の順番がバラバラになったり、意図しない動きになることがありえます。

先ほどあげた2つの問題はどちらもこれで説明ができ、今回のケースの場合、以下のような流れで不具合が発生したと考えられます。

1. forEachステップ1:"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);が実行される

2. forEachステップ1:"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;が実行される。

解決策

forEachではなく、for await...ofを使用して繰り返し処理を行うことで今回の不具合を解消しました。for await...ofは各ループの非同期的部分を同期的部分と同様に処理するため、forEachのように非同期処理が並列に動作して順序が崩れることはありません。(参考:for await...of - JavaScript | MDN
また、非同期処理をすぐに実行させるため、即時実行関数(参考:IIFE (即時実行関数式) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN)を利用しました。

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);
      }
    }
  }
})();

【追記】 Array.fromAsync()による解決

Array.fromAsync()を使うと、非同期反復可能なオブジェクトに対して、非同期処理を順番に実行することができます。この関数を用いても今回の問題の解消が可能です。(参考:Array.fromAsync() - JavaScript | MDN

script.js(不具合解消後)
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);
    }
  }
});

まとめ

今回の不具合を通して、繰り返し処理と非同期処理を組み合わせる際には、その振る舞いを正しく理解して適切な構文を選ぶ必要があるということを学びました。

「なぜか期待した動きにならない」というときには、同期・非同期の観点からのアプローチも武器として持っておこうと思います。

ここまで読んでいただきありがとうございました!

参考にした記事

ARMテックブログ

Discussion

junerjuner
mfurugenmfurugen

コメントいただきありがとうございます🙇‍♂️
キャッチできていなかったので、教えてくださり大変助かります。記事の中に追記させていただきました。

Array.fromAsync()の方がfor await...ofよりも分かりやすく、既存のコードからの変更も少ないので、今回の解決策として適していると感じました。

junerjuner

ただし、 Array.fromAsync() は 最終的に配列に変換する為のメソッドですので、無限イテレータとかを食わせる場合は不適切なのに注意が必要です。

なお、 proposal の async iterator helper が Stage4 になれば 下記の様に Object.values(array).values().toAsync().forEach(...) の形に収まる予定です。

https://github.com/tc39/proposal-async-iterator-helpers

await Object.values(ROOT_MENU)
.values()
.toAsync()
.forEach(async root => {
// ...
});

Object.values():Array → Array.prototype.values():Iterator → Iterator.prototype.toAsync():AsyncIterator → AsyncIterator.prototype.forEach():Promise