pnpmを使う理由→名前がかわいい(ぷにぷみ)から。
☝️そろそろこれを卒業したいお年頃・・・
ちょうどこのポートフォリオサイトをnpmからpnpmに移行したタイミングだったので、ついでに「依存関係(node_modules)の持ち方」をメインにいろいろと調べてみましたー!!!
npm v2以前:ネスト地獄の時代
昔のnpm(v2以前)は、依存関係をそのままツリー状にネストして持っていました。
たとえばこのサイトのpackage.jsonだと、nextと@tailwindcss/postcssが両方postcssに依存していて、そのpostcssがさらにpicocolorsに依存しています。これをそのままネストで持つと:
node_modules/
├── next/
│ └── node_modules/
│ └── postcss/
│ └── node_modules/
│ └── picocolors/ ← next用
└── @tailwindcss/
└── postcss/
└── node_modules/
└── postcss/
└── node_modules/
└── picocolors/ ← tailwind用(同じ1.1.1でも重複!)
postcssもpicocolorsも、同じバージョンなのに2箇所へ実ファイルとしてコピーされます。
これの困ったところ:
- 依存が増えるほどこの重複が積み重なって、
node_modulesが数百MB〜1GBに膨れ上がることがあった - Windowsだとパスが伸びすぎて削除すらできなくなるケースも報告されていた
npm v3:フラット化とphantom dependency
これを解決するために、npm v3ではフラット化(hoisting)が導入されました。
可能な限り全パッケージをnode_modules直下に配置して、同じバージョンなら1つだけ持つ、という方式です。
node_modules/
├── next/
├── @tailwindcss/postcss/
├── postcss/ ← 共通、重複なし
├── picocolors/ ← 共通、重複なし
├── nanoid/
└── source-map-js/
ディスク容量問題は解決!
ところが、これにも困ったところがあります:
- phantom dependency が起こる
package.jsonに書いてない未宣言のパッケージが、なぜかrequireできてしまう現象のこと- (名前はかっこいいが、よくない)
// package.jsonにはnextしか書いてないのに
const postcss = require('postcss'); // 動いちゃう
phantom dependencyの原因は、Node.jsのnode_modules探索アルゴリズムにあります。
requireされると、Node.jsは呼び出し元ファイルの位置を起点に./node_modules → ../node_modules → ../../node_modulesと、上へ上へ探しにいきます。このとき見るのは各階層のnode_modules直下だけで、ディレクトリの奥までは掘りません。
なのでrequire('postcss')が呼ばれても、Node.jsは呼び出し元から./node_modulesを見てpostcssという名前のエントリがあるか確認するだけです。
フラット化のおかげでpostcss(本来はnextの内部依存)がnode_modules直下に置かれてるから、そのまま見つかってしまうのです。
pnpm:3層構造でネスト地獄もphantom dependencyも回避
ここで登場するpnpm❗️❗️❗️
pnpmは、ネスト地獄ともフラット化とも違う、3層構造を取っています。
- トップレベル …
package.jsonに書いた直接依存だけが並ぶ層 .pnpm/… 使う全パッケージがpkg@versionという名前でフラットに並ぶ層- グローバルstore … 実ファイルが1つだけ置かれる層。ここだけプロジェクトの外(macOSなら
~/Library/pnpm/store/)にある
3層目がプロジェクトの外にあるのがミソで、ここはこのマシンの全プロジェクトで共有されます。
別のリポジトリで同じreact@19.2.8を入れても、実ファイルはstoreにある1つを見に行くだけです。npm v2でnode_modulesが膨れ上がってた話が、pnpmではプロジェクトをまたぐレベルで解決されます!!
ということで、pnpmのnode_modules/を見て見ましょう。
矢印(→)が「どこを指してるか」に注目して見てみてください!:
node_modules/
├── next → .pnpm/next@16.3.4/node_modules/next
├── @tailwindcss/postcss → .pnpm/@tailwindcss+postcss@4.3.3/node_modules/@tailwindcss/postcss
│
└── .pnpm/
├── next@16.3.4/
│ └── node_modules/
│ ├── next → グローバルstore
│ └── postcss → ../../postcss@8.5.23/node_modules/postcss
│
├── @tailwindcss+postcss@4.3.3/
│ └── node_modules/
│ ├── @tailwindcss/postcss → グローバルstore
│ └── postcss → ../../postcss@8.5.28/node_modules/postcss
│
├── postcss@8.5.23/
│ └── node_modules/
│ ├── postcss → グローバルstore
│ └── picocolors → ../../picocolors@1.1.1/node_modules/picocolors
│
├── postcss@8.5.28/
│ └── node_modules/
│ ├── postcss → グローバルstore
│ └── picocolors → ../../picocolors@1.1.1/node_modules/picocolors
│
└── picocolors@1.1.1/
└── node_modules/
└── picocolors → グローバルstore
.pnpm/直下の名前は見やすさのために簡略化してます。実物はpeer dependencyの解決結果までエンコードされてるので、next@16.3.4_@babel+core@7.29.7_@types+node@20.19.43_react-dom@19.2.8_react@19.2.8__react@19.2.8みたいなことになってます。
なぜphantom dependencyが起きない?
- トップレベルには直接依存(
package.jsonに書いたもの)へのリンクしかない postcssやpicocolorsのような間接依存はトップレベルにも自分のコードの探索パスにも存在しないので、Node.jsの解決アルゴリズムをそのまま使っても「たまたま見つかる」ことがなく、requireが不可能
なぜネスト地獄にならない?
-
.pnpm/も見た目は階層的(ネスト) -
npm v2との違いは「ネストした先に何が置かれてるか」
- npm v2はネストした先に実体のコピーを置いていたから重複が起きた
- pnpmはネストした先にリンクを置くだけで、実体はバージョンごとに1つ(
.pnpm/{pkg}@{version}/)しか存在しない
-
バージョン違いが複数あっても、
.pnpm/はフラットな1階層にpkg@versionという名前で共存させている
シンボリックリンクとハードリンク
さっきの図では矢印を全部→で書きましたが、実はこれには2種類のリンクが混在しています。
- シンボリックリンク(symlink)
- 平たく言えばショートカット。
- ただし普通のショートカット(.lnk)がアプリケーション側の解釈でジャンプするのに対して、symlinkはOSがサポートしてる機能で、ファイルを開こうとした瞬間にOSが透過的にリンク先へ転送してくれる
- ハードリンク (hardlink)
- 1つの実体(inode)に対して別の名前が複数ついている状態。
- どちらの名前を消しても、もう一方が残っていれば実体は消えない。
- 片方を編集すると、もう片方も(同じ実体だから)変わる
pnpmはこの2つを、以下のように使い分けています:
- 「自分自身の実体を指す1本」(
.pnpm/pkg@ver/node_modules/pkg→ グローバルstore) = ハードリンク - 「他パッケージへの依存を表す矢印」(トップレベルの直接依存、
.pnpm/内のパッケージ間参照) = シンボリックリンク。
さっきの図からnext → postcssの1本だけ抜き出すと、こんな感じです:
node_modules/
└── next [symlink] → .pnpm/next@16.3.4/node_modules/next
└ 依存を表す矢印
.pnpm/
├── next@16.3.4/
│ └── node_modules/
│ ├── next [hardlink] → グローバルstore
│ │ └ next自身の実体
│ └── postcss [symlink] → ../../postcss@8.5.23/node_modules/postcss
│ └ 依存を表す矢印
│
└── postcss@8.5.23/
└── node_modules/
└── postcss [hardlink] → グローバルstore
└ postcss自身の実体
.pnpm/{pkg}@{version}/node_modules/の中で、自分と同じ名前のものだけがハードリンク(=実体)で、残りは全部symlink(=矢印)になってるのがポイントです。
まとめ
| npm v2 | npm v3以降 | pnpm | |
|---|---|---|---|
| 構造 | ネスト | フラット | 3層(トップ/.pnpm/グローバルstore) |
| 重複 | 起きる(実体ごとコピー) | 起きない | 起きない(symlink参照 + ハードリンク) |
| phantom dependency | - | 起きる | 起きない |
pnpmは「階層構造」を取ってるけど、npm v2のネスト地獄とは別物でした。
ネストした先に置くのが、実体のコピー or 既存実体への参照 って違いがいちばん大きいみたいでした!
少しだけですが、名前のかわいさ以外にも、pnpmを選ぶ理由が説明できるようになった気がします〜!(めでたし)
新しく調べた用語のおさらい
| 用語 | ざっくり |
|---|---|
| node_modules探索アルゴリズム | requireされると./node_modulesから上へ上へ探す。各階層の直下しか見ない |
| シンボリックリンク(symlink) | OSがサポートしてるショートカット。開いた瞬間に透過的にリンク先へ飛ばされる |
| ハードリンク | 1つの実体(inode)に複数の名前がついた状態。片方を消しても実体は残る |
| APFS clone(macOS固有) | コピー時は実体を共有し、編集された分だけ分離する |
| phantom dependency | 未宣言のパッケージが、なぜかrequireできてしまう現象 |
参考
pnpm とは何か — npm の Phantom Dependencies 問題と安全な移行方法 - Qiitaこの記事の要点 pnpm は npm 互換の高速パッケージマネージャ 最大の違いは「依存管理の厳格さ」 Phantom Dependencies を防止しやすい CI / モノレポと相性が良い npm ユーザーでも移行コストは低い なぜ今 pnpm か 昨今のソフ...Qiita
node_modulesの中身を読む:npmの依存ツリー構造を図解で完全理解するZenn
Phantom Dependency(幽霊依存)とは何か? 実例で理解するnode_modulesの構造的欠陥Zenn