投稿日:2026/11/6
更新日:2026/11/6

pnpm ワークスペースのモノレポで node_modules の中を実際に覗き、npm・yarn と何が違うのかを整理したメモ(pnpm 10.20.0・macOS で 2026-11-06 に確認)。
3つとも Node.js の「親へ向かって node_modules/<名前> を探す」規則の上に乗っていて、違うのは どこに何を置くか だけ。
node_modules 直下に平らに並べる(巻き上げ)。package.json に書いていないパッケージまで import できてしまう「幻の依存」が起きるnode_modules/.pnpm/ に1つずつ置き、アプリの node_modules には宣言した依存だけをシンボリックリンクで並べる。ファイルの中身はマシン全体で1つの中央ストアから配るnode_modules を作らず、対応表 .pnp.cjs で Node.js の探索そのものを置き換える。代わりにエディタで TypeScript などを動かすための設定が要る.pnpm/node_modules への巻き上げで宣言していない依存に届く。幻の依存を防げるのは自分のコードからの import だけ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つのツールの違いは、この規則を前提に「どこに何を置くか」をどう設計したかの違い。
依存の依存まで、置けるものはすべて node_modules 直下に平らに並べる。バージョンが衝突したものだけ、使う側の中に入れ子にする。
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 を使わなくなった日に突然壊れる~/.npm/_cacache)が持つのは tarball とメタデータだけ。展開した実体はプロジェクトごとに全部コピーされる
lodashを自分の package.json に書いていなくても、どこかの依存が使っていれば import できてしまう構造になっている。
巻き上げをやめ、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
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 を指す幻の依存が構造で防がれる:
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 → ハードリンク → コピー |
macOS ではリンク数が 1 になっていて、ハードリンクではないことが分かる(APFS では auto が clone を最初に試す)。
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の中を誤って編集してもストアは汚れない。ハードリンクだとストアの中身を直接書き換えることになり、同じファイルを使う他のプロジェクトまで変わる。
プロジェクト/
├── .pnp.cjs ← 「どの名前@版が、どの zip のどこにあるか」の対応表。Node.js の require を差し替える
├── .yarn/cache/ ← ゼロインストールのときだけ使う(既定はマシン全体の共有キャッシュ)
│ ├── react-npm-19.2.8-xxxx.zip
│ └── semver-npm-7.7.3-xxxx.zip
└── (node_modules/ は作られない)
enableGlobalCache: true)では、zip はマシン全体の共有キャッシュに置かれる。enableGlobalCache: false にしてプロジェクトの .yarn/cache に置き、git にコミットすると、clone 直後にインストールなしで動く「ゼロインストール」になるyarn dlx @yarnpkg/sdks vscode で SDK を入れないと、補完や型チェックが動かない.yarnrc.yml の nodeLinker を node-modules(npm と同じ平らな配置)にする選択肢もある。pnpm(pnpm と同じシンボリックリンク+中央ストアの配置)も選べる| 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 は既定(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 へのリンク) |
# アプリからは見つからない → 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 文字)を超えると、末尾がハッシュに置き換わる。
@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@' のように先頭で探す。
ワークスペースごとに react の版がずれると、react-dom@…_react@<版> が版の数だけでき、その上に乗る @apollo/client なども組ごとに分かれる。pnpm-workspace.yaml の catalog: で react の版を1つに固定すると、組が1本にまとまる。
# pnpm-workspace.yaml
catalog:
react: 19.2.8
react-dom: 19.2.8
// apps/web/package.json
{
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
}
}
clone はディスク上のブロックを共有しているが、du はそれを見抜けず、ファイルごとに丸ごと数える。git worktree を何本も切っていると、.pnpm/ ごとの du の合計がディスク容量を大きく超えて見えることがあるが、それは実際の使用量ではない。
どれだけ共有されているかは du では測れない。