前几年我帮朋友排查一个前端项目的构建问题,那个项目里有个老旧的依赖管理器,每次装包都要折腾半天。后来我把它的依赖安装方式换成了 pnpm,问题一下子就清亮了。今天就把这背后的原理,以及在实际生产中怎么把它的优势发挥出来,掰开揉碎讲一遍。

一、为什么我们需要依赖隔离

先从一个日常场景说起。你做项目要装 A 包,A 包内部又依赖 B 包的 1.0 版本。而你的项目里另一个 C 包,却依赖 B 包的 2.0 版本。两个版本的接口可能完全不同,如果只有一个 B 包存在,总有一个包会跑不起来。

这种问题太常见了。我在一个老项目里见过,同一个包有四五个版本散落在各处,安装完 node_modules 体积大到要用分钟来计算。更头疼的是,因为某个依赖被提升到了顶层,你的代码本来没有声明过它,却可以直接 require 到它。项目能跑,可一旦有人清理了一次依赖树,它就不见了,报错报得莫名其妙。

pnpm 的设计思路完全不同。它不搞拍平,也不搞无休止的嵌套,而是用一个全局的仓库来存放所有版本的包,再通过链接把包放到每个项目的 node_modules 里。这样,每个包都能用到自己声明的依赖,谁也管不着谁。这就是依赖隔离的核心价值。

二、pnpm 的存储与链接:它到底做了什么

要理解 pnpm,先得记住两个词:硬链接和符号链接。这两个概念在操作系统中很常见。硬链接相当于给同一个文件多起了一个名字,不管从哪个名字进去,文件内容只有一份。符号链接更像是一个快捷方式,双击快捷方式打开的是它指向的那个文件。

pnpm 把所有下载过的包都存进一个全局的 store 目录。同一份文件,在 store 里永远只有一份。安装项目时,pnpm 并不会把文件复制到项目的 node_modules,而是通过硬链接,让这些文件在项目里也能被访问到。这样做的好处是,磁盘占用低,安装速度快,因为大多时候只是在创建链接,不是传输文件。

而 node_modules 内部的依赖关系,则用符号链接来组织。pnpm 会在项目的 node_modules 下创建一个 .pnpm 目录,里面按照“包名@版本号”的子目录存放着各个包的真实内容,再用符号链接把项目的直接依赖映射到对应目录。

拿到一个 pnpm 项目,打开 node_modules 看,会发现里面几乎全是链接文件。只有进入 .pnpm 那个目录,才能看到真正的物理文件。这种设计,让每个包都拥有一个独立的“小房间”。哪怕两个包都依赖了同一个底层库的不同版本,它们也能各自访问自己版本,谁也不会影响谁。

为了把这个机制讲得更透,我拿一个可以和 pnpm 对照的传统工具说。npm 在早期版本中,会把每个包依赖的子包直接嵌套进该包的 node_modules 里,形成一个很深的树。后来为了性能,npm 又搞了扁平化安装,把所有能提升的包都放到顶层 node_modules。这个做法虽然缩短了依赖查找路径,却引入了“幽灵依赖”。而 pnpm 通过“全局 store + 硬链接 + 符号链接”的组合,既保留了嵌套方案中的隔离性,又避免了磁盘空间浪费,这就是它的高明之处。

三、一个从零开始的项目示例

现在开始动手。下面的示例统一使用 Node.js 和 npm 生态,所有代码都基于这个技术栈。我们建一个临时项目,一步一步看 pnpm 的表现。


# 先创建项目目录并进入
mkdir pnpm-demo
cd pnpm-demo

# 用 pnpm 初始化 package.json
pnpm init

如果你还没有安装 pnpm,可以先用 Node 自带的工具装一下:


# 全局安装 pnpm
npm install -g pnpm

初始化之后,package.json 的内容大概是这样的:


