5月28日 01:04

pnpm 的 node_modules 结构是怎样的?为什么这样设计?

核心答案

pnpm 的 node_modules 采用符号链接 + 硬链接的混合结构,由三层组成:

  1. node_modules/[package] — 符号链接层,只暴露直接依赖
  2. node_modules/.pnpm/[package@version]/node_modules/ — 实际包内容,硬链接到全局 store
  3. 全局 store(~/.local/share/pnpm-store) — 内容寻址存储,所有项目共享

这样设计的核心目标是:消灭幽灵依赖 + 节省磁盘空间 + 保持 Node.js 模块解析兼容

目录结构详解

shell
node_modules/ ├── .pnpm/ # 实际内容存放区 │ ├── lodash@4.17.21/ │ │ └── node_modules/ │ │ └── lodash/ # 硬链接 → 全局 store │ ├── express@4.18.2/ │ │ └── node_modules/ │ │ ├── express/ # 硬链接 → 全局 store │ │ ├── body-parser/ # express 的依赖(符号链接) │ │ └── ... │ └── .modules.yaml # pnpm 元数据 ├── lodash → .pnpm/lodash@4.17.21/node_modules/lodash # 符号链接 └── express → .pnpm/express@4.18.2/node_modules/express # 符号链接

关键点:你的代码 require('lodash') 时,Node.js 解析路径是 node_modules/lodash,它是一个符号链接,最终指向 .pnpm 中的真实包。而 .pnpm/lodash@4.17.21/node_modules/lodash/ 下的每个文件,都是硬链接到全局 store 的——不占额外磁盘空间。

三层结构各自的职责

符号链接层:严格隔离依赖

shell
node_modules/ ├── lodash → .pnpm/lodash@4.17.21/node_modules/lodash └── express → .pnpm/express@4.18.2/node_modules/express

这一层只包含 package.json 中声明的直接依赖。你声明了什么,就只能访问什么。

javascript
// package.json 只声明了 express // npm/yarn 下 body-parser 被 hoist 到顶层,可以直接访问 const bodyParser = require('body-parser') // npm: ✅ 能用 pnpm: ❌ 找不到 // 这就是"幽灵依赖"——你用了没声明的东西,某天它被移除就挂了

.pnpm 层:依赖的真实组织

.pnpm 目录按 包名@版本 平铺,每个包下有自己的 node_modules,包含该包的所有依赖(也是符号链接)。这样每个包只能看到自己声明的依赖,形成严格的依赖图

shell
.pnpm/ ├── express@4.18.2/ │ └── node_modules/ │ ├── express/ # 包自身内容(硬链接) │ ├── body-parser/ # → .pnpm/body-parser@1.20.2/... (符号链接) │ └── cookie/ # → .pnpm/cookie@0.5.0/... (符号链接) └── body-parser@1.20.2/ └── node_modules/ ├── body-parser/ └── raw-body/ # → .pnpm/raw-body@2.5.1/...

每个包的依赖都在自己的 node_modules 下,不会泄漏到外部。

全局 store:内容寻址去重

bash
# 查看 store 位置 pnpm store path # ~/.local/share/pnpm-store/v3 # 同一台机器上 10 个项目都用 lodash@4.17.21 # 磁盘上只存一份,每个项目的 node_modules 里的文件是硬链接 ls -i node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js # inode 号相同 → 同一份磁盘数据

硬链接意味着:同一台机器上,无论多少个项目安装同一个包的同一个版本,磁盘上只有一份数据。删除某个项目不会影响 store 中的文件(硬链接引用计数 > 0 就不删除)。

为什么这样设计:解决 npm/yarn 的三大痛点

痛点一:幽灵依赖

npm v3 之前用嵌套结构,依赖层级太深(Windows 路径 260 字符限制)。npm v3 改成扁平化,所有依赖被 hoist 到 node_modules 根目录:

shell
# npm 的扁平结构 node_modules/ ├── express/ ├── body-parser/ ← 你没声明但能访问 ├── raw-body/ ← 你没声明但能访问 ├── cookie/ ← 你没声明但能访问 └── ...

pnpm 的符号链接层只暴露直接依赖,从结构上杜绝了幽灵依赖。

痛点二:磁盘浪费

npm/yarn 每个项目的 node_modules 都是完整拷贝。10 个项目用 React,磁盘上存 10 份。

pnpm 通过硬链接共享全局 store,同一版本只存一份。实际效果:

bash
# 用 npm 安装一个新项目 du -sh node_modules # 200MB # 用 pnpm 安装(已有其他项目装过相同依赖) du -sh node_modules # 目录大小仍显示 200MB(ls -l 看大小) # 但实际磁盘占用接近 0(硬链接不占额外空间) du -sh ~/.local/share/pnpm-store # store 总大小可能也才 200MB

痛点三:依赖分身

npm 的扁平化可能导致同一个包的多个版本都被 hoist,只有一个能到顶层,其他版本仍嵌套。项目可能同时依赖一个包的两个版本而不自知,产生难以排查的 bug。

pnpm 的 .pnpm/name@version 结构天然支持多版本共存,每个版本独立存储、独立链接。

peer dependencies 的处理

pnpm 对 peer dependencies 的处理比 npm 更严格。如果包 A 声明了 peer dependency React,但项目中没有安装,pnpm 会报错而非静默跳过:

bash
ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependencies . └─┬ foo@1.0.0 └── ✕ missing peer react@>=16.0.0

.pnpm 结构中,peer dependencies 会被正确地提升到依赖它的包可见的层级,而不是像 npm 那样依赖 hoisting 的不确定性。

追问:硬链接和符号链接的区别是什么?

硬链接:指向文件的 inode,和原文件共享同一份数据块。删除原文件不影响硬链接,反之亦然。pnpm 用它连接 store 和各项目的 node_modules,实现零拷贝共享。

符号链接(软链接):指向文件路径,类似快捷方式。pnpm 用它构建依赖关系图:node_modules/lodash → .pnpm/lodash@4.17.21/node_modules/lodash,让 Node.js 的模块解析逻辑正常工作。

两者配合:符号链接解决依赖可见性,硬链接解决磁盘空间。

标签:PNPM