YASD-TECH
YASD TECH
# JavaScript

npm・yarn・pnpm の node_modules の違い(巻き上げ・シンボリックリンク・PnP)

投稿日:2026/11/6

更新日:2026/11/6

ttitleImage

npm・yarn・pnpm の node_modules の違い(巻き上げ・シンボリックリンク・PnP)

pnpm ワークスペースのモノレポで node_modules の中を実際に覗き、npm・yarn と何が違うのかを整理したメモ(pnpm 10.20.0・macOS で 2026-11-06 に確認)。
3つとも Node.js の「親へ向かって node_modules/<名前> を探す」規則の上に乗っていて、違うのは どこに何を置くか だけ。

結論

  • npm / yarn v1 は依存の依存まで node_modules 直下に平らに並べる(巻き上げ)。package.json に書いていないパッケージまで import できてしまう「幻の依存」が起きる
  • pnpm は実体を node_modules/.pnpm/ に1つずつ置き、アプリの node_modules には宣言した依存だけをシンボリックリンクで並べる。ファイルの中身はマシン全体で1つの中央ストアから配る
  • yarn v2 以降の Plug'n'Play(PnP) は node_modules を作らず、対応表 .pnp.cjs で Node.js の探索そのものを置き換える。代わりにエディタで TypeScript などを動かすための設定が要る
  • pnpm でも、依存パッケージ同士は .pnpm/node_modules への巻き上げで宣言していない依存に届く。幻の依存を防げるのは自分のコードからの import だけ

前提: Node.js がパッケージを探す規則

apps/web/components/Foo.tsx に import React from 'react' と書くと、Node.js は from の右側のパッケージ名を、そのファイルの場所から親へ向かって node_modules/<名前> の順に探す。

flowchart LR
    S1["apps/web/components/node_modules/react<br/>(なし)"] --> S2["apps/web/node_modules/react<br/>(ここで見つかる)"]
    S2 -.->|見つからなければ| S3["apps/node_modules/react"]
    S3 -.-> S4["node_modules/react"]
  • 探索の対象は 'react'・'@apollo/client' のような「裸の名前」だけ。'./Foo' のような相対パスは位置が決まっているので探さない
  • import { Box } from '...' の { Box } は、ファイルが見つかった後の話で、探索には関係しない
  • tsconfig.json の baseUrl で書く 'components/Button' のような import は、baseUrl で先に解決されるので node_modules とは無関係

3つのツールの違いは、この規則を前提に「どこに何を置くか」をどう設計したかの違い。


npm / yarn v1: フラット配置(巻き上げ)

依存の依存まで、置けるものはすべて node_modules 直下に平らに並べる。バージョンが衝突したものだけ、使う側の中に入れ子にする。

text
node_modules/
├── react-dom/              ← package.json に書いた依存
├── scheduler/              ← react-dom の依存。書いていないのに直下に来る(幻の依存)
├── semver/   (7.7.3)
└── a/                      ← semver@6 を使う架空のパッケージ
    └── node_modules/
        └── semver/  (6.3.1) ← 直下の 7.7.3 と衝突したので a の中へ複製

起きること:

  • 幻の依存: import 'scheduler' が宣言なしで動く。react-dom が scheduler を使わなくなった日に突然壊れる
  • 複製: 衝突した版は使う側ごとにコピーされる。同じ semver@6.3.1 が何か所にも展開されうる
  • 不定性: どの版が直下に来るかはインストール順で変わりうる。ロックファイルで配置を固定する
  • ディスク: npm のキャッシュ(~/.npm/_cacache)が持つのは tarball とメタデータだけ。展開した実体はプロジェクトごとに全部コピーされる

lodash を自分の package.json に書いていなくても、どこかの依存が使っていれば import できてしまう構造になっている。


pnpm: 2段のリンクと中央ストア

巻き上げをやめ、2段のシンボリックリンクで Node.js の探索規則を満たす。

