npm v1 时代的嵌套地狱
node_modules/ ├── react/ │ └── node_modules/ │ └── object-assign/ ├── react-dom/ │ └── node_modules/ │ └── object-assign/ ← 重复一份 └── lodash/
2015 年之前,npm 严格嵌套:每个包有自己的 node_modules。优点是每个依赖看到的版本绝对正确,缺点是重复太多——Windows 还因路径过长崩溃。
npm v3 / Yarn 的扁平化
node_modules/ ├── react/ ├── react-dom/ ├── object-assign/ ← 提升到顶层 ├── lodash/ └── ...(所有依赖混在一起)
2016 年起,npm 和 Yarn 默认扁平化:能提就提。磁盘好多了,但带来了新问题。
问题一:幽灵依赖(phantom dependencies)
// 你的 package.json { "dependencies": { "react": "^18.0.0" } }
// 你的代码 import _ from 'lodash'; // ← 你没装 lodash! _.debounce(...); // 居然能运行
为什么?react 某个子依赖装了 lodash,被扁平化提到顶层——Node 的模块解析能找到,你以为自己装了。直到某天 react 升版不依赖 lodash 了,线上炸掉。
这不是假设
Vercel 2021 年一次 Next.js 升级,打包后产物依赖了
Vercel 2021 年一次 Next.js 升级,打包后产物依赖了
@babel/types(实际是其它包的子依赖,从没显式声明),升级后找不到,全网 build 红。这种事故在大型单仓里几乎是月更频率。
问题二:磁盘重复
~/projects/project-a/node_modules ← 600MB ~/projects/project-b/node_modules ← 600MB (80% 和 A 重复) ~/projects/project-c/node_modules ← 600MB
笔记本 SSD 动辄 256GB,装十个项目 6GB 没了。开 du -sh 去找罪魁祸首,永远是 node_modules。
问题三:非确定性提升
同样 package.json,npm 和 Yarn 提升的结果可能不同,甚至同一工具不同版本结果都可能变——这意味着「本地能跑、CI 跑不通」时,复现很难。
pnpm 的三招
1. 内容寻址仓库(CAS)
所有包只存一份在
~/.local/share/pnpm/store,按文件内容哈希索引。相同文件跨项目跨版本共享,存 100 次 React 只占 1 份空间。2. 硬链接(hard link)到 node_modules
从全局 store 链接到项目,不复制文件——瞬间完成,不占额外空间。
3. 严格 symlink 布局
项目
node_modules/ 顶层只有你 package.json 声明的包。子依赖藏在 node_modules/.pnpm/——幽灵依赖直接报错。对比:装个 Next.js 项目
| npm | Yarn v1 | pnpm | |
|---|---|---|---|
| 首次 install | 75s | 55s | 30s |
| 二次 install(改包) | 35s | 25s | 8s |
| 磁盘占用(10 个项目) | 6.0 GB | 5.8 GB | 1.2 GB |
| 幽灵依赖 | ✅ 有 | ✅ 有 | ❌ 没有 |
| Monorepo workspaces | v7 原生 | 原生 | 原生且最快 |
| Catalog 版本对齐 | ❌ | ❌ | ✅(v9) |
Yarn Berry(v2+)怎么样
Yarn 2020 年推出 Berry,用 Plug'n'Play(PnP)完全绕过 node_modules——更激进,确实快。但:
- 生态适配不全,很多包/工具走不通 PnP(虽然有 pnp-shim 兜底)
.pnp.cjs文件要进仓库,上百 MB- IDE/TS 解析需要插件支持
pnpm 走中间路线:保留 node_modules 布局、生态全兼容,同时用 store+symlink 拿到 PnP 的好处。实际项目里 pnpm 胜出。
生态现状(2026)
- Vercel、Vue、Vite、Astro、SvelteKit、Nx、Turborepo 官方推荐 pnpm
- Next.js 模板
create-next-app默认询问 npm/pnpm/Yarn/Bun,pnpm 是第二位 - VS Code、Chakra UI、Strapi、Supabase 内部开发全在用 pnpm
- npm 20 吸收 pnpm 的
--install-links策略,说明思路被认可
什么时候用 pnpm
推荐场景
- 新项目,没历史包袱
- Monorepo(多包共享依赖)
- 本地开发机磁盘紧张
- CI 想加速 install(store 可缓存复用)
- 想彻底杜绝幽灵依赖
什么时候暂缓
- 老项目用 npm/Yarn 跑得好好的——迁移成本要评估,尤其 postinstall 脚本、hoist 相关配置
- 部分老旧包坚持扁平 hoist 假设——pnpm 提供
public-hoist-pattern兜底 - Serverless 打包要求小体积——pnpm 的 symlink 在某些 deploy 环境要
node-linker=hoisted
Bun 和 Deno 呢
- Bun:自带 bun install 很快,兼容 npm,但 monorepo/catalog 功能比 pnpm 弱。作为 runtime 优秀,作为包管理器目前仍不如 pnpm 成熟。
- Deno 2:默认用
deno.json和 npm:/jsr: 协议,不生成 node_modules。生态略小,但方向有趣。
三年内 JS 社区会有多种包管理工具并存,pnpm 是目前兼容性和功能性最好的平衡点。
本章小结
- npm/Yarn 扁平 node_modules 解决了嵌套地狱,带来幽灵依赖 + 磁盘浪费
- pnpm 用「内容寻址 + 硬链接 + 严格 symlink」三件套全解
- 装包速度 2-3 倍,磁盘省 70%,幽灵依赖 0
- 生态主流工具链默认或推荐 pnpm,迁移成本小
- Yarn Berry/Bun/Deno 各有流派,pnpm 目前平衡最佳