{
  "name": "pnpm-demo",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

接下来,我们安装一个真实的包:is-odd。这个包很小很有趣,它自己依赖了另一个包 is-number,非常适合用来演示依赖隔离。


# 安装 is-odd,它会顺带把 is-number 拉进来,但 is-number 不会出现在顶层
pnpm add is-odd@3.0.1

安装完成后,看看顶层 node_modules 里有什么:


# 查看目录入口详情
ls -la node_modules

你会发现,node_modules 里只有少量符号链接,根本没有 is-number。这说明,作为传递依赖的 is-number 没有被提升到顶层,它被老老实实地关进了 .pnpm 的小隔间里。

现在我们写一个脚本,故意去 require 那个未声明的 is-number,看看会发生什么:


// try-require.js
// 尝试加载一个我们从未声明过的包,看看会发生什么
try {
  // 在 pnpm 的项目里,这行代码会抛出 MODULE_NOT_FOUND
  require('is-number');
  console.log('很意外,居然加载成功了');
} catch (err) {
  console.log('加载失败,这是预期结果:', err.code);
}

运行这段脚本:


# 执行 Node 脚本
node try-require.js

输出会显示“加载失败”,错误码是 MODULE_NOT_FOUND。这就直接证明了 pnpm 的隔离:你没有声明过的包,哪怕存在磁盘上,也不会让你乱用。

再深入一步,我们看看 is-odd 这个包自己声明了什么:


// inspect-pkg.js
// 读取 is-odd 的 package.json,看看它自己声明了哪些依赖
const path = require('path');
const fs = require('fs');

// 通过 require.resolve 找到 is-odd 的 package.json 路径
const pkgJsonPath = require.resolve('is-odd/package.json');
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));

// 打印包的基本信息和声明依赖
console.log('包名:', pkg.name);
console.log('版本:', pkg.version);
console.log('这个包声明的依赖:', Object.keys(pkg.dependencies || {}).join(', '));

运行:


# 查看 is-odd 声明的依赖
node inspect-pkg.js

它打印出的依赖列表里会出现 is-number。但那个 is-number 只存在于 is-odd 自己的隔离环境里,项目根目录的代码没法直接访问。这就是 pnpm 和扁平化 node_modules 最直观的区别。

四、生产场景中的落地要点

原理看懂了,关键是生产中怎么用好它。下面这些点,是我踩过坑后总结出来的经验。

4.1 不要把 node_modules 手动“拍平”

有人会觉得 pnpm 的符号链接看起来不“直观”,想手动用软链接把它们变成 npm 那样的平面结构。千万别这么做。一旦手动干预或使用某些脚本去链接 node_modules,pnpm 的硬链接和符号链接就会混乱,很可能导致依赖丢失。你要相信它的隔离设计,不要用旧思维去“修正”它。

4.2 使用 .npmrc 统一配置

生产环境建议在项目根目录放一个 .npmrc 文件,把关键配置写死。比如:


# 使用 pnpm 内置的 store 地址,方便多项目共享
store-dir=~/.pnpm-store

# 开启严格模式,避免意外访问到未声明的包
strict-peer-dependencies=true

. npmrc 是 ini 格式,分号后面是注释。配置项的命名需要符合 pnpm 的规范,别记错。

4.3 处理 peerDependencies

pnpm 对 peerDependencies 的处理比 npm 严格很多。比如一个插件要配合 React 才会工作,但你又没有在项目里显式安装 React,pnpm 就不会像 npm 那样“装看不见”。遇到这种情况,你要么显式安装 React,要么在 .npmrc 里设置 auto-install-peers=true。pnpm 默认会开启自动安装 peer 依赖,但如果你在 CI 里覆写了配置,就可能出问题。

4.4 清理全局 store

pnpm 的 store 会越来越大,因为它会永久保存所有下载过的包。在硬盘紧张的机器上,建议定期执行:


# 清理当前项目中没有被引用的孤儿包
pnpm store prune

这个命令不会删除还在使用的包,它按照引用计数来清理。如果你有多个项目共用一个 store,某个包仍然被其他项目引用,是不会被删掉的,可以放心用。

4.5 配合 Monorepo 架构

pnpm 最大的舞台是 monorepo。它内置了对 workspace 的支持,可以让多个子包共享同一个依赖环境,同时每个子包又能保持自己的依赖隔离。比如在 packages 目录下放多个独立发布的服务,根目录运行 pnpm install -r 就能一次装完。子包之间如果需要互相引用,可以通过 workspace 协议来声明版本,非常方便。

4.6 小心安装脚本的安全性

pnpm 默认允许包运行自带的安装脚本,例如某些需要原生编译的包。但也正因如此,恶意依赖可以在安装时搞破坏。在生产环境,建议在 .npmrc 里设置:


# 只允许白名单内的包运行安装脚本
enable-pre-post-scripts=false

