CLOVER🍀

That was when it all began.

JavaScriptのArray.prototype.forEachにasyncな関数を渡しても、awaitで終了を待てないという話

これは、なにをしたくて書いたもの?

Array.prototype.forEachをなにも考えずに使っていて、ちょっとハマったので。

Array.prototype.forEachawaitと組み合わせて使えないようです。

Array.prototype.forEachとasync/await

MDNリファレンスでのArray.prototype.forEachの記載はこちら。

Array.prototype.forEach() - JavaScript | MDN

「解説」に、以下のようにハッキリと書かれています。

メモ: 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() / 解説

言われてみて、冷静に考えると確かにそうだなという感じなのですが。

もうちょっと言うと、forEachの中でasyncは使えないということはないのですが、その終了をawaitで待てないということですね。

というわけで、あらためて試してみることにしました。

環境

今回の環境は、こちら。

$ node --version
v18.12.1


$ npm --version
8.19.2

確認は、Node.jsで行うことにします。

準備

プログラムの記述はTypeScriptで行い、テストコードで確認することにしましょう。

npmプロジェクトの作成と、依存関係の追加。

$ npm init -y
$ npm i -D @types/node@v18
$ npm i -D typescript
$ npm i -D prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir src test

依存関係は、こうなりました。

  "devDependencies": {
    "@types/jest": "^29.2.3",
    "@types/node": "^18.11.9",
    "esbuild": "^0.15.15",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.3.1",
    "prettier": "^2.8.0",
    "typescript": "^4.9.3"
  }

scripts

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

お題とテストコードの雛形

今回は、以下のようなテストコード例を基本に考えることにします。

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  values.forEach((v) => {
    sum += v;
  });

  expect(sum).toBe(15);

ここで、forEachasyncawaitを組み合わせてどのような動作になるかを確認してみます。

この時、わかりやすいようにスリープを入れたり、時間計測を行うようにしてみます。

テストコードの雛形は、こちら。

test/foreach.test.ts

import { setTimeout } from 'timers/promises';

jest.setTimeout(10 * 1000);

// ここに、テストを書く!!

では、進めていきましょう。

同期関数の呼び出し

まずは、基本となる同期関数の呼び出しで書いておきます。

test('foreach', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  values.forEach((v) => {
    sum += v;
  });

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(10); // 10ミリ秒以下
});

配列をforEachでループさせ、中身を加算する処理ですね。

特に詳しく書くようなことはないと思います。

asyncを使う

次は、forEachasyncな関数を渡してみます。

test('foreach async', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  values.forEach(async (v) => {
    await setTimeout(1000);
    sum += v;
  });

  expect(sum).toBe(0); // <- 0!!

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(10); // 10ミリ秒以下
});

動きがわかりやすいように、forEachの中でseteTimeoutを呼び出して待つようにしました。

  values.forEach(async (v) => {
    await setTimeout(1000);
    sum += v;
  });

Timers | Node.js v18.12.1 Documentation

結果は見たらわかりますが、すぐさま終了してしまうので加算結果が0になっています。

  expect(sum).toBe(0); // <- 0!!

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(10); // 10ミリ秒以下

実行時間も短いままですね。

awaitをつけてみる

次は、asyncに加えてawaitをつけてみます。

test('foreach async await', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  await values.forEach(async (v) => {
    await setTimeout(1000);
    sum += v;
  });

  expect(sum).toBe(0); // <- 0!!

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(10); // 10ミリ秒以下
});

結果はasyncをつけた時とまったく変わりません。

ちなみに、asyncを使った関数が呼び出されていないかというとそんなことはなく、forEachの外で待つようにすると処理が動いていることは
確認できます。

test('foreach async, with wait', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  await values.forEach(async (v) => {
    await setTimeout(1000);
    sum += v;
  });

  await setTimeout(5000);

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeGreaterThanOrEqual(5000); // 5秒以上
});

そもそも、Array.prototype.forEachの戻り値はvoidですからね。awaitを使っても結果を待つPromiseがないので効果がないのは
当たり前なのですが。

気持ち的には、TypeScriptを使っている場合はコンパイルエラーにしてくれないかな、とちょっと思ったりもします。

forを使う

ではどうしたらいいかというと、逐次処理で良いのならfor文を使うことになると思います。

以下は、for of文に書き直した例です。

test('for of async await', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  for (const v of values) {
    await setTimeout(1000);
    sum += v;
  }

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeGreaterThanOrEqual(5000); // 5秒以上
});

for...of - JavaScript | MDN

逐次処理になったので、5秒以上時間がかかるようになりました。結果も計算できています。

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeGreaterThanOrEqual(5000); // 5秒以上

オマケ:Promise#allを使う

主題の`Array.prototype.forEachからは逸脱しますが、Promise#allを使う方法もあると思います。

test('Promise#all', async () => {
  const startTime = new Date().getTime();

  const values = [1, 2, 3, 4, 5];

  let sum = 0;

  await Promise.all(
    values.map(async (v) => {
      await setTimeout(1000);
      sum += v;
    })
  );

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(1500); // 1.5秒以下
});

この場合、各要素を並行に処理することになるので実行時間がfor文より短くなる可能性があります。

  expect(sum).toBe(15);

  const elapsedTime = new Date().getTime() - startTime;
  expect(elapsedTime).toBeLessThanOrEqual(1500); // 1.5秒以下

こんなところでしょうか。

まとめ

JavaScriptArray.prototype.forEachasyncな関数を渡しても、awaitで終了を待てないということを確認してみました。

よくよく考えるとそれはそうですね、という気がするのですが、理解が浅いまま適当に扱っていて見事にハマりました。

ちゃんと勉強しないとな、という気になりました…。