新项目用 pnpm 。Monorepo 用 pnpm workspaces。存量 yarn classic 项目不必强迁。 如果你还在用 npm 跑 CI,你的构建时间和磁盘账单都有改善空间。
三者的核心区别是依赖存储机制 和性能,来看一张对比图:

核心区别详解
npm(Node Package Manager)
Node.js 的官方包管理器,自带安装,无需额外配置。最大的问题是早期使用串行安装,速度慢;每个项目都会在 node_modules 里复制一份完整依赖,多个项目磁盘占用极高。npm v7+ 引入了 workspaces,v7 以后的版本速度也明显改善,但仍不如另外两者。
yarn(Yet Another Resource Negotiator)
Facebook 在 2016 年发布,针对 npm 早期的痛点:并行下载大幅提速,yarn.lock 提供更可靠的版本锁定。现在的 Yarn Berry(v2+)引入了 PnP(Plug'n'Play)模式,完全抛弃 node_modules,但兼容性问题较多,很多工具链还未完全适配,社区接受度一般。
pnpm(Performant npm)
最大亮点是全局内容寻址存储 + 硬链接 :所有版本的包只在 ~/.pnpm-store 存一份,各项目通过硬链接引用,节省大量磁盘空间。同时严格隔离依赖,防止"幽灵依赖"(即代码里能访问到 package.json 里没声明的包),在 Monorepo 场景下表现最好。
依赖存储:三种截然不同的哲学
包管理器最核心的差异不在命令行 API,而在依赖如何存放在磁盘上。这个决策直接决定了安装速度、磁盘占用和依赖隔离性。
npm --- 扁平化 node_modules
npm v3 之后引入扁平化算法(hoisting),把所有依赖尽量提升到顶层 node_modules,解决了早期嵌套过深的问题。代价是产生了「幽灵依赖」:你的代码能 require 到任何被提升到顶层的包,哪怕它根本不在你自己的 package.json 里。
每个项目都会在本地 node_modules 里存一套完整副本。五个项目依赖同一版本的 React,磁盘里就有五份。
yarn classic (v1) --- 确定性 hoisting + 并行下载
yarn v1 的最大贡献是 yarn.lock 和并行网络请求。它修复了 npm 早期「同一机器两次安装结果不一致」的问题,但依赖存储模型与 npm 本质相同:还是扁平化 node_modules,幽灵依赖问题同样存在。
yarn v2/v3(Berry)引入了 PnP(Plug'n'Play)------彻底抛弃 node_modules,用一个 .pnp.cjs 映射文件替代。理论上最快、最严格,但生态兼容性至今仍是硬伤:Webpack、Vite、很多 CLI 工具对 PnP 的支持参差不齐,踩坑成本高。
pnpm --- 内容寻址存储 + 硬链接
pnpm 的机制完全不同。它维护一个全局存储目录(默认 ~/.local/share/pnpm/store),每个版本的每个文件只存一份,用硬链接 (hard link)映射到各项目的 node_modules 里。
bash
# 全局 store 里只有一份 react@18.3.1
~/.pnpm-store/v3/files/00/abc123... ← 真实文件
# 每个项目的 node_modules 只是硬链接
project-a/node_modules/react/index.js ← 硬链接,inode 相同
project-b/node_modules/react/index.js ← 硬链接,inode 相同
更关键的是,pnpm 保留了非扁平化的依赖结构:每个包只能访问它自己声明的依赖,不会意外访问到传递依赖,从根本上消除了幽灵依赖问题。
性能对比:数字说话
以下是在一个中等规模项目(~200 个直接依赖)上的参考数据,三种场景:全新安装、有缓存安装、lockfile 未变的重复安装。
| 场景 | npm v10 | yarn v1 | pnpm v9 |
|---|---|---|---|
| 冷启动(无缓存) | ~85s ❌ | ~55s ⚠️ | ~40s ✅ |
| 有缓存,lockfile 变更 | ~40s ❌ | ~22s ⚠️ | ~14s ✅ |
| 有缓存,lockfile 不变 | ~18s ⚠️ | ~9s ⚠️ | ~3s ✅ |
| 磁盘占用(10个项目复用同版本依赖) | 10× ❌ | 10× ❌ | ~1.1× ✅ |
pnpm 在 CI 环境中的优势更明显:只要挂载全局 store 目录作为缓存,即使 runner 是新容器,增量安装也极快。
yaml
# GitHub Actions 示例:缓存 pnpm store
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
幽灵依赖:被低估的工程风险
幽灵依赖(phantom dependencies)是 npm/yarn 扁平化模型的副产品。一个典型场景:
json
// package.json 里只声明了 webpack
{
"dependencies": {
"webpack": "^5.0.0"
}
}
javascript
// 但代码里直接 require 了 webpack 的间接依赖
const lodash = require('lodash') // webpack 依赖了 lodash,hoisting 到顶层
这段代码现在能跑,但一旦升级 webpack,如果新版本不再依赖 lodash,就会直接报 Cannot find module 'lodash'------而你的 package.json 里根本没有声明它,排查起来也很隐蔽。
更隐蔽的情况是:
你升级了 webpack,lodash 跟着从 4.17.20 升到了 4.17.21。这两个版本之间如果有某个函数的行为细节变了,你的代码可能会出现奇怪的 bug,但不抛任何错误。
更关键的是,你根本不会去排查 lodash------因为你没有动它,它甚至没出现在你的 package.json 里,你可能都不知道自己的代码在用它。
pnpm 的严格模式 从结构上杜绝了这个问题------每个包只能看到自己 package.json 里声明的依赖。如果你想在 pnpm 项目里访问间接依赖,安装时会直接报错,迫使你把依赖显式写进 package.json。
Monorepo:pnpm workspaces 为什么更好
三者都支持 workspaces,但实现质量差距明显。
arduino
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
1. 共享依赖真正共享。 如果 apps/web 和 apps/admin 都依赖 React 18,磁盘里只有一份,通过硬链接复用。npm/yarn workspaces 虽然也会做 hoisting,但每个 workspace 还是会有自己的副本。
2. 过滤执行更精细。 pnpm --filter 支持按包名、路径、依赖关系图过滤,只跑受改动影响的包,CI 时间大幅缩减。
python
# 只 build 依赖了 @myorg/ui 的包(及 @myorg/ui 本身)
pnpm --filter "...[packages/ui]" build
# 只跑 apps/ 下的所有包
pnpm --filter "./apps/*" dev
3. 协议链接本地包更清晰。 用 workspace:* 协议引用本地包,发布时 pnpm 会自动把它替换成真实版本号,不需要手动维护或借助额外工具。
less
// apps/web/package.json
{
"dependencies": {
"@myorg/ui": "workspace:*" // 发布时自动替换为 "^1.2.0"
}
}
迁移成本:从 npm/yarn 切到 pnpm
迁移通常比想象中简单,主要工作量在两块:
1. 幽灵依赖排查
这是最容易卡住的地方。切到 pnpm 后,之前能跑的代码可能因为幽灵依赖直接报错。推荐用 pnpm why <package> 排查某个包从哪里引入,再决定是否显式声明。
makefile
# 检查 lodash 为什么在 node_modules 里
pnpm why lodash
# 输出示例
Legend: production dependency, optional only, dev only
project@1.0.0
dependencies:
webpack 5.88.0
└── lodash 4.17.21
2. 脚本和工具链兼容性
大部分 npm scripts 可以直接用。需要注意的是 npx 换成 pnpm dlx,以及某些直接操作 node_modules 路径的构建工具(一般改个配置就能解决)。
选型决策
| npm | yarn v1 | pnpm | |
|---|---|---|---|
| 适合场景 | 快速原型、脚本工具 | 存量项目维护 | 新项目首选 |
| 不适合 | CI 敏感、大型 Monorepo | 不建议新项目从零选 | 极度依赖特定旧工具链 |
| 关键词 | 零配置、随 Node 自带 | 不折腾、团队熟悉 | Monorepo、CI 提速、严格隔离 |
作为前端同学,建议这样考虑:
- 个人项目、快速上手 → npm,零配置开箱即用
- 团队项目、性能敏感、Monorepo → pnpm,节省磁盘,速度快,依赖关系更干净
- yarn → 如果团队已有存量项目用 yarn classic(v1),继续用即可;新项目不太推荐从 Yarn Berry 起手,兼容性坑较多
目前新项目的主流趋势是 pnpm,Vite、Vue、Nx 等知名项目都在用。
最后
包管理器的选型不是技术洁癖,是工程决策。pnpm 的硬链接模型和严格隔离在多数场景下带来了实质性的收益,这也是为什么 Vue、Vite、Turborepo 等主流项目都在用它。
但如果你的项目已经跑得很好,团队没有遇到明显的痛点,迁移的收益就需要和成本放在一起评估。技术选型最忌讳的是「因为新所以换」,或「因为旧所以不换」。