我第一次意识到node_modules是个吞磁盘怪兽,是在同时维护五个前端项目的时候。每个项目的依赖都差不多,React、Vue、Webpack、各种构建工具,但 npm 会分别往各自的node_modules里复制一份,光老项目的 node_modules 加起来就轻松破 G。后来我心一横换成 pnpm,第一次安装完就觉得不对劲——明明各个项目都有完整依赖,磁盘怎么只多占了几百 MB?跟踪下来才发现,关键在于 pnpm 硬链接。
这个包管理器用文件系统里一个相当古老、但之前几乎没有包管理器认真用过的特性,把看起来互相独立的项目串在了一起。硬链接既不是把依赖复制一遍,也不是纯粹放一个“快捷方式”,它让多个目录项指向磁盘上的同一份数据。今天我就把 pnpm 硬链接从底层原理到实际踩坑完整拆开,包括 store 目录结构、修改源码带来的连锁反应、跨文件系统的限制,以及它到底能帮你省多少空间。不管是刚接触 pnpm 的新人,还是已经被“修改 node_modules 导致另一个项目变了”折磨过的老手,这篇应该都能给你一些参考。
1. 从 node_modules 体积失控说起:pnpm 硬链接解决的原始痛点
1.1 扁平化安装:依赖能用了,磁盘却再也回不去了
用过 npm v2 的人可能记得,那个时代的node_modules是严格嵌套的:A 依赖 B,B 依赖 C,那目录就是node_modules/A/node_modules/B/node_modules/C。好处是依赖关系非常清晰,坏处是路径很长,而且同一个包被多个父依赖引用时,会在项目里出现多份物理拷贝,磁盘占用极其夸张。
npm v3 之后改成扁平化安装,所有依赖尽量提升到顶层node_modules下,这样大部分公共依赖可以被不同包共用。但扁平化有一个历史遗留问题:当两个包依赖同一个包的不同版本时,无法同时占用顶层同一个目录名,于是 npm 只能把一个版本提升到顶层,另一个版本塞进某个子依赖的node_modules里。举例来说,lib-a依赖left-pad@1,lib-b依赖left-pad@2,最后项目里很可能同时存在node_modules/left-pad(版本 2)和node_modules/lib-a/node_modules/left-pad(版本 1),两份是不同的文件,磁盘上确确实实存了两遍。
yarn classic 也是同样的扁平化思路。这种方案带来的另一个问题是“幽灵依赖”:你明明没有在package.json里声明某个依赖,但因为 npm 把它提升到了顶层,你的代码居然可以直接require它。这在单项目开发时可能没人管,一旦切换到多项目协作、微前端或者大型 monorepo,版本不一致导致的“灵异 bug”能让你排查到怀疑人生。
pnpm 的思路完全不一样。它不是让所有依赖挤在一个平层里,而是把每个包都放在自己的隔离目录中,同时通过全局 store 来避免重复保存文件内容。store 是核心,硬链接正是让 store 能同时服务多个项目的关键机制。
1.2 硬链接加全局 store:pnpm 给出的解法
pnpm 的完整安装过程大概是这样的:首次安装某个版本的依赖包时,它会先把包下载到一个全局存储目录(store),然后按内容寻址的方式把文件保存下来;之后在任何项目里安装同一个版本的包,不再重复下载和写入文件内容,而是建立硬链接,把项目里的文件“指向”store 里的那一份数据。
硬链接增加的不是数据,而是一个目录项。你可以在两个不同的目录里有两个文件名,但它们指向同一个 inode,也就是同一份真实文件内容。pnpm 的项目node_modules里那些依赖文件,本质上就是 store 文件的“另一个入口”。所以在多个项目里使用同一版本的依赖,磁盘上真正存储的文件只有一份,其他项目都只是额外的目录项。
用一个生活化类比:一张底片冲洗出来的照片。底片只有一张,但你可以晒出很多张照片放在不同相框里。你看到的每个相框里都有一张“完整照片”,但背后的负片是同一个。如果有一天你拿笔在照片上涂改,其他相框里的同底片照片也一样会被涂花——这就是硬链接最本质的特征,也是后面所有坑的根源。
2. 硬链接的底层原理:目录项、inode 与引用计数
2.1 文件名不等于文件本身
绝大多数人对“文件”的理解是“路径加文件名”,但这只是操作系统呈现给你的一个假象。在 Linux、macOS 这些类 Unix 系统里,一个文件真正的主体是 inode:它保存着文件的类型、权限、所有者、大小、数据块位置、时间戳等所有元信息。目录项则是一个“名字到 inode 编号”的映射。
平时我们创建一个文件,实际上是在某个目录里新增一个目录项,并把该目录项指向一个新的 inode。当你用ln命令创建硬链接时,并不是复制数据,而是为同一个 inode 再增加一个目录项:
$ echo "hello" > original.txt $ ln original.txt hardlink.txt $ ls -li original.txt hardlink.txt 123456 -rw-r--r-- 2 user group 5 Jan 1 00:00 original.txt 123456 -rw-r--r-- 2 user group 5 Jan 1 00:00 hardlink.txt注意看,两个文件的 inode 编号都是123456,而且权限位后面那个2就是硬链接计数。这说明现在有两个目录项同时指向同一个 inode。读original.txt和读hardlink.txt拿到的都是同一份数据;修改其中一个文件,另一个文件的内容也会跟着变,因为它们的 inode 是同一个。
inode 本身还带一个引用计数,当计数降为 0 时,系统才会真正释放数据块。所以删除original.txt之后,hardlink.txt依然能正常读取,数据没有丢,直到最后一个硬链接也被删除,磁盘空间才会真正回收。
2.2 硬链接、软链接、复制:包管理场景里的三方对比
pnpm 的魔法不只靠硬链接,它还大量使用了软链接(符号链接)。要理解为什么这么设计,需要先看清三者的差异。
| 对比维度 | 硬链接 | 软链接(符号链接) | 复制文件 |
|---|---|---|---|
| 磁盘数据占用 | 只有一份真实数据,多个链接共享同一 inode | 极小,只保存目标路径字符串 | 每份都是完整独立数据 |
| 跨文件系统 | 不支持 | 支持 | 支持 |
| 目标被删除后 | 仍然可以访问,inode 还在 | 变成断链,无法访问 | 完全不受影响 |
| 修改内容的影响 | 所有硬链接都能看到变化 | 目标不变则链接不变 | 各改各的,互不影响 |
| 主要适用场景 | 省磁盘、共享数据 | 路径定向、动态跳转 | 隔离、安全、便携 |
硬链接是真正“共享同一份数据”的机制,修改任一链接都会影响所有链接。软链接本质上是个“路径快捷方式”,本身不存数据,一旦它指向的目标被移动或删除,这个链接就废了。复制文件最安全,每个项目都有独立的一份,但代价是磁盘占用成倍增长。
pnpm 的组合策略是:文件内容层面用硬链接,目录结构层面用软链接。这样既保留了“文件像普通文件一样存在”的语义,又能让依赖的解析路径保持隔离和清晰。
2.3 为什么不是“所有东西都用软链接”
看到硬链接有这么多限制,有些朋友可能会想:pnpm 为什么不能全部用软链接?反正软链接占的空间也很小,不是更省吗?
理论上可以,实际上一旦依赖数量多起来,软链接方案会很难受。第一,软链接只保存路径字符串,依赖树里几千上万个包,每个包里的每个文件都要做一层路径解析,运行时的性能开销和构建工具的处理复杂度都会上升。第二,软链接对“目标是否存在”非常敏感,store 路径一换、包被清理,整个依赖树立刻变成一堆断链。第三,也是最重要的:大量工具链不会老老实实顺着软链接解析,比如一些打包器会把软链接解析成真实路径,于是你会看到同一个包在构建产物里出现两份,或者缓存 key 因为“真实路径不同”而失效。
硬链接没有这些问题,因为链接出来的文件在数据层面就是普通文件,Node.js 读取它时不需要做任何特殊处理,编辑器的 watch 和打包器的文件监听也只会认为“这就是一个普通的依赖文件”。代价是它不能跨文件系统,而且修改内容会波及所有共享该 inode 的路径,这正好引出后面要讲的坑。
3. 一个真实项目的 pnpm 目录解剖
3.1 store 里到底藏了什么
先用pnpm store path看一下全局 store 在哪儿:
$ pnpm store path /Users/me/Library/pnpm/store不同系统的默认路径不一样,但核心结构类似。store 内部按内容的哈希值分目录存放文件,典型布局是这样的:
store/v3/files/00/1b/2f... # 某个包内文件的内容寻址位置 store/v3/index/ # 索引和元数据pnpm 是按内容寻址存储的,也就是说文件在 store 里的文件名,是根据文件内容算出来的哈希值,而不是原来的package.json、index.js这样的名字。所以同一个文件,哪怕在不同包里出现,只要内容完全一样,在 store 里就只会保存一份。不过对大多数场景来说,同版本依赖的文件自然都一样,所以能共享的其实比想象中多。
这里有一个容易误解的点:store 不是缓存。npm 的缓存里压缩包只是暂存,安装时还要解压到项目里;pnpm 的 store 则是“活数据”,项目node_modules里的硬链接文件本质上指向的就是 store 里的这些文件本体。
3.2 项目里的 node_modules 长什么样
用 pnpm 安装一个同时依赖lodash和express的项目后,目录结构大致是这样的:
node_modules/ ├── .pnpm/ │ ├── lodash@4.17.21/ │ │ └── node_modules/ │ │ └── lodash/ │ │ ├── package.json │ │ ├── lodash.js │ │ └── ... │ ├── express@4.18.2/ │ │ └── node_modules/ │ │ └── express/ │ │ ├── package.json │ │ ├── index.js │ │ └── ... │ └── ... ├── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash └── express -> .pnpm/express@4.18.2/node_modules/express顶层node_modules下的lodash和express是软链接,指向.pnpm对应包的真实目录。而.pnpm/<pkg>@<version>/node_modules/<pkg>目录里的文件,才是硬链接到 store 的真实数据。
用stat可以验证硬链接信息:
$ stat -c '%i %h %n' project-a/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/package.json 123456 2 .../package.json $ stat -c '%i %h %n' ~/Library/pnpm/store/v3/files/xx/yy/..../package.json 123456 2 .../package.json两个文件的 inode 相同,硬链接数是 2,说明一个在 store,一个在项目依赖目录,但它们共享同一份数据。这正是 pnpm 省磁盘空间的物理基础。
3.3 跨项目复用:为什么第二个项目“几乎不涨磁盘”
我在本机做过一次对比例子:项目 A 安装一套依赖,项目 B 安装完全相同的依赖。如果都用 npm,两个项目各自的node_modules都是完整副本;如果都用 pnpm,第二次安装基本不会再复制文件内容,只是给 store 里的文件增加一些硬链接计数,同时新写一批目录和软链接。
所以 pnpm 的第二个项目装完之后,磁盘增长往往只有几十 MB 甚至几 MB。对比 npm 动不动就是几百 MB 的完整拷贝,差距非常直观。当然,这个“增长”里还包含pnpm-lock.yaml、包元数据、.pnpm结构等开销,但数据大头确实被硬链接抹掉了。
如果你在团队里看到有人用 pnpm 装完项目后,磁盘占用始终不涨,那不是错觉,就是硬链接在起作用。
4. 硬链接的暗面:改源码、删旧版本、打包发布时的连锁反应
4.1 最经典的坑:修改 node_modules 里的源码,store“母本”跟着变
硬链接带来的最大风险,就是你无意中修改了项目里的一个“文件”,但其实改的是 store 里全项目共享的那份数据。
这种事遇到一次就记住了。之前某同事为了调试,直接在项目 A 的node_modules/some-pkg/dist/index.js末尾加了一行console.log。他本意只是临时看看输出,几小时之后项目 B 的同名依赖也打出了同样的日志,整个团队一度以为是缓存错乱。实际原因很简单:项目 A 和项目 B 里那个包的所有文件,都硬链接到了 store 里的同一个 inode,你改的是 inode 里的数据。只要 store 没换,任何使用了这个包版本的项目都会读到修改后的内容。
更麻烦的是,常见的编辑器修改文件时会先写临时文件再替换原子重命名,这种情况下硬链接会“断掉”,不会污染 store;但某些工具会直接在原文件上原地写入,立刻波及所有链接。你很难预判别人会用什么方式修改文件,所以根治办法只有一个:永远不要直接改node_modules里的源码。
4.2 正确改依赖的做法:patch 机制
pnpm 从 v7 开始内置了依赖补丁功能。想改某个包时,不要碰node_modules,而是用pnpm patch把它展开到一个临时目录,改完再生成补丁文件:
# 展开某个包到临时目录 pnpm patch some-pkg@1.0.0 # 进入输出目录做修改,完成后执行: pnpm patch-commit /path/to/temp-dir执行patch-commit后,pnpm 会生成patches/some-pkg@1.0.0.patch文件,并自动在package.json里记录pnpm.patchedDependencies。之后任何人重新执行pnpm install,这个补丁都会被自动应用。整个过程不会去修改 store 里的原始文件,因为补丁是在安装期重新应用层。
如果你用的是旧版 pnpm 或者团队习惯不同,也可以用社区常见的patch-package,思路一样:修改源码、生成 patch、提交进仓库、安装时自动打补丁。关键原则是“修改必须经过补丁层落地”,让改动对所有人可复现,而不是在本地node_modules里随手一改。
4.3 store 清理与硬链接的“回收滞后”
很多人第一次用 pnpm 时还会发现一个问题:删掉一个项目的node_modules后,磁盘空间并没有马上释放。这不是故障,而是因为 store 仍然保存着所有文件,而项目侧的硬链接虽然删了,store 里的 inode 引用计数可能还没到 0。
如果确实需要回收空间,应该用pnpm store prune来清理那些已经没有任何项目引用的包。这个命令会扫描当前所有项目,找出 store 里不再被引用的文件并释放。
需要提醒的是,pnpm store prune在多个项目共享一个 store 的环境里要谨慎执行。如果某台 CI 机器上只有一个临时项目,执行 prune 后清掉的可能是其他跑在同一台机器上的项目正在用的依赖。我个人的习惯是:本地个人机器上可以定期 prune,团队共用的构建机尽量别主动清理,让 pnpm 自己管理引用计数就好。
5. 硬链接的局限:哪些环境下 pnpm 的优势会打折
5.1 跨文件系统和网络驱动器:硬链接无法建立
硬链接有一个硬性前提:多个目录项必须在同一文件系统内。如果你的 store 在一块盘上,项目在另一块盘上,或者项目目录位于网络驱动器、容器挂载卷里,硬链接很可能创建失败。
pnpm 在遇到这种情况时,会根据配置和实际环境决定是否退化成普通复制。最常见的结果就是磁盘节省效果消失,项目安装的大小和 npm 几乎没有区别。如果你发现自己明明用了 pnpm,但磁盘占用还是很高,先检查一下 store 和项目是不是同盘。
可以执行pnpm store path看 store 位置,再用df看项目目录所在分区是否一致。在 CI 环境里尤其要警惕:如果每次构建都用临时容器,而 store 目录和数据卷不在同一个挂载点,pnpm 的硬链接基本就是白搭。这种场景下,与其纠结硬链接,不如直接用配置关闭它:
pnpm config set package-import-method copypackage-import-method的可选值大体有auto、hardlink、copy、clone。默认是auto,在无法硬链接时会自动降级;如果你明确知道自己处在跨文件系统环境,直接设成copy可以避免各种隐晦的失败和警告。
5.2 版本冲突:硬链接也没办法把不同版本“合体”
硬链接只能让“内容相同”的文件共享存储。如果同一个包名有两个不同的版本,比如lodash@4和lodash@3,它们的源码内容不同,inode 必然不同,硬链接不可能把两个不同内容的数据合并成一份。
所以在同一个项目里同时存在多个不兼容版本时,pnpm 的 store 里依然会保存多份数据,项目里的.pnpm目录也会有多个版本对应的子目录。pnpm 的“省空间”主要体现为“跨项目复用相同版本”,而不是“把所有重复依赖都压成一份”。这也是很多人在 monorepo 或复杂依赖树里发现 pnpm 磁盘占用依然不小的重要原因。
另外,所谓“所有依赖只存一份”也只是一个理想描述。pnpm 通常以“包名+版本+平台”为粒度存储,不同 CPU 架构、不同操作系统对应的二进制包,在 store 里也是互相独立的。
5.3 工具链的“真实路径”问题
pnpm 目录里有大量软链接和硬链接,虽然大部分工具能正常工作,但总有一些边缘场景让人头疼。
一类是打包器和缓存机制。某些构建工具在解析依赖时,会试图获取文件的“真实路径”,于是node_modules/express被解析成store/v3/files/xx/yy/...。真实路径里包含 store 的哈希目录,不同项目甚至同一项目的缓存 key 就可能因为这些路径不同而失效,导致缓存命中率下降。这类问题在 Webpack 5 刚普及那两年比较常见,现在很多构建工具已经明确支持或兼容 pnpm,但仍然值得留意。
另一类是原生模块和并发场景。比如某个依赖包含node-gyp编译出来的.node二进制,它同样会被硬链接到 store。如果多个项目同时执行构建,或者后台进程对这些文件做了写入,因为底层共享数据,就可能在没预期的地方产生互相干扰。
还有 watch 类工具。项目 A 里修改了依赖源码,由于硬链接共享的是同一个 inode,项目 B 这种“看起来无关”的项目也可能触发重新编译。这不是工具判断错了,而是数据层面确实发生了变更,所以不要试图用“忽略目录”来规避,正确的做法还是回到补丁流程。
6. 实测数据:pnpm 硬链接到底省了多少,代价又是什么
6.1 同依赖多项目复用的磁盘收益
为了让大家有个更直观的感受,我描述一次粗略但真实的实验。测试环境是一台普通 Linux 笔记本,ext4 文件系统,Node.js 20,两个空项目都安装同一组依赖:express、webpack、lodash、vue等常见包。
先看总占用:
| 安装方式 | 项目 A 安装后节点体积 | 项目 B 安装后新增空间 | 两个项目合计占用 |
|---|---|---|---|
| npm | node_modules 约 810MB | 新增约 810MB | 约 1.62GB |
| pnpm(默认硬链接) | store 约 810MB,项目侧链接开销约 30MB | 新增约 30MB | 约 870MB |
这个数据不是绝对的,实际大小会随依赖版本和平台变化,但量级对比很明显:在项目数量增多时,pnpm 的磁盘优势会越来越大。三个项目、五个项目同时使用同一批依赖时,npm 是线性叠加,而 pnpm 基本只需要一份 store 再加上每次几十 MB 的链接开销。
当然,如果项目之间的依赖差异很大,比如一个项目装 Electron,另一个项目装机器学习框架,两者完全没有共享包,pnpm 节省的空间就很小。它的强项是“多项目共享同一套技术栈”这种场景,比如同时维护多个 React 或 Vue 项目。
6.2 install 时间:省空间不等于省时间
很多人误以为省了磁盘就一定能加快安装,其实不一定。pnpm 的安装过程要做比较多的额外工作:解析依赖图、创建大量软链接、校验 store 是否完整、再逐个建立硬链接。依赖里如果有成千上万个文件,光建立硬链接的系统调用开销就不小,在某些小项目上,pnpm 的第一次安装时间可能比 npm 直接下载解压还要慢。
真正快的场景是 store 热命中的情况下。如果项目 A 已经装过某个包的同一版本,项目 B 再安装时,几乎不需要重新下载和解压文件,只需要创建硬链接,安装速度会明显快于 npm 重新下载整个压缩包。所以“pnpm 快不快”不是一个绝对答案,要看你关注的是首次冷装、热装、还是多项目共享场景。
我在实际项目里感受到的变化是:pnpm 的优势更多体现在“安装之后”的日常开发里,比如磁盘占用稳定、依赖关系干净、执行脚本时不会因为幽灵依赖产生莫名的报错。安装耗时本身,并没有传说的那么神。
6.3 什么时候我应该关掉硬链接
尽管硬链接很强大,但下面这些场景下,我建议你直接把它关掉或换回复制:
- 项目位于网络驱动器、FUSE 挂载、容器跨卷挂载等不支持硬链接的文件系统中;
- 你经常需要临时修改
node_modules里的源码来调试,而且不想花时间建立 patch 流程; - 你在做一个一次性环境,比如临时跑个脚本、渲染个报表,之后整个目录直接删除,省磁盘的意义不大;
- 你的团队用了很多会实时解析真实路径的构建工具,且已经确认和 pnpm 的硬链接存在兼容问题。
关闭方式就是前面提到的package-import-method。如果文件系统支持 Reflink 之类的 CoW 克隆特性,也可以试试clone方式,它既能在一定程度上共享底层数据,又能避免修改波及原文件,算是个折中方案。
我个人现在的使用习惯是:常规开发项目保持默认硬链接;CI 容器和跨盘场景显式配置复制;一旦发现某个依赖需要改源码,立即走pnpm patch并生成补丁。这套流程跑了一年多,没有再出现过“同事的项目悄悄多了个 console.log”之类的怪事。硬链接是很底层的系统能力,pnpm 把它用到了包管理上,但正因为底层,理解它的边界,才能真的用好它。