これは、なにをしたくて書いたもの?
Array.prototype.forEach
をなにも考えずに使っていて、ちょっとハマったので。
Array.prototype.forEach
はawait
と組み合わせて使えないようです。
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);
ここで、forEach
にasync
やawait
を組み合わせてどのような動作になるかを確認してみます。
この時、わかりやすいようにスリープを入れたり、時間計測を行うようにしてみます。
テストコードの雛形は、こちら。
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を使う
次は、forEach
にasync
な関数を渡してみます。
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秒以上 });
逐次処理になったので、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秒以下
こんなところでしょうか。
まとめ
JavaScriptのArray.prototype.forEach
にasync
な関数を渡しても、await
で終了を待てないということを確認してみました。
よくよく考えるとそれはそうですね、という気がするのですが、理解が浅いまま適当に扱っていて見事にハマりました。
ちゃんと勉強しないとな、という気になりました…。