如果你确实需要让某些包执行脚本,可以再用 pnpm approve-builds 命令手动批准。安全永远比方便重要。

五、应用场景与优缺点

5.1 最适合的使用场景

第一类是大型业务项目,依赖几百上千个包,用 pnpm 能明显缩短安装时间,减小磁盘占用。第二类是 monorepo 仓库,多包之间共享依赖,但互相隔离,这几乎是 pnpm 的主场。第三类是团队需要强制规范依赖声明的场景,pnpm 能帮你揪出那些平时从来没有显式声明、却一直靠着“幽灵依赖”跑通的代码。

5.2 优点

它的优点很突出。磁盘占用低,因为一个包的每个文件只有一份硬链接。安装速度快,因为大多数时候只要建立链接而不是复制文件。依赖隔离彻底,每个包只能访问自己的显式依赖,彻底摆脱了“幽灵依赖”的困扰。另外,它完全兼容 npm 的 registry 和 lockfile 格式,迁移成本极低,很多老项目换到 pnpm 只需要重新安装一次依赖。

5.3 缺点和坑

当然,pnpm 也不是没有毛病。它创建的符号链接会让一些工具扫描 node_modules 时逻辑变复杂,比如有些老旧的打包器或者文件监听工具需要额外配置才能正确处理。还有,某些很老的项目会线下修改 node_modules,这在 pnpm 下完全行不通。另外,因为 pnpm 很严格,团队里如果有人习惯了 npm 的“迷糊玩法”,可能需要一段适应期。

六、注意事项

在实际落地时,有几个地方一定要盯紧。

第一个是 lockfile。pnpm 使用 pnpm-lock.yaml 文件锁定完整依赖版本。这个文件要提交到代码仓库,并且绝不要在本地随意改动。升级某个包时,使用 pnpm update 代替手动改版本号,这样才能保证整个团队的依赖树一致。

第二个是 CI 环境。在流水线里安装依赖时,最好给 pnpm 设置明确的 store 路径,防止多台机器共用缓存时出现权限问题。更关键的是,CI 里要加上锁文件检查。可以用下面这个典型配置:


# .github/workflows/ci.yml 中的关键片段
- uses: pnpm/action-setup@v2
  with:
    version: 8.x
- run: pnpm install --frozen-lockfile

这里 --frozen-lockfile 参数非常关键。它要求 pnpm 必须完全按照 lockfile 安装,一旦发现 package.json 与 lockfile 不一致,就直接失败。这道“紧箍咒”能拦住很多临时偷偷改版本的行为。

第三个是包管理器的统一。项目里一定要写清楚使用 pnpm,禁止混用 npm 或 yarn。可以在 package.json 里加一个声明,再配合 corepack 自动切换:


{
  "packageManager": "pnpm@8.15.0"
}

这样每个开发者打开项目,corepack 都会自动使用对应的 pnpm 版本,从源头上避免“为什么我本地跑得好好的,服务器上就报错”这类悲剧。

第四个是清理 hook。如果项目里有 postinstall 脚本,注意 pnpm 版本的差异。pnpm 的高版本默认不会运行依赖自身的生命周期脚本,除非在白名单里允许。这能提升安全性,但也会导致某些老包安装后无法正常工作。遇到这种情况,请先查 pnpm 的文档,而不是直接关闭安全策略。

第五个是 Windows 环境。pnpm 依赖符号链接,Windows 下的权限设置偶尔会制造麻烦。如果你在团队里统一使用 Windows,记得先确认以管理员身份运行终端,或者在 .npmrc 里打开相关兼容选项。否则可能出现“明明装好了,但就是找不到文件”的怪现象。

七、总结

pnpm 的依赖隔离不是玄学,它就是通过一个全局 store 存放内容,再用硬链接和符号链接把正确的版本塞到你项目的 node_modules 里。每个包都被关在单独的小隔间里,不越界,也不互相打扰。生产环境里,我们既要享受它带来的速度快、占空间小、依赖干净等好处,也要记得用 lockfile、安全策略、store 清理这些工具来稳住底盘。

对我个人而言,pnpm 是接手新项目时最先换装的工具。它让你不再为了“到底哪个包被提升到顶层了”而烦恼。你可以把时间留给业务逻辑,而不是和 node_modules 的结构打架。希望这篇朴素的分享,能帮你少走几步弯路,也让你的项目跑得更稳更痛快。