很多人第一次用 pnpm,都会先感受到两个特点:
- 安装速度挺快
node_modules看起来和npm、Yarn不太一样
然后就会冒出一个问题:
pnpm 到底做了什么,为什么它既能省空间,又能保持依赖可用?
如果你去搜它的原理,常常会看到一堆词:
- 内容寻址存储
- 硬链接
- 软链接
- 严格依赖隔离
这些词本身没错,但一上来全堆出来,反而很容易越看越晕。
这篇文章我想用更通俗的方式,把 pnpm 的底层逻辑讲清楚。你不需要先懂文件系统,只要抓住一条主线:
pnpm 的核心目标,就是“同样的包尽量只存一份,同时又要让 Node.js 还能正常找到它”。
一、先说结论:pnpm 为什么和别的包管理器不一样?
一句话概括:
npm / Yarn 更像是把依赖“搬进你的项目”,pnpm 更像是“全局存一份,项目里用链接接过去”。
这就是区别的根源。
也正因为这样,pnpm 往往会有几个明显优势:
- 更省磁盘空间
- 多项目场景下复用率更高
- 安装通常更快
- 依赖关系更严格,不容易出现“幽灵依赖”
但它不是魔法,它背后主要依赖三件事:
- 内容寻址存储
- 硬链接
- 软链接
下面我们一个个拆开。
二、先理解问题:为什么传统 node_modules 会很浪费?
假设你电脑里有 3 个项目:
project-aproject-bproject-c
它们都安装了 lodash@4.17.21。
传统思路通常是:
每个项目自己的 node_modules 都放一份 lodash。
看起来没问题,但实际上这三份文件几乎一模一样。
于是就出现了浪费:
- 占用重复磁盘空间
- 装依赖时重复写入大量文件
- 项目越多,浪费越明显
pnpm 看到这个问题后的想法其实很朴素:
既然内容一样,为什么不只保存一份?
三、pnpm 的第一步:把包放进一个“总仓库”
pnpm 不会一上来就把每个包完整复制到项目里,而是先放进一个全局存储区,通常叫做 store。
你可以把它理解成一个总仓库:
- 相同内容的文件,全局尽量只存一份
- 各个项目需要时,再通过链接使用它
这个目录一般长这样:
~/.pnpm-store/
所以 pnpm 的思路不是:
项目 A 一份,项目 B 一份,项目 C 再来一份
而是:
先在总仓库里存好,再让不同项目复用。
四、什么叫“内容寻址存储”?
这个词听起来很硬,其实意思不复杂。
1. 传统方式:按名字找
传统存储更像这样:
~/.npm/package-name@1.0.0/
也就是根据:
- 包名
- 版本号
来决定它放在哪。
2. pnpm 的方式:按内容找
pnpm 更强调:
不是看你叫什么名字,而是看你内容是不是一样。
如果两个文件内容完全相同,那么它们没有必要存两份。
所以 pnpm 会根据文件内容的哈希值来定位文件,类似这样:
~/.pnpm-store/v3/files/00/abc123...
这就叫 内容寻址存储(Content-addressable Store)。
这样做的好处是:
- 相同内容只需要存一份
- 更容易复用
- 还能顺带做内容校验,确保文件没损坏
五、光有总仓库还不够,项目怎么用这些包?
这就轮到 硬链接 出场了。
1. 硬链接是什么?
你可以把硬链接理解成:
同一份文件内容,有多个“入口”。
它不是“复制出一个新文件”,而是让不同路径都指向同一份实际数据。
比如:
cp file.txt copy.txt
这是复制,结果是两份独立内容。
而:
ln file.txt link.txt
这是创建硬链接,结果看起来像两个文件,底层其实共享同一份数据。
所以核心区别就一句话:
- 复制:占两份空间
- 硬链接:看起来有两个入口,实际上只占一份空间
2. pnpm 怎么用硬链接?
pnpm 会把 store 里的文件,通过硬链接放到项目内部的 .pnpm 目录里。
大致可以理解成这样:
~/.pnpm-store/v3/files/...
↑
│ 硬链接
│
project/node_modules/.pnpm/package-a@1.0.0/node_modules/package-a/
这意味着:
- 项目里看起来“有这个包”
- 但底层数据并不是重新复制出来的
- 多个项目可以共享同一份文件内容
这也是 pnpm 省空间的关键原因之一。
六、那为什么还要软链接?
看到这里很多人会问:
既然 .pnpm 里已经有包了,为什么还要再搞一层软链接?
答案是:
因为 Node.js 找包的方式,默认就是从 node_modules/package-name 这个路径往下找。
也就是说,Node.js 并不会天然跑去认 .pnpm/package-a@1.0.0/... 这种内部结构。
所以 pnpm 需要再做一层“门面”,让 Node.js 看起来依然像在用熟悉的 node_modules。
1. 软链接是什么?
软链接可以理解成一个“快捷方式”。
它本身不是实际文件内容,只是指向另一个路径。
比如:
node_modules/package-a
这个位置看起来像真的有个包,但它实际上可能只是一个软链接,指向:
node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
2. pnpm 的完整链路
所以一个包在 pnpm 里,通常会形成这样的关系:
node_modules/package-a
↓ 软链接
node_modules/.pnpm/package-a@1.0.0/node_modules/package-a
↓ 硬链接
~/.pnpm-store/v3/files/...
把它翻译成人话就是:
- 最底层真实文件放在全局 store
- 项目内部
.pnpm目录通过硬链接复用这些文件 - 顶层
node_modules/package-a再通过软链接暴露给 Node.js 使用
这就是 pnpm 为什么经常会被说成是“两层链接结构”。
七、为什么必须是“两层链接”,不能只用一层?
这个问题很关键。
1. 第一层硬链接,解决的是“省空间”
.pnpm 到 store 之间使用硬链接,目的很明确:
- 复用已有文件
- 不重复占用空间
- 多个项目共享同一份内容
也就是说,硬链接主要解决的是 效率和存储成本。
2. 第二层软链接,解决的是“兼容 Node.js”
顶层 node_modules/package-a 到 .pnpm/.../package-a 之间使用软链接,目的则是:
- 保持 Node.js 模块解析兼容
- 让
require('package-a')/import 'package-a'正常工作
如果没有这层,Node.js 找包时会很别扭,很多现有工具链也可能不兼容。
3. 两层组合起来,还能顺手实现“依赖隔离”
这是 pnpm 很重要但经常被忽视的一点。
pnpm 不只是为了省空间,它还希望做到:
一个包只能访问它真正声明过的依赖。
比如 package-a 依赖了 package-b,那么在 pnpm 的结构下,package-a 会在属于自己的那层依赖目录里访问到 package-b。
这样做的结果是:
- 依赖关系更清晰
- 不容易误用没声明的包
- 能减少“幽灵依赖”
4. 用一句更准确的人话总结
如果用最通俗的话来说,你的理解方向是对的,但可以再修正得更准确一点:
- 软链接更像“地址”或“快捷方式”,它指向
.pnpm目录里的实际包位置,主要作用是让 Node.js 还能按熟悉的node_modules/package-name路径去找模块。 - 硬链接不是“文件内容本身”,更准确地说,它是同一份文件数据的另一个入口。
- 多个硬链接看起来像是多个文件路径,但底层其实共享同一份真实数据,所以不会重复占用同样的磁盘空间。
如果把这件事压缩成一句话,可以理解为:
软链接负责“让 Node.js 找得到”,硬链接负责“让多个位置共用同一份文件”。
八、什么是“幽灵依赖”?pnpm 为什么能减少它?
先说最短定义:
幽灵依赖(Phantom Dependency)就是:你没有在自己的 package.json 里声明某个包,但代码里却能直接用它。
这类问题最危险的地方在于,它常常不是“立刻报错”,而是:
- 在你电脑上能跑
- 换一台机器可能不能跑
- 升级依赖后突然报错
- CI 和本地结果不一致
所以它不是“方便”,而是一个隐藏得很深的坑。
1. 幽灵依赖是怎么出现的?
假设你的项目只安装了 package-a:
{
"dependencies": {
"package-a": "^1.0.0"
}
}
而 package-a 自己依赖了 package-b。
按道理讲,你的项目并没有直接安装 package-b,所以你不应该直接这样写:
import { something } from 'package-b'
但在一些传统的 node_modules 结构里,这段代码却可能“误打误撞”运行成功。
为什么?因为很多包管理器会把依赖尽量扁平化到顶层 node_modules。
比如可能会变成这样:
node_modules/
├── package-a/
├── package-b/
└── ...
虽然你的项目只声明了 package-a,但因为 package-b 被提升到了顶层,Node.js 依然能在 node_modules/package-b 这里找到它。
于是就出现了一个很迷惑的现象:
- 你没声明它
- 但它刚好在顶层
- Node.js 又确实能找到
- 所以代码表面上“没问题”
这就是幽灵依赖产生的根源。
2. 为什么这会成为问题?
因为这会让“代码实际能访问到的依赖”和“你明确声明的依赖”不是一回事。
表面上看,项目跑起来了;但实际上,它偷偷依赖了某个你没有显式安装的包。
这会带来几个后果:
- 新同事拉代码后环境不一定一致
- 升级
package-a时,package-b可能被移除或换版本 - 包管理器一变,原来“能跑”的代码就不跑了
- 你很难从
package.json一眼看出项目真实依赖了什么
3. pnpm 是怎么控制这个问题的?
pnpm 的思路不是把所有依赖都暴露到项目顶层,而是:
谁声明了谁,谁就只能在自己的依赖上下文里访问它。
还是上面的例子:
- 你的项目依赖
package-a package-a依赖package-b
在 pnpm 里,更接近下面这种结构:
node_modules/
├── package-a -> .pnpm/package-a@1.0.0/node_modules/package-a
└── .pnpm/
├── package-a@1.0.0/
│ └── node_modules/
│ ├── package-a/
│ └── package-b/
└── package-b@1.0.0/
└── node_modules/
└── package-b/
这时候最关键的一点是:
package-b 是放在 package-a 自己那层依赖环境里的,不是随便平铺在你的项目顶层入口上。
于是访问关系就变得很清楚:
package-a可以访问package-b- 你的应用代码只能直接访问你自己声明过的依赖
- 如果你没有声明
package-b,通常就不能理直气壮地直接import 'package-b'
这就是 pnpm 的“依赖隔离”。
4. pnpm 的依赖隔离,隔离的到底是什么?
它隔离的不是“文件不能共享”,而是:
不同层级的代码,只能访问属于自己那一层的依赖集合。
换句话说:
- 你的应用有自己的直接依赖
package-a也有它自己的直接依赖package-b也有它自己的直接依赖
pnpm 会尽量让这些依赖关系按声明组织起来,而不是全部摊平成一个大杂烩。
所以它实现的其实是:
让真实可访问的依赖,尽量等于声明过的依赖。
5. 为什么说 pnpm 是“更严格”?
因为它会更早暴露问题。
以前在 npm / Yarn 某些扁平化场景下,你可能写了不规范代码也能跑;换到 pnpm 后,代码很可能立刻就报找不到模块。
这听起来像“pnpm 更麻烦”,但本质上它只是帮你把原来被掩盖的问题提前暴露出来了。
从长期看,这反而是好事,因为:
- 依赖关系更清晰
- 团队协作更稳定
- CI 和本地更一致
- 升级依赖时更不容易踩隐藏坑
所以更准确地说,pnpm 不是靠某个单独功能“消灭”幽灵依赖,而是通过更严格的目录结构和依赖暴露规则,大幅减少了这类问题。
九、拿一个实际场景来理解
假设现在有两个项目都安装了 lodash@4.17.21。
传统思路
两个项目各自的 node_modules 都放一份 lodash。
结果就是:
- 磁盘里有两份几乎一样的文件
- 每个项目都要各自维护一份
pnpm 思路
pnpm 会更像这样:
全局 store
└── lodash 的真实文件(只存一份)
project-a/node_modules/.pnpm/.../lodash
└── 通过硬链接复用 store 内容
project-b/node_modules/.pnpm/.../lodash
└── 通过硬链接复用 store 内容
project-a/node_modules/lodash
└── 软链接到 .pnpm 里的 lodash
project-b/node_modules/lodash
└── 软链接到 .pnpm 里的 lodash
这样一来:
- store 里只有一份真实内容
- 两个项目都能正常使用
- Node.js 仍然能从熟悉的位置找到包
这就是 pnpm 这套设计最巧妙的地方。
十、那 pnpm 为什么通常也更快?
很多人一提 pnpm,会先想到“省空间”,但它的速度优势也很明显。
原因通常有这些:
- 已下载过的包可以直接复用
- 已存在的文件可以通过链接快速建立关系
- 不需要在多个项目里反复写入同样的大量文件
尤其在这些场景下,pnpm 的优势会更明显:
- 你本地有很多项目
- 频繁切分支重装依赖
- Monorepo 依赖规模大
- CI 想尽量减少安装成本
所以 pnpm 的快,并不只是“下载快”,更重要的是:
它减少了重复存储和重复写入。
十一、底层术语不用全背,记住这张脑图就够了
如果你已经看到有点晕了,可以直接记这个简化版:
1. 内容寻址存储
作用:让相同内容只存一份。
2. 硬链接
作用:让多个项目看起来都有这个文件,但底层共享同一份数据。
3. 软链接
作用:让 Node.js 还能按熟悉的 node_modules 路径找到包。
把这三个点串起来,就是:
全局 store 存真实内容
↓
.pnpm 通过硬链接复用内容
↓
node_modules 通过软链接把包暴露出来
如果你能记住这张图,pnpm 的底层思路基本就不会再混了。
十二、最后用一句人话总结 pnpm
如果要把 pnpm 的原理压缩成一句最容易记住的话,那就是:
pnpm 先把依赖统一存到全局,再通过硬链接复用文件,通过软链接兼容 Node.js 的查找方式。
所以它最终实现了三件事:
- 相同依赖尽量只存一份
- 项目里依然可以像平常一样
import/require - 依赖关系更严格,更不容易藏问题
这也是为什么越来越多中大型项目、Monorepo 项目会选择 pnpm。
它不只是“另一个 install 工具”,而是一套更偏工程化的依赖管理方案。