事情要从一次磁盘清理说起。

某天我扫了一眼 C 盘,发现可用空间只剩 3GB。用工具分析了一下磁盘占用,node_modules 文件夹加起来 12GB。我电脑上也就五六个前端项目,平均每个项目的 node_modules 超过 2GB。

而且几乎每个项目的 node_modules 里都有一模一样的 typescripteslintpostcss——版本号一样,代码也一样。一个文件在硬盘上被复制了五六份,每一份都在占我的空间。

这让我开始认真想一个问题:包管理器的本职工作到底是什么

npm 的”扁平化”是怎么来的

在 npm v3 之前,node_modules 是嵌套的。每个包把自己的依赖装在自己的 node_modules 里,一层套一层。如果你同时装了 A、B、C 三个包,它们都依赖 lodash,那 node_modules 里会存在三份 lodash——尽管版本一样。

这在 Windows 上很致命。Windows 的文件路径最大长度是 260 个字符,嵌套多了直接报错。有些项目甚至因为路径太长删不掉,需要用 robocopy 或者专门工具来清理。

Windows 经典问题

删不掉 node_modules 是每个前端开发者的入门仪式:

# 试过这个的都知道我在说什么
Remove-Item node_modules -Recurse -Force
# 报错:路径太长,无法删除

npm v3 引入了扁平化方案——把所有依赖尽量”提升”到顶层的 node_modules。同一个版本号的包只装一份,大大减少了目录嵌套深度。

但扁平化带来了新问题:依赖幽灵。项目代码可以直接 require 一个没在 package.json 里声明的包——因为它被某个依赖”提升”上来了。更糟的是,不同版本的依赖冲突时,npm 的处理策略会因安装顺序而异,导致不同人的电脑上 node_modules 结构不同。

业内把这个问题叫 “幻影依赖”(phantom dependencies)和不一致性问题。我在之前那篇文章 里提到过类似的问题——你以为是工具帮你管好的事情,实际上全靠运气。

yarn 的 lock 文件是好东西

yarn 在 2016 年出来的时候,最大的贡献是 yarn.lock解决了一个非常基础的问题:不同人装出来的依赖应该一样

在 yarn 之前,npm 的 npm-shrinkwrap.json 也有类似功能,但体验很差,很少人用。yarn.lock 默认生成、格式清晰、自动维护,一下子成了标配。npm 后来在 v5 也抄了 package-lock.json

但我用 yarn 的时候有个烦人的问题:离线安装包,慢到怀疑人生。刚到一个新环境,yarn install 要跑四五分钟,输出全是网络请求日志,卡在某个包上好半天。后来发现是 yarn v1 的网络请求策略有问题——每次重新安装都重新请求,缓存命中率不高。

pnpm 让”空间”这个问题第一次被认真对待

pnpm 的核心思路其实很简单:用硬链接来共享依赖

原理一句话

pnpm install 把所有包下载到一个全局存储目录(~/.pnpm-store),然后在项目的 node_modules 里创建硬链接指向那个目录。 同一个版本同一个包,不管装了多少个项目,硬盘上只有一份。

我用 pnpm 重建了博客项目之后,磁盘占用从 2.1GB 降到了 1.2GB——而且这还是单项目的对比。如果是六七个项目都用 pnpm,共享的 node_modules 会让总占用低得多。

你感性地感受一下这个差异:

场景npmpnpm
博客项目装依赖2.1 GB1.2 GB
新建第二个项目再花 2GB只需下载增量包
5 个项目总占用10+ GB2~3 GB
首次 install 速度50s35s

pnpm 的全局存储默认在 ~/.pnpm-store。可以通过 pnpm store path 查看位置,用 pnpm store prune 清理不再被引用的包。

严格的依赖隔离

pnpm 的第二个设计我觉得比空间更重要——它默认用的是严格隔离node_modules 结构。

简单说就是:你只能 importrequirepackage.json 里显式声明过的包。那些”被某个依赖提升上来的包”,你引用不到。

这听起来像是一个限制,实际上是一个保护:

// 在 npm 里,这段代码可能"碰巧能跑"——
// 因为 eslint 依赖了 typescript,typescript 被提升到了顶层 node_modules
const ts = require('typescript')

// 在 pnpm 下,这段代码直接报错——除非你显式安装了 typescript
// ERR_PNPM_NO_DEV_DEPENDENCY  typescript is not in your package.json

第一次遇到这个报错的时候我也骂过——“怎么就我的电脑跑不了”。后来发现是好事情:你的代码依赖了什么,就应该写清楚。不然项目交接的时候没人知道哪些包是真正的依赖,哪些是碰巧装在那里的。

开发体验:快

pnpm 的安装速度快是有原因的。除了硬链接减少 IO 外,它的安装算法也比 npm 的确定性安装要快。我实测了几个项目:

# 清缓存后全量安装
Measure-Command { npm install }  # ~52s
Measure-Command { pnpm install } # ~35s

# 有缓存时重新安装
Measure-Command { npm ci }       # ~28s
Measure-Command { pnpm install } # ~15s

日常开发中最明显的是:切换分支、stash、拉代码之后跑 pnpm install 基本秒完成。npm 的话要等十几二十秒检查一遍。

迁移到 pnpm 的过程

我的博客项目迁移过程很简单。pnpm 官方提供了兼容策略——pnpm import 可以从 package-lock.jsonyarn.lock 导入依赖锁定信息:

# 1. 全局安装 pnpm
npm install -g pnpm

# 2. 回到项目目录,导入 lock 文件
pnpm import

# 3. 删除旧的 lock 和 node_modules
Remove-Item package-lock.json
Remove-Item node_modules -Recurse -Force

# 4. 安装
pnpm install

然后检查一下构建是否正常:

pnpm run build

我遇到的唯一问题是有个包通过 postinstall 脚本下载二进制文件——pnpm 默认不允许你直接访问 node_modules/.pnpm 下的内容。解决方案是在 package.json 里加一个配置:

{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp"]
  }
}

这个配置告诉 pnpm:只有这些列出的包可以跑它们的 postinstall 脚本。比 npm 的 ignore-scripts=true 全部封杀更精细。

新项目也用 pnpm 吗?

现在所有新项目我都默认用 pnpm。不是因为它完美,而是因为 它是在”解决一个真实问题”的包管理器 。npm 解决的问题是”有没有依赖管理”,yarn 解决的问题是”能锁定版本”,pnpm 解决的问题是”我的硬盘装不下第二个项目了”。

三个工具解决的问题是递进的,各有各的时代背景。我并不是说 npm 不好——而是在小项目、单项目、SSD 还不那么贵的年代,npm 的扁平化方案确实够用。只是现在前端项目越来越重,依赖复用成了绕不开的问题。

我觉得看一个工具好不好,不是看它有多少 Star,而是看它解决了什么问题。pnpm 解决的这个问题——依赖重复占用空间、幻影依赖、不一致的 node_modules 结构——是每一个维护多个前端项目的人都会遇到的。它选了工程上最干净的方案(硬链接 + 严格隔离),开发体验也足够好。

毕竟,如果你的 node_modules 比我写的代码还大,也许该想想是不是哪里出了问题。

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS