返回博客列表
技术文章Node.js包管理器npmYarnpnpmBun工程化

npm、Yarn、pnpm 和 Bun 到底有什么区别

2026年05月🇨🇳 中文

前端项目里,包管理器很容易被当成一个“安装依赖的工具”。

于是讨论 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

这样做带来两个直接收益:

  1. 磁盘占用更低——一台机器上 10 个项目共用同一份 React。
  2. 安装速度更快——已缓存的包不需要反复下载和复制,只需创建链接。

但 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 installbun run <script> 跑的是你原来的 Node.js 项目,不强制要求切换到 Bun 的运行时。

但如果从 bun install 一路用到 bun run devbun testbun 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 个包都依赖 lodashlodash 就会被复制 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 至少优化了四个维度:

  1. 磁盘极省——同一台机器 10 个项目都装 React 19,只有一份物理文件
  2. 安装更快——已缓存包不需要复制,只创建硬链接
  3. 幽灵依赖根除——根目录只暴露声明过的包
  4. 依赖边界严格——每个包的 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 结构回归到扁平提升模型,没有在依赖边界上推进一步。但它做了两点优化:

  1. 安装器用更接近底层的语言实现,文件 I/O 和解析比 Node.js 实现快
  2. 全局缓存 + 硬链接,同一台机器上的重复包通过硬链接共享(类似 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
npmpackage-lock.json
Yarnyarn.lock
pnpmpnpm-lock.yaml
Bunbun.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 或 pnpmnpm 胜在默认稳定,pnpm 胜在严格和磁盘效率
中大型项目 / monorepopnpm 优先workspace 成熟、安装快、磁盘省、依赖边界严
已深度使用 Yarn 的老项目不迁移稳定优先,Yarn 1 够用,Yarn Berry 需确认工具链兼容
个人项目 / 工具 / 脚手架Bun速度快、一体化体验好、边界可控
对生产稳定性要求高不要急着切 Bun先确认依赖兼容性和运行时行为,想提安装速度可考虑 pnpm