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で終了を待てないということを確認してみました。

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

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

npmでプロジェクトの依存関係の確認や、依存パッケージのバージョン確認などを行ってみる

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

以前、npm installnpm i)で使うバージョンについて調べてみました。

npm installなどで使うバージョンがよくわからなかったので、調べてみました - CLOVER🍀

なのですが。npmプロジェクトが依存しているパッケージを表示したり、より新しいバージョンがリリースされているかどうかなどはどうやって
確認したらいいのかなと思って調べてみると。

npmコマンドでできそうだったので、まとめておくことにします。

結論を言うと、npm lsnpm outdatednpm updateなどを使うと良さそうです。

環境

今回の環境は、こちら。

$ node --version
v16.18.1


$ npm --version
8.19.2

準備

準備として、npmプロジェクトを作成して現時点では少し古いパッケージを依存関係に追加してみます。

$ npm init -y


$ npm i express@4.17.0
$ npm i winston@3.8.0
$ npm i -D @types/node@16.18.0
$ npm i -D typescript@4.8.2

package.jsonの依存関係は、このようになりました。

  "dependencies": {
    "express": "^4.17.0",
    "winston": "^3.8.0"
  },
  "devDependencies": {
    "@types/node": "^16.18.0",
    "typescript": "^4.8.2"
  }

npmプロジェクトの依存関係を表示する

npmプロジェクトが依存しているパッケージを表示するには、npm lsnpm list)を使うと良さそうです。

This command will print to stdout all the versions of packages that are installed, as well as their dependencies when --all is specified, in a tree structure.

npm-ls | npm Docs

インストールされている依存関係を表示してくれるコマンドのようです。

実行してみます。

$ npm ls
npm-dependencies@1.0.0 /path/to/my-npm-project
├── @types/node@16.18.0
├── express@4.17.0
├── typescript@4.8.2
└── winston@3.8.0

現在のプロジェクトの依存関係が表示されました。

パッケージを直接指定することもできます。

$ npm ls express
npm-dependencies@1.0.0 /path/to/my-npm-project
└── express@4.17.0

デフォルトだと1階層しか表示しないようなので、依存しているパッケージが推移的に他のパッケージに依存している場合を表示するような
ケースでは、--depthオプションを使います。

2階層まで表示してみます。

$ npm ls --depth 2
npm-dependencies@1.0.0 /path/to/my-npm-project
├── @types/node@16.18.0
├─┬ express@4.17.0
│ ├─┬ accepts@1.3.8
│ │ ├── mime-types@2.1.35
│ │ └── negotiator@0.6.3
│ ├── array-flatten@1.1.1
│ ├─┬ body-parser@1.19.0
│ │ ├── bytes@3.1.0
│ │ ├── content-type@1.0.4 deduped
│ │ ├── debug@2.6.9 deduped
│ │ ├── depd@1.1.2 deduped
│ │ ├── http-errors@1.7.2
│ │ ├── iconv-lite@0.4.24
│ │ ├── on-finished@2.3.0 deduped
│ │ ├── qs@6.7.0 deduped
│ │ ├── raw-body@2.4.0
│ │ └── type-is@1.6.18 deduped
│ ├─┬ content-disposition@0.5.3
│ │ └── safe-buffer@5.1.2 deduped

〜省略〜

デフォルトでは、--depthに1が指定された状態で実行しているようです。

全階層を表示する場合は、--allオプションを使います。

$ npm ls --all
npm-dependencies@1.0.0 /path/to/my-npm-project
├── @types/node@16.18.0
├─┬ express@4.17.0
│ ├─┬ accepts@1.3.8
│ │ ├─┬ mime-types@2.1.35
│ │ │ └── mime-db@1.52.0
│ │ └── negotiator@0.6.3
│ ├── array-flatten@1.1.1
│ ├─┬ body-parser@1.19.0
│ │ ├── bytes@3.1.0
│ │ ├── content-type@1.0.4 deduped
│ │ ├── debug@2.6.9 deduped
│ │ ├── depd@1.1.2 deduped
│ │ ├─┬ http-errors@1.7.2
│ │ │ ├── depd@1.1.2 deduped
│ │ │ ├── inherits@2.0.3 deduped
│ │ │ ├── setprototypeof@1.1.1 deduped
│ │ │ ├── statuses@1.5.0 deduped
│ │ │ └── toidentifier@1.0.0
│ │ ├─┬ iconv-lite@0.4.24
│ │ │ └── safer-buffer@2.1.2
│ │ ├── on-finished@2.3.0 deduped
│ │ ├── qs@6.7.0 deduped

〜省略〜

重複しているものは、dedupedが付いているみたいですね。

新しいバージョンのパッケージがリリースされているかどうか確認する

