webpackのバンドルを調べて見えてきたnpmの問題点とpnpmに乗り換えたい話

一年位前に書いた記事だけど、限定公開でほったらかしにしていたので公開する。

## バンドルがでっかいんだけど...

まずはこちらを見てほしい。nuxt.jsを使ったプロジェクトをwebpack-bundle-analyzerで可視化したグラフの一部である。

![スクリーンショット 2022-07-22 3.37.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99585/c832d80f-9c7a-1ec4-c6ea-88833544abd8.png)

bn.jsというファイルが複数バンドルされており、容量を喰っていそうなことがわかる。ググってみると[bn.js](https://www.npmjs.com/package/bn.js?activeTab=readme)はjavascriptでBigNumを扱うためのライブラリで、暗号化などの文脈でよく使われている物のようだ。

バンドルをなるべく小さくするため、これらのファイルをできることなら一つにまとめたいところ。

:::note warn
本記事ではたまたま目についたbn.jsを例示しているが、もちろんbn.jsが悪いわけでも、それに依存しているパッケージが悪いわけでもないことを念の為補足しておく。全てはそれらを組み合わせたときの問題だ。
:::

## なんで複数バンドルされるの？

なぜ複数バンドルされるかというと、 **node_modulesの下にbn.jsが複数存在するから** に他ならない。

```console
$ find node_modules -type d -name bn.js
node_modules/elliptic/node_modules/bn.js
node_modules/miller-rabin/node_modules/bn.js
node_modules/public-encrypt/node_modules/bn.js
node_modules/diffie-hellman/node_modules/bn.js
node_modules/create-ecdh/node_modules/bn.js
node_modules/asn1.js/node_modules/bn.js
node_modules/bn.js
```

package-lock.jsonなどを調べて、これらの殆どはバージョン4.12.0で、一つだけ5.2.0が紛れ込んでいることがわかった。

```console
$ cat package-lock.json | grep "https://registry.npmjs.org/bn.js/-/bn.js"
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz",  <======
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
```

## なぜこうなるのか

依存関係の解決の結果、２つのバージョンのモジュールが存在するのはわかるが、なぜ３つ以上の実体が存在するのだろうか？ ググったところ、以下に詳細を見つけた。

https://formidable.com/blog/2018/finding-webpack-duplicates-with-inspectpack-plugin/

要約すると以下になる:

1. npmは元々、`~/node_modules/依存モジュール/node_modules/子依存モジュール/node_modules/孫依存モジュール/...` という、 `npm install`した各モジュールが独立した構造になっていた(`~`はプロジェクトルート)。でもこの仕様だと重複モジュールの数だけ容量食うし依存関係によっては無限ループしてしまう
1. ということでnode_modulesフォルダ直下に全モジュールを並べるフラット構造に変更された(Flattening)。これでギガ食わないし、インストールもはやくなったよ！ やったね！
1. でも同じモジュールで複数のバージョンが必要になったら？ →　その場合は、片方がFlatteningされて `~/node_modules/` 直下に置かれて、名前が競合するもう片方は今まで通り `~/node_modules/依存モジュール/node_modules/` の下に置かれる。こっちは依存される数だけ独立して存在することになる

どちらが `~/node_modules/` 直下に置かれるかは `npm install` した順番に依存するらしい。いちおう、package-lock.jsonで固定はされるが...

## 試してみる

現在、直下側にあるのはbn.js@5.2.1である。そこでbn.js@^4.12.0を明示的にインストールして、どう変わるかみてみる。そうすると直下に置かれるモジュールに変わり、bn.jsの実体数が変わるはずだ。

```console
$ npm install bn.js@^4.12.0
$ npm dedupe
```

`npm dedupe` はあまり馴染みがないが、重複依存モジュールを整理（削除）するコマンドらしい。なお、この問題に気がついた当初、このコマンド単品で実行してみたが効果はなかった。flatteningされたモジュールが変わったなど限定的な状況でしか機能しないようだ。

```console
$ find node_modules -type d -name bn.js
node_modules/browserify-rsa/node_modules/bn.js
node_modules/browserify-sign/node_modules/bn.js
node_modules/bn.js
```

```console
$ cat package-lock.json | grep "https://registry.npmjs.org/bn.js/-/bn.js"
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",  <=====
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
```

![スクリーンショット 2022-07-22 4.42.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99585/b7906c92-945a-e69a-1588-64aca7374b49.png)

予想通り、bn.jsの数が減った！ でも、2つにはならない...

これは複数のモジュールがbn.js@4.12.0に依存しているのと同様に、bn.js@5.2.1に依存しているモジュールも１つではないからだ。

また、ここまでの調査から、今回はたまたまbn.jsが目についたが、これはnpmとwebpackに関わるより一般的な問題であり、他の依存モジュールでも発生し得る（、或いは、気づいていないだけで既に発生している[^1]）問題であることが分かった。

[^1]: [duplicate-package-checker-webpack-plugin](https://www.npmjs.com/package/duplicate-package-checker-webpack-plugin)や[Inspectpack DuplicatesPlugin](https://github.com/FormidableLabs/inspectpack)のように重複を検知できるwebpack pluginがあるようだが、今回は試していない。後述するように、検知してからの手動解決よりも、より抜本的な解決が望ましいと思われるからだ。

## じゃあどうする？

ここまででなぜ同じファイルが複数バンドルされてしまうのか、原因は明確になった。現在のnpmの仕様として、複数のバージョンのモジュールが必要な場合、その片方についてはまとめることができない、ということだ。

では、この状況を解決するにはどうすればいいだろうか？ いくつか方法が考えられる。

1. package.jsonのバージョン指定を調整して単一のバージョンに依存するよう修正する
2. webpackのresolve.aliasを設定する
3. npmから[pnpm](https://pnpm.io/ja/)に移行する

1.は現実的には困難だろう。依存モジュールのバージョンを全て把握するのは多大な労力を要するし、モジュールのバージョンアップのたびに苦労することになる。ただし、他の施策の前提として最新のバージョンを使用するように心がけることは効果がある。2.も同じくバージョン管理作業の負担増に繋がる。ただし、即時的に「バンドルサイズをあとｘｘKB小さくしたい」というケースにおいては一番現実的だ。

3.が今回のメインの解決策となる。

## pnpm

https://pnpm.io/

pnpmはnpm、yarnの代替となるパッケージマネージャー。今回の話で重要なのは、pnpmの以下で説明されているディレクトリ構造の違いである。

https://pnpm.io/motivation#creating-a-non-flat-node_modules-directory

pnpmは、.pnpm store(npmでいうcache)にモジュールがバージョン別に格納され、node_modulesの下はそれらに対するシンボリックリンク、ハードリンクが張られる。そのため、モジュールの実体は常に一つであり、上述のような問題は発生しない(はず)。

## 導入したい

さっそく導入したいところだが、yarnがyarn.lockファイルでバージョンを管理するように、pnpmもpnpm-lock.yamlというpackage-lock.jsonとは別のファイルでバージョン詳細が管理される。モジュールのバージョンが変わってしまうため、移行時には動作確認をしっかりする必要がある。pnpmはpackage-lock.json等からの移行を支援する `pnpm import` コマンドが用意されているが、やはりある程度、トライアンドエラーで調整する必要があるようだった。

## npmのもう一つの問題点

`pnpm import` コマンドを使ってもなお一筋縄でいかない理由の一つは、pnpmの「厳格」なパッケージ管理だ。

> **厳格**
> デフォルトで、pnpm はフラットでない node_modules を作成するので、コードは任意のパッケージにアクセスできません。

これが問題となるのは、peerDependenciesの扱いである。npmを使っている場合、peerDependenciesはよくわからないうちに解消されていることが多い。これは、npmが先述のようなフラットなnode_modulesを生成するからだ。つまり、peerDependenciesのために明示的にインストールしたパッケージと、他のモジュールの依存関係で入ったパッケージが等価に扱われるため、気が付いたら解消されているわけだ。

これはパッケージを導入する際には、peerDependenciesを解消しなければならないのはめんどくさいのだが、裏を返すとnpmが副作用的にpeerDependenciesを解決してしまっているのは結構危険なのではないだろうか。先述のように、依存関係で複数のバージョンが必要となる場合、どちらがプロジェクトルートのnode_modulesに置かれるかは `npm install` の順番による。これは、package-lock.jsonを削除して再度 `npm install` すると、仮に全パッケージのバージョンが変わっていなくても、動作しなくなる可能性があるということである（というか、nodeで開発しているとそれに近い状況に遭遇したことがあるのではないだろうか）。この問題が発生しなくなるというだけでも、pnpmに乗り換える価値があるのではないだろうか。

とはいえ、先述の通り乗り換えはそれなりの労力がいる。新しく作るレポジトリはpnpmで管理するところから始めてみるといいかもしれない。