この記事の要点
- pnpm は npm 互換の高速パッケージマネージャ
- 最大の違いは「依存管理の厳格さ」
- Phantom Dependencies を防止しやすい
- CI / モノレポと相性が良い
- npm ユーザーでも移行コストは低い
なぜ今 pnpm か
昨今のソフトウェアサプライチェーン攻撃の増加を受け、「使っている依存パッケージをすべて把握できているか」は組織のセキュリティ課題として重要です。
npm の hoisting と Node.js の module resolution の組み合わせにより、宣言していないパッケージでも import 解決できてしまう問題(Phantom Dependencies)を引き起こしやすく、依存関係の認識ズレを生みやすいです。
pnpm は Phantom Dependencies を防止しやすくし、依存関係の可視性と再現性を改善します。
pnpm は「npm が危険だから置き換えるべき」というより、「依存関係をより厳格・高速・再現性高く管理するための選択肢」と捉えるのが適切です。
コマンド体系は npm とほぼ同じなので、移行コストは低いです。
| 観点 | 効果 | |
|---|---|---|
| 🔒 | 依存関係を把握しやすい | SBOM・監査・レビュー精度向上 |
| ⚡ | インストールが速く、ディスク消費が少ない | CI 高速化・開発体験向上 |
| 🔄 | コマンドはほぼ同じ | 既存プロジェクトも容易に移行可能 |
1. pnpm とは
pnpm(Performant npm)は JavaScript / Node.js 向けパッケージマネージャです。
npm・yarn と同じ役割を担いながら、以下の点で大幅に優れています。
- セキュリティ / 再現性 — Phantom Dependencies を防止しやすい
- ディスク効率 — パッケージをグローバルストアに1つだけ保存し、全プロジェクトで共有
- インストール速度 — キャッシュヒット時は主にハードリンクを張るだけ
- 成熟したエコシステム — Vue.js・Vite・Nuxt・Turborepo 等が採用
公式サイト: https://pnpm.io/
2. Phantom Dependencies(幽霊依存)とは
npm は依存関係を可能な限り node_modules/ 上位へ hoist します。
その結果、自分の package.json に書いていないパッケージでも、偶然 import できてしまうことがあります。
これを Phantom Dependencies(幽霊依存) と呼びます。
// package.json
{
"dependencies": {
"react": "^19.0.0"
}
}
一見すると、このプロジェクトは react しか使っていないように見えます。
しかし react 自体も内部で他パッケージに依存しています。
node_modules/
react/ ← 自分が宣言した依存
loose-envify/ ← react の依存
js-tokens/ ← loose-envify の依存
npm の hoisting と Node.js の module resolution により、これらが同じ階層に並ぶことがあります。
すると:
import looseEnvify from 'loose-envify'
が動いてしまいます。
しかし loose-envify は自分の package.json に存在しません。
つまり:
- コードは依存している
- しかし package.json には書かれていない
という不整合が発生します。
なぜ問題か
- 実際に利用している依存と
package.jsonがズレる - レビュー・SBOM・監査が難しくなる
- CI や別環境で突然壊れる
- 依存構造の理解が難しくなる
3. npm と pnpm の違い
| 観点 | npm | pnpm |
|---|---|---|
| 依存解決 | hoist ベース | 宣言依存を重視 |
| 保存方式 | プロジェクトごとにコピー | グローバルストア共有 |
| リンク方式 | コピー中心 | ハードリンク + シンボリックリンク |
| Phantom Dependencies | 起きやすい | 防止しやすい |
pnpm では通常 node_modules/ 直下には直接依存しか現れません。
推移的依存は .pnpm/ 内に隔離されます。
node_modules/
react -> .pnpm/react@19/node_modules/react
この構造によって、宣言していない依存への意図しない依存アクセスを防ぎやすくします。
4. pnpm のメリット
4-1. lifecycle scripts の明示的な許可制御
npm がすべての lifecycle scripts(postinstall 等)を自動実行するのに対し、pnpm は明示的な許可リスト方式でスクリプト実行を制御します。
-
pnpm v9/v10: デフォルトでは lifecycle scripts は自動実行されます。
package.jsonのpnpm.onlyBuiltDependenciesフィールドで許可リストを設定することで、リストに載っていないパッケージのスクリプトをブロックできます - pnpm v11 以降: lifecycle scripts の扱いがさらに厳格化され、未許可パッケージのビルドスクリプトを制御しやすくなりました(参考:https://github.com/pnpm/pnpm/releases/tag/v11.0.0)
「このパッケージのスクリプトは本当に必要か」を開発者が意識的に判断することが強制されるため、サプライチェーン攻撃リスク低減の一助になります。
4-2. ディスク使用量を大幅削減
節約されるのはプロジェクト単体ではなく、端末ストレージ全体のレベルです。同じパッケージはグローバルストア(pnpm store path で確認可能)に1つだけ物理コピーが存在し、各プロジェクトの node_modules/ からはハードリンクで参照します。ローカルに抱えるプロジェクトが増えるほど節約効果が大きくなります。
例: 10 プロジェクトで React 19.0.0 を使う場合
npm: 端末に 10 個の物理コピー
pnpm: 端末に 1 個の物理コピー。各プロジェクトはハードリンクで参照
4-3. インストールが高速
- キャッシュヒット時はネットワーク不要でリンクを張るだけです
- 並列インストールが最適化されています
- CI 環境ではストアキャッシュを保持することでさらに高速化できます
CI での pnpm が速い理由
npm は node_modules/ をまるごとキャッシュする必要があり、サイズが大きくキャッシュの復元・展開にも時間がかかります。
pnpm はグローバルストアだけキャッシュすれば済み(GitHub Actions Linux ランナーでは ~/.local/share/pnpm/store)、復元後は実体ファイルのコピーではなくハードリンクを張るだけなのでコピー中心の展開より低コストです。
「キャッシュサイズが小さい × 復元後の展開コストが低い」という2つの効果が重なり、pnpm install ステップが大幅に短縮されます。
# GitHub Actions のキャッシュ設定例
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
5. pnpm のデメリット・注意点
5-1. シンボリックリンクへの非対応ツールとの相性問題
一部の古いツールやスクリプトがシンボリックリンクを正しく解決できないことがあります。
ただし現在はほぼ解消されており、主要なビルドツール(Vite, webpack, esbuild 等)は対応済みです。
対処法: .npmrc に shamefully-hoist=true を設定することで npm 互換のフラット構造に切り替え可能です(ただし非推奨)。
5-2. グローバルストアの管理が必要
グローバルストアが肥大化した場合は手動で pnpm store prune を実行する必要があります。
pnpm store prune は、どのプロジェクトからも参照されていないパッケージ(古いバージョン・削除済みプロジェクトが使っていたパッケージ等)をグローバルストアから削除し、ディスクを解放します。現在いずれかのプロジェクトで使われているパッケージは削除されないため、既存プロジェクトへの影響はありません。
5-3. チームへの学習コスト(小さい)
npm install → pnpm install、npm run → pnpm など、コマンドはほぼ同じです。
移行コストは低いです。
| 操作 | npm | pnpm |
|---|---|---|
| 依存インストール | npm install |
pnpm install |
| パッケージ追加 | npm install react |
pnpm add react |
| 開発依存追加 | npm install -D vitest |
pnpm add -D vitest |
| パッケージ削除 | npm uninstall react |
pnpm remove react |
| スクリプト実行 | npm run build |
pnpm build または pnpm run build
|
| グローバルインストール | npm install -g pkg |
pnpm add -g pkg |
| ストアのクリーンアップ | — | pnpm store prune |
| ロックファイル更新 | npm install |
pnpm install |
5-4. corepack が Node.js 本体から切り離された
Node.js 25(2025年10月リリース)以降、corepack は Node.js の配布物から削除されました(参考:https://nodejs.org/en/blog/announcements/v25-release-announce)。corepack 自体は独立プロジェクト(https://github.com/nodejs/corepack)として継続開発されていますが、Node.js に同梱されなくなったため、corepack 経由で pnpm を管理していたプロジェクトは Node.js 25 以降の環境では別途 npm install -g corepack が必要になります。
Node.js 24 以前の環境では引き続き同梱の corepack が使えます。
5-5. package-lock.json との共存不可
pnpm は pnpm-lock.yaml を使用します。
npm install を誤って実行すると package-lock.json が生成されリポジトリが混乱するため、package.json の packageManager フィールドで制限するのが推奨です。なお、この制限は corepack enable で corepack が有効な環境でのみ機能します。
{
"packageManager": "pnpm@9.15.0"
}
または preinstall スクリプトで強制することもできます。
{
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
6. 導入・移行手順
インストール
# npm 経由(推奨)
npm install -g pnpm
# corepack を使う方法(最新版を使いたい場合)
# Node.js 24 以前: 同梱の corepack をそのまま使える
# Node.js 25 以降: 先に `npm install -g corepack` が必要
corepack install -g pnpm@latest
既存 npm プロジェクトからの移行
# 1. node_modules と package-lock.json を削除
rm -rf node_modules package-lock.json
# 2. pnpm でインストール
pnpm install
# 3. pnpm-lock.yaml をコミット、package-lock.json は .gitignore へ
既存プロジェクトの段階的移行(参考)
既存の npm プロジェクトでは、Phantom Dependencies に暗黙的に依存しているコードがある場合、shamefully-hoist=false(pnpm デフォルト)に切り替えた瞬間にビルドやテストが壊れることがあります。そのような場合は以下の段階的アプローチが有効です。
# ステップ1: まず shamefully-hoist=true で pnpm に切り替える
# → npm と同じフラット展開になるため、既存コードがそのまま動く
echo "shamefully-hoist=true" >> .npmrc
rm -rf node_modules package-lock.json
pnpm install
# ステップ2: ビルド・テストが通ることを確認したら、false に戻す
# → エラーになった箇所 = Phantom Dependencies に依存している箇所
# macOS:
sed -i '' 's/shamefully-hoist=true/shamefully-hoist=false/' .npmrc
# Linux:
# sed -i 's/shamefully-hoist=true/shamefully-hoist=false/' .npmrc
pnpm install
# ステップ3: エラーになったパッケージを package.json に明示的に追加
pnpm add <不足していたパッケージ>
shamefully-hoist=true は「恥ずかしながらホイストする」という名前の通り、あくまで移行の踏み台です。最終的には false(デフォルト)に戻し、pnpm の厳格な依存管理を活かすことが目標です。
.npmrc の推奨設定
# Node.js の engines フィールドのバージョン指定を厳格に適用する
engine-strict=true
.npmrc と pnpm-workspace.yaml の関係
2つのファイルは役割が全く異なるため、競合・上書きの関係はなく、両方を同時に使えます。
| ファイル | 役割 |
|---|---|
.npmrc |
pnpm の動作設定(厳格モード・ホイスト設定・レジストリ等) |
pnpm-workspace.yaml |
ワークスペースの定義(モノレポでどのディレクトリをパッケージとして扱うか) |
読み込み順の優先度もなく、設定する対象が別物なので「どちらかが上書きする」という概念自体が存在しません。
# .npmrc — 「pnpm がどう動くか」を設定
engine-strict=true # engines フィールドのバージョン指定を厳格に適用
shamefully-hoist=false # 推移的依存を node_modules/ 直下に展開しない(デフォルト。pnpm の厳格な依存管理を維持する設定)
# pnpm-workspace.yaml — 「どこがパッケージか」を定義(モノレポのみ必要)
packages:
- 'apps/*'
- 'packages/*'
package.json にエンジン指定を追加(任意)
{
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"packageManager": "pnpm@9.15.0"
}
まとめ
- pnpm は npm 互換の高性能パッケージマネージャ
- 最大の特徴は依存管理の厳格さ
- CI・モノレポ・大規模開発と相性が良い
- npm ユーザーでも移行コストは低い
参考資料
| タイトル | URL |
|---|---|
| pnpm 公式ドキュメント | https://pnpm.io/motivation |
| pnpm — Feature Comparison(npm/yarn との比較表) | https://pnpm.io/feature-comparison |
pnpm — Symlinked node_modules structure |
https://pnpm.io/symlinked-node-modules-structure |
| Node.js corepack 公式ドキュメント | https://github.com/nodejs/corepack#readme |
| Node.js 25 リリースアナウンス(corepack 削除の公式発表) | https://nodejs.org/en/blog/release/v25.0.0 |
| pnpm v11 リリースノート(lifecycle scripts 挙動変更) | https://github.com/pnpm/pnpm/releases/tag/v11.0.0 |
| event-stream 事件 GitHub issue | https://github.com/dominictarr/event-stream/issues/116 |
| npm 公式ドキュメント | https://docs.npmjs.com/ |