現在のプロジェクトが依存しているnpmパッケージの中に、より新しいバージョンのものがリリースされているかどうかを確認するには
npm outdatedを使用します。

npm-outdated | npm Docs

実行してみます。

$ npm outdated
Package      Current   Wanted   Latest  Location                  Depended by
@types/node  16.18.0  16.18.3  18.11.9  node_modules/@types/node  npm-dependencies
express       4.17.0   4.18.2   4.18.2  node_modules/express      npm-dependencies
typescript     4.8.2    4.9.3    4.9.3  node_modules/typescript   npm-dependencies
winston        3.8.0    3.8.2    3.8.2  node_modules/winston      npm-dependencies

「Current」は現在のバージョンです。「Wanted」は、package.jsonで指定されたsemverの範囲を満たすもので最も新しいバージョンを
表示します。「Latest」はパッケージの最新のバージョンですね。

「Current」と「Latest」が一致している場合は、結果に表示されないようです。

今回、意図的に少し古いパッケージをインストールしていますが、ほとんどのものは「Wanted」と「Latest」が一致しています。
@types/nodeのみ、メジャーバージョンが異なるのでv16の範囲が「Wanted」となり、「Latest」と差が出ています。

  "dependencies": {
    "express": "^4.17.0",
    "winston": "^3.8.0"
  },
  "devDependencies": {
    "@types/node": "^16.18.0",
    "typescript": "^4.8.2"
  }

依存しているパッケージを新しいバージョンに更新する

npm outdatedで新しいバージョンのパッケージがリリースされていることを確認できたら、アップデートを検討するわけですが。

これは、npm updateで行えるようです。

npm-update | npm Docs

npm updateでは、パッケージの最新版またはsemverを満たす範囲でパッケージをアップデートします。

This command will update all the packages listed to the latest version (specified by the tag config), respecting the semver constraints of both your package and its dependencies (if they also require the same package).

不足しているパッケージがある場合は、同時に追加してくれるようです。

試してみましょう。npm outdatedの結果は以下でした。

$ npm outdated
Package      Current   Wanted   Latest  Location                  Depended by
@types/node  16.18.0  16.18.3  18.11.9  node_modules/@types/node  npm-dependencies
express       4.17.0   4.18.2   4.18.2  node_modules/express      npm-dependencies
typescript     4.8.2    4.9.3    4.9.3  node_modules/typescript   npm-dependencies
winston        3.8.0    3.8.2    3.8.2  node_modules/winston      npm-dependencies

最初にどれくらいの変更量があるのかを確認してみます。これには--dry-runオプションを使います。

$ npm update --dry-run

added 7 packages, removed 3 packages, and changed 21 packages in 757ms

8 packages are looking for funding
  run `npm fund` for details

あんまりよくわかりませんが…。

では、更新してみます。

$ npm update

added 7 packages, removed 1 package, changed 23 packages, and audited 87 packages in 5s

8 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

npm lsの結果。

$ npm ls
npm-dependencies@1.0.0 /path/to/my-npm-project
├── @types/node@16.18.3
├── express@4.18.2
├── typescript@4.9.3
└── winston@3.8.2

依存パッケージがアップデートされたようです。

npm outdatedも確認してみましょう。表示されるのが@types/nodeのみになりました。

$ npm outdated
Package      Current   Wanted   Latest  Location                  Depended by
@types/node  16.18.3  16.18.3  18.11.9  node_modules/@types/node  npm-dependencies

これは、以下の記述なのでv16の範囲でしか更新されないからですね。

    "@types/node": "^16.18.0",

メジャーバージョンが変わるような場合はリリースノート等それなりに確認することもあると思うので、semverの範囲を守るのは妥当な
感じがしますね。

semverの範囲よりもさらに更新したい場合は、npm installで個々のパッケージを指定してインストールすることになります。

それでも最新版に一括でアップデートしたい、という場合はこちらを使うのではないかと思います。

npm-check-updates - npm

ところで、npm updateを実行してもpackage.jsonが変わるわけではありません。

  "dependencies": {
    "express": "^4.17.0",
    "winston": "^3.8.0"
  },
  "devDependencies": {
    "@types/node": "^16.18.0",
    "typescript": "^4.8.2"
  }

package.jsonも含めて更新する場合は、--saveオプションを付与します。

$ npm update --save

こうすると、package.jsonの記述も更新されるようになります。

  "dependencies": {
    "express": "^4.18.2",
    "winston": "^3.8.2"
  },
  "devDependencies": {
    "@types/node": "^16.18.3",
    "typescript": "^4.9.3"
  }

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

まとめ

npmプロジェクトの依存関係を確認したり、新しいバージョンの確認、更新を行うnpmコマンドを確認してみました。

今まであんまり知りませんでしたが、けっこういろいろできるんですね。覚えておきましょう。