npm / yarn / pnpm:别再靠感觉选了

新项目用 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/webapps/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 等主流项目都在用它。

但如果你的项目已经跑得很好,团队没有遇到明显的痛点,迁移的收益就需要和成本放在一起评估。技术选型最忌讳的是「因为新所以换」,或「因为旧所以不换」。

相关推荐
网安蟹佬霸23 分钟前
区块链与智能合约安全实战:从Solidity审计到DeFi漏洞深度剖析
运维·前端·网络·安全·自动化·区块链·智能合约
念何架构之路27 分钟前
Gin响应渲染
前端·javascript·gin
invicinble32 分钟前
设计网站的底层思路(深刻版本)
前端
三8441 小时前
WordPress REST API 参数校验机制剖析:为什么 author__not_in 无法直接盲注?
服务器·前端·数据库
ITmaster07311 小时前
Vibe Coding 时代:Vue 消失了还是 React 太强?
前端·vue.js·react.js
WebInfra1 小时前
Rspack 2.2 发布:30+ 项性能优化,拥抱 Solid 2.0
前端·javascript·前端框架
额额额对了1 小时前
Linux 进程管理详解:从概念到实战
java·服务器·前端
fatcoder1 小时前
玩转 Redis · Set 篇
前端·redis·后端
爱喝麻油的小哆1 小时前
🐾 Day 5|桌面数字人-接入llm可以对话啦
前端·three.js
我叫黑大帅2 小时前
关于没有对生产者做校验的思考
前端·面试·架构