前端项目里,包管理器很容易被当成一个“安装依赖的工具”。
于是讨论 npm、Yarn、pnpm、Bun 的时候,最后经常只剩下一句话:谁更快。
但包管理器真正影响项目的地方,远不止安装速度。它决定了 node_modules 如何组织、依赖如何解析、lockfile 是否稳定、幽灵依赖会不会被放过、monorepo 工作区怎么管理、CI 缓存怎么设计,甚至会影响团队成员本地环境能不能保持一致。
所以这篇文章不打算只做跑分比较,而是从工程角度把这四个工具放在一起,把真正重要的差异讲清楚。
npm:默认选项,也是生态基线
npm 是 Node.js 默认自带的包管理器。
它最大的优势不是某个单点能力,而是“默认”。几乎所有 Node.js 开发者都知道它,几乎所有包都以 npm registry 为分发中心,几乎所有工具链都默认兼容 npm 的行为。
对一个不复杂的单仓库项目,npm 已经足够稳定。团队协作成本最低——新人不用安装额外工具,CI 环境也通常默认支持。
但 npm 也留下过一些历史包袱,其中最典型的是 node_modules 的扁平化结构。
npm v2 时代(2015 年前)采用嵌套结构——每个包自己的依赖都放在自己目录下的 node_modules 里。结果是路径极深,同一个包可能被重复安装几十次。
npm v3(2015 年)引入了扁平化:尽量把所有依赖提升到项目根部的 node_modules 中。路径深度解决了,但副作用是——项目代码有时候可以引用到自己没有显式声明的依赖。
这就是常说的幽灵依赖。
比如你的项目没有在 package.json 里声明 lodash,但某个依赖 A 间接依赖了 lodash,而 npm 又把 lodash 提升到了根目录。此时你的代码里 import lodash from 'lodash' 可能是能跑的。
问题是,这种可运行是偶然的。一旦依赖 A 升级或移除,lodash 消失,你的项目才会暴露问题。
另外,扁平化也不是绝对的。当 A 依赖 C@1、B 依赖 C@2 时,npm 只能把其中一个版本提升到根目录,另一个版本仍然回退到嵌套结构。所以你看到的 node_modules 实际上是一个“尽量扁平但不保证完全扁平”的结构。
npm 现在已经比早期成熟很多,lockfile、workspace、缓存和安装性能都有明显改善。但它的定位仍然更像生态默认基线:稳定、通用、兼容性好,但在依赖严格性和磁盘效率上不是最激进的。
Yarn:最初是为了解决 npm 的确定性和速度问题
Yarn 最早流行起来,是因为早期 npm 在安装速度和确定性上确实有不少问题。
当年 npm 的 lockfile 还不够成熟,不同机器、不同时间安装出来的依赖结果可能不一致。Yarn 通过 yarn.lock、并行下载和更稳定的缓存机制,解决了很多团队协作中的痛点。
所以 Yarn 一开始的价值很清晰:让依赖安装更快、更确定。
后来 npm 自己补上了 lockfile 和很多性能改进,Yarn 的差异化就转向了另一个方向:Yarn Berry,也就是 Yarn 2+。
Yarn Berry 最大的变化是 Plug'n'Play,简称 PnP。
PnP 的思路很激进:不要再生成传统 node_modules。依赖包存放在 .yarn/cache/ 中以 zip 压缩包形式保存,运行时通过一个 .pnp.cjs 映射文件告诉 Node.js 该从哪里加载包。
这样做有几个明显好处:
- 安装速度快——省去了大量文件 I/O 和目录创建
- 磁盘占用低——依赖以 zip 形式存放,不需要解压
- 依赖关系更严格——不存在幽灵依赖
- 连未声明的 peer dependency 也会被检测到
但它的代价也很明显:生态兼容性。
因为 JavaScript 生态里大量工具都默认假设存在 node_modules。一些老工具、脚本、构建插件、编辑器集成,可能会因为 PnP 的解析方式而出问题。虽然 Yarn 提供了很多兼容方案,但团队需要理解这套模型,否则排查问题时会比较痛苦。
所以今天看 Yarn,可以分成两种语境:
- Yarn Classic(Yarn 1),更像 npm 的增强版,
node_modules结构与 npm 基本相同。 - Yarn Berry(Yarn 2+),更像一套更严格、更现代但也更有学习成本的依赖管理方案。
如果一个团队已经深度使用 Yarn workspace 或 PnP,并且工具链兼容良好,那它完全可以继续稳定使用。但如果是新项目,我不会因为“Yarn 更快”就默认选择 Yarn,因为这个优势已经不像早期那么明显。
pnpm:用内容寻址存储解决重复安装问题
pnpm 是我现在更愿意在中大型前端项目里优先考虑的包管理器。
它最核心的特点不是“快”,而是依赖存储模型不同。
npm 和 Yarn Classic 会把依赖复制到项目的 node_modules 里。不同项目如果都依赖 React,那每个项目都会有一份自己的 React。
pnpm 的做法是:把包内容存到全局内容寻址仓库中,然后在项目的 node_modules 里通过硬链接和符号链接指向这些真实文件。
简单理解:
全局 store:
[email protected]
[email protected]
项目 A node_modules:
react -> 硬链接到全局 store
typescript -> 硬链接到全局 store
项目 B node_modules:
react -> 硬链接到同一份全局 store
这样做带来两个直接收益:
- 磁盘占用更低——一台机器上 10 个项目共用同一份 React。
- 安装速度更快——已缓存的包不需要反复下载和复制,只需创建链接。
但 pnpm 更重要的优点是:它的 node_modules 结构更严格。
pnpm 不会像 npm 那样随意把间接依赖提升到根目录。默认情况下,项目只能访问自己在 package.json 里声明过的依赖。
这会让某些历史项目第一次迁移到 pnpm 时直接报错。
表面看是 pnpm 更麻烦,实际上是 pnpm 把项目原本就存在的问题暴露出来了:你代码里使用了没有声明的依赖。
这也是我喜欢 pnpm 的原因之一——它会逼你把依赖关系写清楚。
在 monorepo 场景里,pnpm 的 workspace 也很成熟。它可以很好地处理多个 package 之间的本地依赖、统一 lockfile、依赖复用和安装缓存。对于组件库、前后端混合仓库、工具包集合这类项目,pnpm 的体验通常很好。
当然,pnpm 也不是没有成本。
因为它的依赖结构更严格,某些写得不规范的第三方包可能会暴露问题。比如某个包内部使用了自己没有声明的 peer dependency 或间接依赖,在 npm 下可能侥幸能跑,在 pnpm 下就会失败。
对于从老项目迁移的情况,pnpm 提供了 shamefully-hoist 配置。在 .npmrc 中设置:
shamefully-hoist=true
它会让 pnpm 模拟 npm 的扁平提升行为,把间接依赖也提升到根目录。这可以作为迁移过渡手段,但我不建议长期依赖它——这等于放弃了 pnpm 在依赖边界上的核心优势。更好的做法是逐步修复那些隐式依赖,然后关掉这个配置。
所以 pnpm 更适合愿意维护依赖边界的团队。如果团队本来就重视工程健康,pnpm 反而能帮你更早发现问题。
Bun:它不只是包管理器,而是一整套 JavaScript 工具链
Bun 和前面三个工具不太一样。
npm、Yarn、pnpm 本质上都是 Node.js 生态里的包管理器。Bun 虽然也提供 bun install,但它的野心更大:它同时是 JavaScript runtime、包管理器、脚本运行器、测试工具和打包工具。
这是讨论 Bun 时最容易混淆的地方,所以先说清楚:如果只是想换包管理器,不一定要顺手把 runtime 也换掉。 Bun 的包管理器功能可以单独使用——bun install 和 bun run <script> 跑的是你原来的 Node.js 项目,不强制要求切换到 Bun 的运行时。
但如果从 bun install 一路用到 bun run dev、bun test、bun build,那你引入的就不是一个包管理器,而是一整套替代 Node 工具链的方案。这对兼容性的要求会高很多。
Bun 对很多 Node.js API 已经兼容得不错,但“兼容不错”和“完全等价”不是一回事。尤其是一些依赖底层 Node 行为、原生模块、构建脚本、复杂 CLI 的项目,仍然需要实际验证。
如果只看安装依赖,Bun 确实很快。它的安装器用更接近系统底层的语言实现,文件 I/O 和依赖解析都很快。lockfile 方面,Bun 1.0 使用二进制格式 bun.lockb,1.2+ 版本新增了可读的文本格式 bun.lock。执行 bun install 通常非常快。
但 Bun 的 node_modules 组织方式本质上还是扁平提升模型,幽灵依赖问题仍然存在。它没有像 pnpm 或 Yarn PnP 那样在依赖边界上做严格约束。
所以我对 Bun 的使用建议比较谨慎:
- 新项目、个人项目、工具项目,可以大胆尝试。
- 对性能敏感的本地开发流程,可以考虑单独使用它的包管理器。
- 生产级大型项目要先确认依赖兼容性。
- 如果只是想提升安装速度但不想承担兼容风险,pnpm 可能是更稳的选择。
Bun 的优势是未来感很强,体验也很快;它的风险是边界更宽,因为你引入的可能不只是一个包管理器。
node_modules 组织方式:从嵌套地狱到内容寻址
前面各工具的介绍里,node_modules 的组织方式一直在被反复提及。这一节做一个系统性的横切对比——这件事很重要,因为它直接决定了幽灵依赖、磁盘占用、安装速度和 CI 缓存策略。
阶段一:npm v2 的嵌套地狱
npm v2 时代(2015 年前),每个依赖的依赖都嵌套在自己目录下:
node_modules/
A/
node_modules/
C/ ← A 依赖 C
B/
node_modules/
C/ ← B 也依赖 C,各存一份自己的
一个问题:如果你的项目有 50 个包都依赖 lodash,lodash 就会被复制 50 次。
更麻烦的是路径深度。像 node_modules/A/node_modules/D/node_modules/E/... 这种层级在 Windows 上很容易超过 260 字符的路径限制。
阶段二:npm v3 / Yarn Classic 的扁平化
npm v3 开始尝试把所有依赖尽量提升到根目录:
node_modules/
A/
B/
C/ ← A 和 B 都依赖 C,C 被提升到根目录共享
解决的问题:路径深度、部分磁盘浪费。
引入的新问题:幽灵依赖——项目代码可以直接 import C,即使 package.json 没声明。而且当 A 要 C@1、B 要 C@2 时,其中一个版本仍会回退嵌套,扁平化不是绝对的。
本质上,这不是“优化”,而是用一个新问题换掉了旧问题。 Yarn Classic 沿用同一套模型。
阶段三:pnpm 的内容寻址 + 符号链接
pnpm 没有修修补补扁平化,而是重新设计了存储模型:
node_modules/
.pnpm/
[email protected]/
node_modules/
C/ → 硬链接到全局 store ← A 自己的依赖在 A 目录下
[email protected]/
node_modules/
C/ → 硬链接到同一份全局 store ← B 也依赖 C,指向同一份文件
[email protected]/
node_modules/
C/ → 硬链接到全局 store
A/ → 符号链接到 .pnpm/[email protected]/node_modules/A
B/ → 符号链接到 .pnpm/[email protected]/node_modules/B
三个核心机制:
| 机制 | 说明 |
|---|---|
| 全局 store(内容寻址) | 所有项目中的同一个包只存一份,磁盘复用跨项目生效 |
| 硬链接 | .pnpm/ 里的文件指向全局 store 的实际物理文件,不额外占磁盘 |
| 符号链接 | 根 node_modules/ 只暴露你在 package.json 里声明过的依赖 |
非扁平 .pnpm 结构 | 每个包自己的依赖只在它自己的 .pnpm 子目录下,不会被提升到根 |
pnpm 至少优化了四个维度:
- 磁盘极省——同一台机器 10 个项目都装 React 19,只有一份物理文件
- 安装更快——已缓存包不需要复制,只创建硬链接
- 幽灵依赖根除——根目录只暴露声明过的包
- 依赖边界严格——每个包的
node_modules精确反映它自己的package.json
阶段四:Yarn Berry PnP 的零 node_modules
Yarn Berry 走得最远——完全不去生成 node_modules 目录:
项目根目录/
.pnp.cjs ← 依赖映射表(告诉 Node.js 去哪加载每个包)
.yarn/cache/
react-19.2.1.zip ← zip 压缩包形式存放
lodash-4.17.21.zip
Node.js 不再通过文件系统目录递归查找模块,而是直接查 .pnp.cjs 这个映射表,定位到 .yarn/cache/ 中的 zip 包。
好处是安装极快(没有 I/O 密集的目录创建和复制)、依赖关系严格(没有 node_modules 自然没有幽灵依赖)、磁盘更省(zip 不需要解压)。代价是生态兼容性——大量工具和插件默认假设 require('xxx') 会走文件系统查找 node_modules。
阶段五:Bun 的回归务实
Bun 的 node_modules 结构回归到扁平提升模型,没有在依赖边界上推进一步。但它做了两点优化:
- 安装器用更接近底层的语言实现,文件 I/O 和解析比 Node.js 实现快
- 全局缓存 + 硬链接,同一台机器上的重复包通过硬链接共享(类似 pnpm 的全局 store 思路)
但注意:Bun 仍会做扁平提升,幽灵依赖依然存在。如果你从 pnpm 切到 Bun,之前被 pnpm 拦住的未声明依赖在 Bun 下可能又悄无声息地跑起来了。
总结对比
| 工具 | 组织方式 | 幽灵依赖 | 磁盘复用方式 | 跨项目共享 |
|---|---|---|---|---|
| npm v2 | 嵌套 | 无 | 无 | 无 |
| npm v3+ | 扁平提升 | 有 | 无 | 无 |
| Yarn Classic | 扁平提升 | 有 | 无 | 离线缓存 |
| pnpm | 内容寻址 + 符号链接 | 无 | 硬链接 | 全局 store |
| Yarn Berry PnP | 零 node_modules + 映射表 | 无 | zip 缓存 | 全局缓存 |
| Bun | 扁平提升 + 硬链接 | 有 | 硬链接 | 全局缓存 |
这个对比表能解释很多现象:为什么 pnpm 装得快又省磁盘、为什么 Yarn PnP 最严格但生态兼容成本最高、为什么从 npm 迁移到 pnpm 会报错(不是 pnpm 有问题,是原来的依赖关系有问题)。
lockfile:团队协作里真正不能忽略的东西
包管理器的一个核心职责,是让不同机器安装出同一棵依赖树。
这就是 lockfile 的意义。
不同工具的 lockfile 不一样:
| 工具 | lockfile |
|---|---|
| npm | package-lock.json |
| Yarn | yarn.lock |
| pnpm | pnpm-lock.yaml |
| Bun | bun.lock(文本,1.2+)/ bun.lockb(二进制,1.0) |
lockfile 应该提交到仓库。
如果一个项目里同时出现多个 lockfile,比如既有 package-lock.json 又有 pnpm-lock.yaml,还混了 bun.lock,这通常意味着团队里有人用不同工具装过依赖,依赖树可能已经不一致。
一个项目只保留一个包管理器、一个 lockfile。 这件事比选择哪个工具更重要。
CI 中必须强制校验 lockfile
在本地开发时,npm install 可能会因为版本范围(如 ^1.0.0)而更新 lockfile——这在 CI 中是绝对不应该发生的。
CI 环境必须使用各工具的“冻结安装”命令,强制依赖树与 lockfile 完全一致,lockfile 有差异则直接失败:
npm ci # 严格按 package-lock.json 安装
pnpm install --frozen-lockfile # 严格按 pnpm-lock.yaml 安装
yarn install --frozen-lockfile # 严格按 yarn.lock 安装
bun install --frozen-lockfile # 严格按 bun.lock 安装
关键区别:
npm install/pnpm install可能修改 lockfile,不要在 CI 里使用npm ci/--frozen-lockfile强制 lockfile 不可变,lockfile 与package.json不匹配时直接报错,这是 CI 唯一正确的用法
这个实践应该写进项目的 CI 配置里,而不是靠团队自觉。一个常见的场景:有人本地改了 package.json 但忘记一起提交 lockfile 的变更,CI 如果不做冻结校验,产线和本地跑的可能就是两棵不同的依赖树。
性能对比不能只看“安装快不快”
包管理器性能通常被简化成安装速度,但工程里至少要分几种情况看。
| 场景 | 关键因素 |
|---|---|
| 冷安装 | 无缓存、无 node_modules,从头下载——测下载 + 解压 + 链接全流程 |
| 热安装 | 缓存已存在,只校验和链接——测文件系统操作效率 |
| CI 安装 | 依赖缓存、网络稳定性、lockfile 校验都会影响 |
| monorepo 安装 | 多 package 共享依赖——依赖复用能力是核心 |
在这些场景里,通常可以这样粗略理解:
- npm:稳定通用,冷安装和热安装都已经够用,但 monorepo 下磁盘占用较高
- Yarn Classic:历史上比同时期的 npm 快,现在差距不明显
- Yarn Berry:PnP 模式下冷热安装都很快,但需要生态兼容
- pnpm:冷安装快(内容寻址避免重复下载)、热安装也快(硬链接)、磁盘最省,monorepo 下优势尤其突出
- Bun:冷热安装都非常快,但要同时关注磁盘模型(扁平提升)和运行时兼容
如果只是一个普通小项目,性能差异未必值得你为之迁移工具链。但如果你维护的是大型 monorepo,或者经常在 CI 里安装大量依赖,pnpm 或 Bun 带来的时间差就会变得很明显。
monorepo 场景下,我更偏向 pnpm
如果是单个简单应用,npm、pnpm、Bun 都能胜任,差别没有很多文章说得那么夸张。
但如果是 monorepo,我会更偏向 pnpm。原因有几个。
首先,pnpm workspace 的模型很清楚。pnpm-workspace.yaml 定义工作区:
packages:
- "apps/*"
- "packages/*"
多个 package 之间通过 workspace 协议建立本地依赖:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
其次,pnpm 的全局 store 对 monorepo 很友好。同一仓库里多个包依赖同一个版本的 React、TypeScript、ESLint,不需要重复复制。
再者,它的依赖严格性会让 monorepo 的边界更清楚。每个 package 用了什么就应该自己声明,而不是靠根目录偶然存在的依赖活着。这对长期维护很重要——monorepo 最怕的不是一开始跑不起来,而是半年以后某个子包到底依赖谁已经没人说得清。
补充一点:npm 7+ 和 Bun 也都支持 workspaces,基本功能是够用的。但它们在依赖复用效率(磁盘和安装速度)和依赖边界严格性上,目前还比不上 pnpm。
什么时候选哪个
如果让我给一个比较实际的建议:
| 场景 | 建议 | 理由 |
|---|---|---|
| 简单 Node.js / Next.js 项目 | npm 或 pnpm | npm 胜在默认稳定,pnpm 胜在严格和磁盘效率 |
| 中大型项目 / monorepo | pnpm 优先 | workspace 成熟、安装快、磁盘省、依赖边界严 |
| 已深度使用 Yarn 的老项目 | 不迁移 | 稳定优先,Yarn 1 够用,Yarn Berry 需确认工具链兼容 |
| 个人项目 / 工具 / 脚手架 | Bun | 速度快、一体化体验好、边界可控 |
| 对生产稳定性要求高 | 不要急着切 Bun | 先确认依赖兼容性和运行时行为,想提安装速度可考虑 pnpm |