場所 置かれるもの
段1: apps/web/node_modules/ そのワークスペースが宣言した依存の名前だけ。中身は .pnpm/ へのシンボリックリンク
段2: node_modules/.pnpm/<名前>@<版>_<peerの組>/node_modules/ パッケージの実体と、その隣に「そのパッケージが宣言した依存」へのシンボリックリンク
中央ストア(macOS の既定 ~/Library/pnpm/store/v10/) ファイルの中身。ファイル名は中身の SHA-512。マシン全体で1つ
flowchart LR
    subgraph L1["段1: apps/web/node_modules/"]
        AReact["react"]
        AApollo["@apollo/client"]
    end
    subgraph L2["段2: node_modules/.pnpm/(仮想ストア)"]
        subgraph PR["react@19.2.8/node_modules/"]
            BReact["react/(実体)"]
        end
        subgraph PA["@apollo+client@3.14.0_…/node_modules/"]
            BApollo["@apollo/client/(実体)"]
            BApolloReact["react"]
            BApolloDom["react-dom"]
        end
        subgraph PD["react-dom@19.2.8_react@19.2.8/node_modules/"]
            BDom["react-dom/(実体)"]
        end
    end
    subgraph L3["中央ストア(マシン全体で1つ)"]
        Store["files/xx/xxxx…<br/>(ファイル名は中身の SHA-512)"]
    end
    AReact -.->|シンボリックリンク| BReact
    AApollo -.-> BApollo
    BApolloReact -.-> BReact
    BApolloDom -.-> BDom
    BReact ==>|"中身を共有<br/>macOS: clone / Linux: ハードリンク"| Store
    BApollo ==> Store
    BDom ==> Store
  • 段2のディレクトリ名は「名前@版_peerの組」。react-dom@19.2.8_react@19.2.8 の _react@19.2.8 は、react-dom が peer(使う側が用意する依存)として求める react をどの版で解決したか。組が違えば、同じ react-dom@19.2.8 でも別ディレクトリになる
  • 同じ名前の別バージョンは .pnpm/ に並存する(semver@6.3.1 と semver@7.7.3 など)。使う側がそれぞれ自分の版へリンクする
  • 他のワークスペースの node_modules/react も、全部が同じ .pnpm/react@19.2.8 を指す
  • 中央ストアはパッケージ名でも版でもなく「中身」で管理する。名前が違っても同じ内容のファイルは1つしか保存しない

幻の依存が構造で防がれる: apps/web/package.json に lodash が無ければ apps/web/node_modules/lodash も無い。.pnpm/lodash@4.17.21 に実体があっても、リンクが無ければアプリのコードからは届かない。

中央ストアから仮想ストアへの配り方

packageImportMethod(既定 auto)は、OS ごとに次の順で試す。

OS 試す順
macOS clone → ハードリンク → コピー
Linux ハードリンク → clone → コピー
Windows clone → ハードリンク → コピー
  • clone(APFS などのコピーオンライト): 別のファイルとして作るが、中身を書き換えるまでディスク上のブロックを共有する
  • ハードリンク: 同じ実体を別の名前で参照する

macOS ではリンク数が 1 になっていて、ハードリンクではないことが分かる(APFS では auto が clone を最初に試す)。

bash
stat -f 'links=%l inode=%i' node_modules/.pnpm/react@19.2.8/node_modules/react/index.js
# links=1 inode=…(ハードリンクならストアの分と合わせて 2 以上になる)

clone なら node_modules の中を誤って編集してもストアは汚れない。ハードリンクだとストアの中身を直接書き換えることになり、同じファイルを使う他のプロジェクトまで変わる。


yarn v2 以降: Plug'n'Play(node_modules を作らない)

text
プロジェクト/
├── .pnp.cjs          ← 「どの名前@版が、どの zip のどこにあるか」の対応表。Node.js の require を差し替える
├── .yarn/cache/      ← ゼロインストールのときだけ使う(既定はマシン全体の共有キャッシュ)
│   ├── react-npm-19.2.8-xxxx.zip
│   └── semver-npm-7.7.3-xxxx.zip
└── (node_modules/ は作られない)
  • 探索規則そのものを置き換える: ファイルシステムを親へたどらず、対応表を引く。対応表に無い依存(宣言していないもの)は読み込めない
  • 展開しない: zip のまま読むので、展開した実体が並ばない
  • キャッシュの置き場: 現行の Yarn の既定(enableGlobalCache: true)では、zip はマシン全体の共有キャッシュに置かれる。enableGlobalCache: false にしてプロジェクトの .yarn/cache に置き、git にコミットすると、clone 直後にインストールなしで動く「ゼロインストール」になる
  • 代償は互換性: エディタの拡張機能は PnP の loader を読み込まずに TypeScript・ESLint・Prettier を動かすことが多い。yarn dlx @yarnpkg/sdks vscode で SDK を入れないと、補完や型チェックが動かない
  • この手間を避けて、.yarnrc.yml の nodeLinker を node-modules(npm と同じ平らな配置)にする選択肢もある。pnpm(pnpm と同じシンボリックリンク+中央ストアの配置)も選べる

