Content Addressable Storage of pnpm
pnpm (performant npm)
是一款面向 node.js
的包管理器,其设计目标是 提升安装速度 和 磁盘空间利用效率,相较于 npm
和 yarn classic
等替代方案具有显著优势。pnpm
的核心特性包括:
- 通过链接(
hardlink
或reflink
)从全局存储库引用文件实现高效率的安装速度。 - 通过软链(
symbolic link
)以及结构化目录设计来强制执行严格的依赖访问规则。 - 内置对单一代码库(
monorepo
)管理的支持。 - 通过
pnpm-lock.yaml
文件确保依赖安装的确定性。
pnpm
的基石是 内容寻址存储 机制。与传统包管理器在每个项目中复制依赖文件不同的是 pnpm
会根据文件内容哈希值(content hash
)将文件存储在全局(通常是每个磁盘或文件系统一个)的存储库中。这意味着,如果多个不同的包或同一包的不同版本包含了完全相同的文件,那么在物理磁盘上,该文件仅会存储一份。这是 pnpm
实现显著磁盘空间节省的关键所在。
内容寻址存储 (CAS) 架构
内容寻址原理
内容寻址存储 的 核心思想 是:文件以其 内容哈希值 为唯一标识符进行 存储 和 检索。当 pnpm
需要存储一个文件时,他会计算该文件的 内容哈希值。如果全局存储库([[pnpm store path]]/files/
)中已存在具有相同哈希值的文件,pnpm
就知道这个文件内容没有变化,无需重复存储。因此,即使 100
个项目都依赖 lodash
,或者依赖了 lodash
的不同版本,其中相同的文件在 CAS
中也只存在一份物理副本。这与早期 npm
或 yarn
的 classic
版本中为每个项目的 node_modules
目录下创建依赖副本的做法形成了鲜明对比。
CAS 的优势
- 磁盘空间效率:
CAS
显著减少了磁盘空间占用,尤其是在拥有大量项目或依赖项体积庞大的场景下,可以节省数GB
的空间。更重要的是,当更新依赖时,pnpm
只需向存储库添加版本间 实际发生变化的新文件,而不是复制整个新版本的包。 - 安装速度: 由于只需下载存储库中缺失的文件,并主要通过链接(而非复制)文件到项目中,大大减少了 网络传输 和 磁盘
I/O
操作,从而显著提升了安装速度。此外,pnpm
对每个包的安装过程可以 并行处理,进一步提高了效率。
存储位置 (storeDir
)
pnpm
存储库的默认位置取决于操作系统和环境变量的设置。可以通过在项目根目录的 .npmrc
文件、工作区配置文件 pnpm-workspace.yaml
或全局配置文件中设置 storeDir
选项来自定义存储库路径。
默认 storeDir
位置
操作系统 | 环境变量条件 | 默认路径 |
---|---|---|
Linux | $PNPM_HOME 已设置 | $PNPM_HOME/store |
Linux | $XDG_DATA_HOME 已设置 | $XDG_DATA_HOME/pnpm/store |
Linux | 其他情况 | ~/.local/share/pnpm/store |
macOS | $PNPM_HOME 已设置 | $PNPM_HOME/store |
macOS | 其他情况 | ~/Library/pnpm/store |
Windows | $PNPM_HOME 已设置 | $PNPM_HOME/store |
Windows | 其他情况 | ~/AppData/Local/pnpm/store |
可以通过 pnpm store path
命令查看当前 pnpm
的全局存储库路径。
WARNING
一个至关重要的前提是,为了使 pnpm
能够使用硬链接(hard links
)或引用链接(reflinks
,也称写时复制链接)来链接文件,存储库必须与进行安装的项目位于同一文件系统或驱动器上。如果 storeDir
指向了不同的驱动器,pnpm
将无法创建这些高效的链接,而会回退到文件复制的方式。这虽然能保证安装成功,但会完全失去 pnpm
在 磁盘空间 和 安装速度 上的核心优势。文件系统的限制是硬链接(通常也包括引用链接)无法跨越分区或驱动器边界。pnpm
会智能地检测这种情况,并在链接不可行时优先保证正确性(通过复制)而非效率。
因此,物理磁盘布局直接影响 pnpm
的性能表现。如果用户没有显式设置 storeDir
,并且在不同的驱动器上运行 pnpm install
,pnpm
会在每个驱动器上自动创建一个独立的存储库(例如,在驱动器 D:
的根目录下创建 D:\\.pnpm-store
)。
pnpm-lock.yaml
的作用
pnpm-lock.yaml
文件扮演着至关重要的角色。他精确记录了项目解析后的依赖树结构,包括每个依赖项的确切版本及其自身的依赖关系,从而保证了每次安装都能生成完全相同的 node_modules
结构,实现了确定性安装。此文件还存储了从包注册表(registry
)获取的每个依赖包的 integrity
哈希值(通常是 SHA-512
)。这个哈希值是该包特定版本内容的唯一 指纹。
缓存查找的钥匙: 是
pnpm
在全局内容可寻址存储中查找是否已存在该精确内容包的主要依据。pnpm
会尝试定位到由这个integrity
标识的缓存文件。缓存未命中时(已下载过的包)的校验标准: 如果缓存中没有找到对应的包(缓存未命中),
pnpm
就需要下载。下载完成后,pnpm-lock.yaml
中的这个integrity
值就成为必须达到的目标哈希值,pnpm
会对下载的包(tarball
文件)自行计算一遍包的integrity
值,并将其与pnpm-lock.yaml
中的integrity
值进行比较,如果一致,则认为下载的包是完整且未经篡改的。缓存未命中时(未下载过的包): 如果缓存中没有找到对应的包(缓存未命中),
pnpm
就需要下载。下载完成后,pnpm
自行会计算下载的包(tarball
文件)的integrity
值,并将其与注册表中(默认http://registry.npmjs.org/
)记录的integrity
值进行比较,如果一致,则认为下载的包是完整且未经篡改的,将其记录到pnpm-lock.yaml
中。
PS
- Tarball 完整性: 在下载过程中或下载完成后,
pnpm
会计算下载到的tarball
文件的哈希值,并将其与pnpm-lock.yaml
或注册表元数据中记录的预期integrity
哈希值进行比较。如果不匹配,则表示下载的文件可能已损坏或被篡改,pnpm
会报错并中止安装。值得注意的是,出于安全考虑,npm
生态系统已从早期使用的不安全的SHA-1
哈希转向了加密强度更高的SHA-512
哈希。 - 存储库完整性 (
verifyStoreIntegrity
):pnpm
提供了一个配置项verifyStoreIntegrity
,其默认值为true
。当此选项启用时,如果pnpm
发现CAS
中某个文件自上次写入后被修改过,他会在将该文件链接到项目node_modules
之前对其内容进行校验。这为防止存储库意外损坏提供了一层额外的保障,但也会带来微小的性能开销。 - 锁文件完整性 (潜在问题): 尽管
pnpm-lock.yaml
保证了安装的确定性,但它本身也可能在版本控制操作(如git
合并冲突)中被意外破坏或手动错误编辑。目前,如果锁文件与package.json
的依赖声明一致,pnpm
会倾向于信任锁文件的内容以获得更快的安装速度。然而,社区 讨论 中已经提出了增加锁文件校验和(如lockfileChecksum
字段)的可能性,以便在不进行完整依赖重新解析的情况下,快速检测锁文件是否被篡改。
这种设计体现了在 安装速度 和 健壮性 之间的权衡。pnpm
默认优先考虑速度,通过信任最新的锁文件来跳过耗时的 依赖解析 和 验证 步骤。然而,锁文件合并冲突或手动编辑可能导致静默的错误。verifyStoreIntegrity
设置和 提议中的锁文件校验和 机制,都是为了在不过度牺牲性能的前提下增加安全网,这反映了优化包管理器时固有的设计挑战。
文件哈希、索引与 CAS 内存储
单个文件的内容哈希
当一个包的 tarball
被成功下载并校验通过后,pnpm
会对其内容进行处理。一个关键步骤是,pnpm
会为包内的 每一个单独文件 计算其内容哈希值(例如 SHA-512
)。
这里需要清晰地区分两种哈希值:包完整性哈希 (package integrity hash
) 和 文件内容哈希 (file content hash
)。
包完整性哈希(记录在
pnpm-lock.yaml
中)用于唯一标识和验证整个包的特定版本(即tarball
文件本身)。下述是
compressible
包的pnpm-lock.yaml
文件中的相关内容:yamllockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: .: dependencies: compressible: specifier: 2.0.18 version: 2.0.18 packages: compressible@2.0.18: resolution: { integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== } engines: { node: '>= 0.6' } mime-db@1.54.0: resolution: { integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== } engines: { node: '>= 0.6' } snapshots: compressible@2.0.18: dependencies: mime-db: 1.54.0 mime-db@1.54.0: {}
文件内容哈希是用于标识包内每个独立文件的具体内容,是实现
CAS
存储和文件去重的基础,pnpm
会存储包中的每一个文件并按照文件内容哈希值进行存储(storeDir/v10/files/
)。下述是
compressible
包在pnpm
全局存储的索引文件,里面会包含包中所有的文件的integrity
值,这些都是pnpm
在下载包时计算得出的。json{ "name": "compressible", "version": "2.0.18", "requiresBuild": false, "files": { "LICENSE": { "checkedAt": 1745925782020, "integrity": "sha512-iwWNtxw0wslfJ9F2n8JXm5zpCbwR7Gu+rFrXsuO44MbiUu1TZov30H2eWdbt7+mvwcEp0nSR1CyUCYsPUbI+WQ==", "mode": 420, "size": 1233 }, "index.js": { "checkedAt": 1745925782021, "integrity": "sha512-1/M/lsoE88CWoC2i0B8H/0/1OqcNqutPKkOZ6r4VRFXLD8XkfgiBjWTaySq6DxOPG3ta5xiVfdPZmn2/2wXidg==", "mode": 420, "size": 1038 }, "package.json": { "checkedAt": 1745925782021, "integrity": "sha512-Wk1cB0QJbo7jJUs0U25uFc/2lqJNNbLaXrcPDQ+S9gxPpNcOZYDnHqLtJ0Ep2893chz2MdxN1mArJloAXbTERw==", "mode": 420, "size": 1311 }, "HISTORY.md": { "checkedAt": 1745925782022, "integrity": "sha512-HjgVdL/NlSwDhkut3BCwnal4ahlNd2eLzPCGPARs0Wy+rjHtAJNL7/s426/dIwbg7NI5wnkxuhLImjx+ULgavg==", "mode": 420, "size": 1976 }, "README.md": { "checkedAt": 1745925782022, "integrity": "sha512-GahpusTGRt2M/0s2vsEMBfDm0bBXMVlnhBVY+xoDcfBKn4rGbeW9PGQWZ7LM1adQWm8Q3WizIF1D1jwI97IS7w==", "mode": 420, "size": 1797 } } }
CAS 中的包文件存储 (v10/files/
)
pnpm
存储库内部有一个特定的目录结构来存放这些基于 内容哈希 的包文件集合。在 pnpm
的 v10
版本中,这个结构位于 storeDir/v10/files/
路径下。
以 compressible@2.0.18
为例,该包需要存储的文件集合包含如下:
compressible@2.0.18
├── HISTORY.md
├── index.js
├── LICENSE
├── package.json
└── README.md
以其中的 index.js
为例,检索 compressible@2.0.18
存储在 pnpm
全局存储库中的索引文件路径为:
00/5debecfe5d5b12fc331c884d132539140d68e036224005693af893b054ba68-compressible@2.0.18.json
打开该索引文件,可以看到:
{
"name": "compressible",
"version": "2.0.18",
"requiresBuild": false,
"files": {
"LICENSE": {
"checkedAt": 1745925782020,
"integrity": "sha512-iwWNtxw0wslfJ9F2n8JXm5zpCbwR7Gu+rFrXsuO44MbiUu1TZov30H2eWdbt7+mvwcEp0nSR1CyUCYsPUbI+WQ==",
"mode": 420,
"size": 1233
},
"index.js": {
"checkedAt": 1745925782021,
"integrity": "sha512-1/M/lsoE88CWoC2i0B8H/0/1OqcNqutPKkOZ6r4VRFXLD8XkfgiBjWTaySq6DxOPG3ta5xiVfdPZmn2/2wXidg==",
"mode": 420,
"size": 1038
},
"package.json": {
"checkedAt": 1745925782021,
"integrity": "sha512-Wk1cB0QJbo7jJUs0U25uFc/2lqJNNbLaXrcPDQ+S9gxPpNcOZYDnHqLtJ0Ep2893chz2MdxN1mArJloAXbTERw==",
"mode": 420,
"size": 1311
},
"HISTORY.md": {
"checkedAt": 1745925782022,
"integrity": "sha512-HjgVdL/NlSwDhkut3BCwnal4ahlNd2eLzPCGPARs0Wy+rjHtAJNL7/s426/dIwbg7NI5wnkxuhLImjx+ULgavg==",
"mode": 420,
"size": 1976
},
"README.md": {
"checkedAt": 1745925782022,
"integrity": "sha512-GahpusTGRt2M/0s2vsEMBfDm0bBXMVlnhBVY+xoDcfBKn4rGbeW9PGQWZ7LM1adQWm8Q3WizIF1D1jwI97IS7w==",
"mode": 420,
"size": 1797
}
}
}
index.js
文件的 integrity
哈希值为
sha512-1/M/lsoE88CWoC2i0B8H/0/1OqcNqutPKkOZ6r4VRFXLD8XkfgiBjWTaySq6DxOPG3ta5xiVfdPZmn2/2wXidg==
pnpm
会根据该 integrity
值按如下方式存储文件:
内容哈希值通常被转换成十六进制字符串。
tsfunction base64ToHexNode(base64: string): string { return Buffer.from(base64, 'base64').toString('hex'); } const hex = base64ToHexNode( 'sha512-1/M/lsoE88CWoC2i0B8H/0/1OqcNqutPKkOZ6r4VRFXLD8XkfgiBjWTaySq6DxOPG3ta5xiVfdPZmn2/2wXidg=='.slice( 7 ) ); console.assert( hex === 'd7f33f96ca04f3c096a02da2d01f07ff4ff53aa70daaeb4f2a4399eabe154455cb0fc5e47e08818d64dac92aba0f138f1b7b5ae718957dd3d99a7dbfdb05e276' );
获取到的十六进制哈希值的前两个字符被用作一个子目录的名称。
上述获取的
hex
值为bashd7f33f96ca04f3c096a02da2d01f07ff4ff53aa70daaeb4f2a4399eabe154455cb0fc5e47e08818d64dac92aba0f138f1b7b5ae718957dd3d99a7dbfdb05e276
前两个字符为
d7
,所以该文件将被存储在storeDir/v10/files/d7/
目录下。哈希值剩余的部分构成了该文件在子目录中的文件名。
例子中的剩余部分为
bashf33f96ca04f3c096a02da2d01f07ff4ff53aa70daaeb4f2a4399eabe154455cb0fc5e47e08818d64dac92aba0f138f1b7b5ae718957dd3d99a7dbfdb05e276
通过观察已存储的其他文件路径信息:
00/0a57539c3ea49736380ab214874f4528b455742d1f21c9d8027015cfae1372aa80382fefb88fd0abdd89c27c2d1332561e5186e4fa9ec8c348fc5768ca5b22
00/0cdacf5df3cc29401b02c11f6b4c473b193dfff269021e66d0749bdbe81b1c2336a5a72d1384a0d721b72064857a031a4c937653ed07c5e38731682c4c3eb8
1f/1a6454d1d08337c03084eb136fcd6f1ca1448a7f10cf150bd6dbb65aef9fa3be5b182f02d9127dc0ceadb3ad9a3a0911a98a7b63538afed05b8c5581e357db
可知存储文件的文件名是截取 hash
值的 124
个字符。
综上 compressible@2.0.18/index.js
文件将被存储在
storeDir/v10/files/d7/f33f96ca04f3c096a02da2d01f07ff4ff53aa70daaeb4f2a4399eabe154455cb0fc5e47e08818d64dac92aba0f138f1b7b5ae718957dd3d99a7dbfdb05e276
路径中的 v10
表明这是 pnpm
存储布局的第十个主要版本。这意味着 pnpm
的内部结构可能会随着版本迭代而优化和演进,尽管 CAS
的核心概念保持不变。了解这一点很重要,特别是对于那些试图直接与存储库交互(通常不推荐)的用户来说,因为具体的内部布局是实现细节,可能会发生变化。
CAS 中的包元信息文件存储 (v10/index/
)
除了存储单个文件外,pnpm
还需要存储包的元信息,元信息中包含了 包的名称、版本、文件名 与 文件内容哈希值 的映射关系。存储方式(CAS
)与 v10/files/
类似,不过存储元信息文件的目录与存储包文件的目录是分开的,即 v10/index/
。
以 compressible@2.0.18
的包元信息文件内容为例:
{
"name": "compressible",
"version": "2.0.18",
"requiresBuild": false,
"files": {
"LICENSE": {
"checkedAt": 1745925782020,
"integrity": "sha512-iwWNtxw0wslfJ9F2n8JXm5zpCbwR7Gu+rFrXsuO44MbiUu1TZov30H2eWdbt7+mvwcEp0nSR1CyUCYsPUbI+WQ==",
"mode": 420,
"size": 1233
},
"index.js": {
"checkedAt": 1745925782021,
"integrity": "sha512-1/M/lsoE88CWoC2i0B8H/0/1OqcNqutPKkOZ6r4VRFXLD8XkfgiBjWTaySq6DxOPG3ta5xiVfdPZmn2/2wXidg==",
"mode": 420,
"size": 1038
},
"package.json": {
"checkedAt": 1745925782021,
"integrity": "sha512-Wk1cB0QJbo7jJUs0U25uFc/2lqJNNbLaXrcPDQ+S9gxPpNcOZYDnHqLtJ0Ep2893chz2MdxN1mArJloAXbTERw==",
"mode": 420,
"size": 1311
},
"HISTORY.md": {
"checkedAt": 1745925782022,
"integrity": "sha512-HjgVdL/NlSwDhkut3BCwnal4ahlNd2eLzPCGPARs0Wy+rjHtAJNL7/s426/dIwbg7NI5wnkxuhLImjx+ULgavg==",
"mode": 420,
"size": 1976
},
"README.md": {
"checkedAt": 1745925782022,
"integrity": "sha512-GahpusTGRt2M/0s2vsEMBfDm0bBXMVlnhBVY+xoDcfBKn4rGbeW9PGQWZ7LM1adQWm8Q3WizIF1D1jwI97IS7w==",
"mode": 420,
"size": 1797
}
}
}
内容: 每个元信息文件相当于该包特定版本的一个清单(
manifest
),他包含了包的元数据(如名称name
和版本version
),以及最关键的包内所有 原始文件名 与其对应 内容哈希值 的映射关系。通过这个映射,pnpm
知道如何从v10/files/
目录中找到构成该包的所有文件,并以 正确的目录结构 将他们 组织 在一起。命名/位置:
包元信息文件的命名规则为
[[integrity_hash]]-[[package]]@[[version]].json
。以
compressible@2.0.18.json
为例,从pnpm-lock.yaml
中获取的compressible
包的完整性哈希为:bashsha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
通过
base64ToHexNode
函数将该完整性哈希转换为十六进制字符串。tsconst hex = base64ToHexNode( 'sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=='.slice( 7 ) ); console.assert( hex === '005debecfe5d5b12fc331c884d132539140d68e036224005693af893b054ba68cfb51a460d36699743dbd5708ee89783081769d76e8282cf6c331a928e063246' );
同理使用前两个字符
00
作为子目录名,但通过观察已存储的其他包元信息的文件名称:bash4809a191f68281dd19697243570e3258268c62c2aa4f3c20571b0289715aa9-@pnpm+patch-package@0.0.1.json aa5a6251e7f2de1255b3870b2f9be7e28a82f478bebb03f2f6efadb890269b-base64-js@1.5.1.json 5d3279e22b928e9df782a9528d4cfb45f5b01d788a4a1aaa5206b931f57d6e-meow@11.0.0.json
可知元信息文件名是截取
hash
值的62
个字符。因此compressible@2.0.18.json
最后的文件路径为:bashv10/index/00/5debecfe5d5b12fc331c884d132539140d68e036224005693af893b054ba68-compressible@2.0.18.json
CAS
的链接策略
上述已经介绍了 CAS
的存储布局,接下来就要介绍 pnpm
是如何将 CAS
中的文件链接到项目中。
虚拟存储 (node_modules/.pnpm
)
pnpm
并不会直接将全局 CAS
中的文件链接到项目根目录下的 node_modules
文件夹。他采用了一种 中间层 结构,即在每个项目的 node_modules
目录下创建一个名为 .pnpm
的隐藏目录,这个目录被称为 虚拟存储。
这个 .pnpm
目录才是实际包含指向全局 CAS
中文件链接(hardlink
或 reflink
)的地方。这些链接按照包名和版本的结构进行组织,例如 .pnpm/<pkg-name>@<version>/node_modules/<pkg-name>/
。
包导入方法 (packageImportMethod
)
pnpm
如何将文件从全局 CAS
导入到项目的虚拟存储 (.pnpm
) 中,是由 packageImportMethod
配置项控制的。以下是各选项的详细说明:
auto
(默认值): 这是pnpm
的首选策略。它会优先尝试使用clone
(即 引用链接 / 写时复制)。如果文件系统不支持clone
,则尝试使用hardlink
(硬链接)。如果硬链接也失败(例如,尝试跨文件系统链接),最后会回退到copy
(普通文件复制)。默认设置旨在智能地选择当前环境下最优的可行选项。clone
(引用链接/写时复制): 这是 最快 且 最安全 的方法。他创建一个指向原始文件数据的引用。如果之后修改了项目node_modules
中的这个文件,文件系统会自动创建一个新的副本,而不会影响CAS
中的原始文件。这种方式既节省空间(初始时不复制数据),又保证了 隔离性。但它需要底层文件系统的支持(例如Btrfs
,APFS
, 以及支持reflink
的XFS
)。在速度、空间和安全性(隔离性)之间提供了最佳平衡,但依赖于现代文件系统。hardlink
(硬链接): 创建一个硬链接。这意味着项目node_modules
中的文件条目和CAS
中的文件条目指向磁盘上完全相同的物理数据块。这种方式在 空间效率上 非常高,因为不占用太多的额外空间(记录inode
信息)。但是,它的一个重要后果是,如果在项目node_modules
中直接修改了这个硬链接文件,也会同时修改CAS
中的原始文件,会无意中破坏CAS
,从而影响其他项目。硬链接要求源文件和链接目标必须在同一文件系统上。极度节省空间,但将项目与存储库紧密耦合,存在意外修改存储库文件的风险。copy
(复制): 执行标准的文件复制操作。这是磁盘空间和安装速度效率最低的方式,但它具有普遍适用性,即使跨文件系统也能工作。是万能的后备方案,但牺牲了pnpm
的主要优势(节省磁盘空间 和 安装速度)。clone-or-copy
: 优先尝试clone
,如果不支持则回退到copy
。
文件系统的类型(如 Btrfs
, XFS
, EXT4
, APFS
)直接决定了 clone
或 hardlink
是否可用。此外,在 docker
构建环境中,由于构建时无法在宿主机文件系统和容器文件系统之间创建 硬链接 或 引用链接,需要采用变通方法,例如使用 BuildKit
的缓存挂载(cache mount
)或 pnpm fetch
命令来预先下载依赖。
packageImportMethod
选项比较
选项 | 机制 | 磁盘效率 | 速度 | 文件系统依赖 | node_modules 可修改性 |
---|---|---|---|---|---|
auto (默认) | 优先 clone, 再 hardlink, 最后 copy | 最优可行 | 最优可行 | 是 | 取决于实际使用的方法 |
clone | 引用链接 (COW) | 高 | 最快 | 是 (Btrfs, APFS, etc.) | 安全 (修改时创建副本) |
hardlink | 硬链接 | 最高 | 快 | 是 (同一文件系统) | 危险 (直接修改存储库) |
copy | 文件复制 | 低 | 慢 | 否 | 安全 (修改的是副本) |
clone-or-copy | 优先 clone, 再 copy | 依赖 clone 支持 | 依赖 clone 支持 | 是 (for clone) | 取决于实际使用的方法 |
根 node_modules
中的符号链接
链接过程的最后一步是在项目根目录的 node_modules
文件夹中创建符号链接(symlinks
)。这些符号链接指向 .pnpm
虚拟存储内相应的包目录。
一个关键的特点是,只有项目的 直接依赖(即在项目 package.json
文件的 dependencies
, devDependencies
, optionalDependencies
中列出的包)才会被 符号链接 到 node_modules
的根目录。这强制执行了 pnpm
的严格性原则:应用上下文中无法直接 require
或 import
未在其 package.json
中声明的传递依赖项(即依赖的依赖)。当 node.js
解析模块时,它会沿着这些 符号链接 找到 .pnpm
目录中实际的包代码。
结论
pnpm
的高效性源于其精心设计的 内容寻址存储 以及 软硬链接 机制。他结合了包完整性校验、对包内每个文件进行内容哈希计算、一个全局内容寻址存储库(CAS
)、用于记录包结构的索引文件,以及一套复杂的链接策略(优先使用 引用链接 或 硬链接 将包中所有的文件导入到项目内的虚拟存储 .pnpm
,再通过 符号链接 将直接依赖项暴露在根 node_modules
中)。这一系列机制共同作用,实现了显著的 磁盘空间节省 和 安装速度提升。