跳到主要内容

pnpm 底层到底做了什么?一篇讲清内容寻址、硬链接和软链接

9 分钟阅读

很多人第一次用 pnpm,都会先感受到两个特点:

  • 安装速度挺快
  • node_modules 看起来和 npmYarn 不太一样

然后就会冒出一个问题:

pnpm 到底做了什么,为什么它既能省空间,又能保持依赖可用?

如果你去搜它的原理,常常会看到一堆词:

  • 内容寻址存储
  • 硬链接
  • 软链接
  • 严格依赖隔离

这些词本身没错,但一上来全堆出来,反而很容易越看越晕。

这篇文章我想用更通俗的方式,把 pnpm 的底层逻辑讲清楚。你不需要先懂文件系统,只要抓住一条主线:

pnpm 的核心目标,就是“同样的包尽量只存一份,同时又要让 Node.js 还能正常找到它”。


一、先说结论:pnpm 为什么和别的包管理器不一样?

一句话概括:

npm / Yarn 更像是把依赖“搬进你的项目”,pnpm 更像是“全局存一份,项目里用链接接过去”。

这就是区别的根源。

也正因为这样,pnpm 往往会有几个明显优势:

  • 更省磁盘空间
  • 多项目场景下复用率更高
  • 安装通常更快
  • 依赖关系更严格,不容易出现“幽灵依赖”

但它不是魔法,它背后主要依赖三件事:

  1. 内容寻址存储
  2. 硬链接
  3. 软链接

下面我们一个个拆开。


二、先理解问题:为什么传统 node_modules 会很浪费?

假设你电脑里有 3 个项目:

  • project-a
  • project-b
  • project-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 工具”,而是一套更偏工程化的依赖管理方案。

Loading Comments...