3つの比較

npm / yarn v1 pnpm yarn PnP
node_modules 直下に平らに展開する 宣言した依存だけをシンボリックリンクで並べる 作らない
実体の置き場 プロジェクトごとに全部展開 .pnpm/ に1つずつ。中身はマシン全体の中央ストア zip のまま。既定はマシン全体の共有キャッシュ
宣言していない依存の import 届いてしまう(幻の依存) 自分のコードからは届かない 対応表に無いので読み込めない
同じ版を2プロジェクトで使うと 2回展開する 中身は1つ。clone / ハードリンクで共有 共有キャッシュの zip が1つ(ゼロインストールならプロジェクトごとに1つ)
1プロジェクト内の複数のパッケージが同じ依存を使うと 衝突すれば入れ子に複製 .pnpm/ に1つ。全員がそこへリンク 対応表が同じ zip を指す
node_modules を前提にしたツール そのまま動く そのまま動く SDK などの設定が要る

ハマりどころ

pnpm でも、依存パッケージ同士では宣言していない依存に届く

pnpm は既定(hoist: true、hoistPattern: ['*'])で、すべての依存を node_modules/.pnpm/node_modules/ にも巻き上げている。.pnpm/ の中にあるパッケージは、親へたどる途中でここを通るので、自分が宣言していない依存にも届く。

どこから lodash を探すか たどる場所 結果
アプリのコード(apps/web/) apps/web/node_modules → apps/node_modules → node_modules 見つからない
.pnpm/ の中の react-dom .pnpm/react-dom@…/node_modules → .pnpm/node_modules 見つかる(.pnpm/lodash@4.17.21 へのリンク)
bash
# アプリからは見つからない → Error: Cannot find module 'lodash'
(cd apps/web && node -p "require.resolve('lodash')")

# .pnpm の中からは見つかる → …/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js
(cd node_modules/.pnpm/react-dom@19.2.8_react@19.2.8/node_modules/react-dom && node -p "require.resolve('lodash')")

react-dom は lodash を宣言していないのに届く。依存パッケージ側の宣言漏れを救うための既定値で、幻の依存を防げるのは自分のコードからの import に限られる。
ルートの node_modules/ へ巻き上げる publicHoistPattern は既定が [](何も巻き上げない)なので、アプリのコードからは届かない。

.pnpm/ のディレクトリ名は途中で切れる

peer の組が多いパッケージは名前が長くなり、virtualStoreDirMaxLength(macOS・Linux の既定は 120 文字)を超えると、末尾がハッシュに置き換わる。

text
@apollo+client@3.14.0_@types+react@19.2.7_graphql-ws@6.0.6_crossws@0.3.5_graphql@16.11._<ハッシュ>

graphql@16.11. の途中で切れているので、graphql@16.11.0 で grep しても見つからない。ls node_modules/.pnpm | grep '^@apollo+client@' のように先頭で探す。

peer の組が違うと、同じパッケージが別ディレクトリになる

ワークスペースごとに react の版がずれると、react-dom@…_react@<版> が版の数だけでき、その上に乗る @apollo/client なども組ごとに分かれる。
pnpm-workspace.yaml の catalog: で react の版を1つに固定すると、組が1本にまとまる。

yaml
# pnpm-workspace.yaml
catalog:
  react: 19.2.8
  react-dom: 19.2.8
jsonc
// apps/web/package.json
{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

du で worktree ごとのサイズを足しても、実際の使用量にならない

clone はディスク上のブロックを共有しているが、du はそれを見抜けず、ファイルごとに丸ごと数える。git worktree を何本も切っていると、.pnpm/ ごとの du の合計がディスク容量を大きく超えて見えることがあるが、それは実際の使用量ではない。
どれだけ共有されているかは du では測れない。


参考

Index

  • npm・yarn・pnpm の node_modules の違い(巻き上げ・シンボリックリンク・PnP)
  • 結論
  • 前提: Node.js がパッケージを探す規則
  • npm / yarn v1: フラット配置(巻き上げ)
  • pnpm: 2段のリンクと中央ストア
  • 中央ストアから仮想ストアへの配り方
  • yarn v2 以降: Plug'n'Play(node_modules を作らない)
  • 3つの比較
  • ハマりどころ
  • pnpm でも、依存パッケージ同士では宣言していない依存に届く
  • .pnpm/ のディレクトリ名は途中で切れる
  • peer の組が違うと、同じパッケージが別ディレクトリになる
  • du で worktree ごとのサイズを足しても、実際の使用量にならない
  • 参考