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でも重複!)

postcsspicocolorsも、同じバージョンなのに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に書いたもの)へのリンクしかない
  • postcsspicocolorsのような間接依存はトップレベルにも自分のコードの探索パスにも存在しないので、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/内のパッケージ間参照) = シンボリックリンク

さっきの図からnextpostcssの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 v2npm 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