资讯详情

pnpm硬链接原理实战:从node_modules磁盘占用到store共享机制

📅 2026/10/21 15:23:02 | 华诺云谱 👁 阅读
pnpm硬链接原理实战:从node_modules磁盘占用到store共享机制
我第一次意识到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 依赖 BB 依赖 C那目录就是node_modules/A/node_modules/B/node_modules/C。好处是依赖关系非常清晰坏处是路径很长而且同一个包被多个父依赖引用时会在项目里出现多份物理拷贝磁盘占用极其夸张。npm v3 之后改成扁平化安装所有依赖尽量提升到顶层node_modules下这样大部分公共依赖可以被不同包共用。但扁平化有一个历史遗留问题当两个包依赖同一个包的不同版本时无法同时占用顶层同一个目录名于是 npm 只能把一个版本提升到顶层另一个版本塞进某个子依赖的node_modules里。举例来说lib-a依赖left-pad1lib-b依赖left-pad2最后项目里很可能同时存在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 硬链接加全局 storepnpm 给出的解法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/ │ ├── lodash4.17.21/ │ │ └── node_modules/ │ │ └── lodash/ │ │ ├── package.json │ │ ├── lodash.js │ │ └── ... │ ├── express4.18.2/ │ │ └── node_modules/ │ │ └── express/ │ │ ├── package.json │ │ ├── index.js │ │ └── ... │ └── ... ├── lodash - .pnpm/lodash4.17.21/node_modules/lodash └── express - .pnpm/express4.18.2/node_modules/express顶层node_modules下的lodash和express是软链接指向.pnpm对应包的真实目录。而.pnpm/pkgversion/node_modules/pkg目录里的文件才是硬链接到 store 的真实数据。用stat可以验证硬链接信息$ stat -c %i %h %n project-a/node_modules/.pnpm/lodash4.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-pkg1.0.0 # 进入输出目录做修改完成后执行 pnpm patch-commit /path/to/temp-dir执行patch-commit后pnpm 会生成patches/some-pkg1.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 版本冲突硬链接也没办法把不同版本“合体”硬链接只能让“内容相同”的文件共享存储。如果同一个包名有两个不同的版本比如lodash4和lodash3它们的源码内容不同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 安装后新增空间两个项目合计占用npmnode_modules 约 810MB新增约 810MB约 1.62GBpnpm默认硬链接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 把它用到了包管理上但正因为底层理解它的边界才能真的用好它。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