pnpm
作为当前比较流行的包管理器之一,主要特点是速度快、节省磁盘空间,本文将介绍 pnpm
的底层实现,帮助你理解 pnpm
的原理
# pnpm 简介
pnpm
的含义是 performant npm
,意味着高性能 npm
,从官网中提供的 benchmarks
也可以看出在 intall
、update
等场景时对于 npm
、yarn
、yarn_pnp
有不错的性能优势:
# node_modules 的目录结构
# 嵌套结构
在 npm@2
的早期版本中,对应 Node.js 4.x
及以前的版本,node_modules
在安装时是嵌套结构
一个简单的例子 (opens new window),demo-foo
和 demo-baz
中均依赖 demo-bar
,在同时安装 demo-foo
和 demo-baz
时会生成如下的 node_modules
结构:
node_modules
└─ demo-foo
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar
├─ index.js
└─ package.json
└─ demo-baz
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar
├─ index.js
└─ package.json
这个时候的目录结构虽然比较清晰,但是每个依赖包都会有自己的 node_modules
,相同的依赖并没有复用,例如上面的相同依赖 demo-bar
就被安装了两次
另外一个问题是 windows
的最长路径限制 (opens new window),在复杂项目场景依赖层级较深时,依赖的路径往往会超出长度限制
# 扁平结构
为了解决上述问题,yarn
提出了扁平结构的设计,将所有的依赖在 node_modules
中平铺,后来的 npm v3
版本的实现也与之类似,因此使用 yarn
或者 npm@3+
安装上述的例子,将会得到如下扁平式的目录结构:
node_modules
└─ demo-bar
├─ index.js
└─ package.json
└─ demo-baz
├─ index.js
└─ package.json
└─ demo-foo
├─ index.js
└─ package.json
另外这种方式对于相同依赖的不同版本,则只会将其中一个进行提升,剩余的版本则还是嵌套在对应的包中,例如我们上面的 demo-foo
中对于 demo-bar
的依赖升级到 v1.0.1
版本,则会得到下面的结构,具体哪个版本会提升到最顶层则取决于安装时的顺序(示例 (opens new window)):
node_modules
└─ demo-bar
├─ index.js
└─ package.json
└─ demo-baz
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar
├─ index.js
└─ package.json
└─ demo-foo
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar
├─ index.js
└─ package.json
# 扁平结构存在的问题
扁平化的方案并不完美,反而引入了一些新的问题:
# 幽灵依赖
幽灵依赖(Phantom dependencies)指的是没有显示声明在 package.json
中的依赖,却可以直接引用到对应的包,这个问题是由扁平化的结构产生的,会将依赖的依赖也至于 node_modules
的顶层,也就可以在项目中直接引用到。当某一天这个子依赖不再是引用包的依赖时,项目中的引用则会出现问题
# 分身问题
NPM 分身(NPM doppelgangers)则指的是对于相同依赖的不同版本,由于 hoist
的机制,只会提升一个,其他版本则可能会被重复安装,还是上面的例子 (opens new window),当依赖的 demo-bar
的依赖升级到 v1.0.1
时,作为 demo-foo
和 demo-baz
依赖的 v1.0.0
版本则以嵌套的形式被重复安装:
node_modules
└─ demo-bar // v1.0.1
├─ index.js
└─ package.json
└─ demo-baz
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar // v1.0.0
├─ index.js
└─ package.json
└─ demo-foo
├─ index.js
├─ package.json
└─ node_modules
└─ demo-bar // v1.0.0
├─ index.js
└─ package.json
# pnpm 解题思路
pnpm
首先将依赖安装到全局 store
,然后通过 symbolic link
和 hard link
来组织目录结构,将全局的依赖链接到项目中,将项目的直接依赖链接到 node_modules
的顶层,所有的依赖则平铺于 node_modules/.pnpm
目录下,实现了所有项目的依赖共享 store
的全局依赖,解决了幽灵依赖和 NPM 分身的问题
# symbolic link 与 hard link
链接是操作系统中文件共享的方式,其中 symbolic link
是符号链接,也称软链接,hard link
是硬链接,从在使用的角度看,二者没有什么区别,都支持读写,如果是可执行文件也可以直接执行,主要区别在于底层原理不太一样:
# hard link
- 硬链接不会新建
inode
(索引节点),源文件与硬链接指向同一个索引节点 - 硬链接不支持目录,只支持文件级别,也不支持跨分区
- 删除源文件和所有硬链接之后,文件才真正被删除
# symbolic link
- 符号链接中存储的是源文件的路径,指向源文件,类似于
Windows
的快捷方式 - 符号链接支持目录与文件,它与源文件是不同的文件,
inode
值不一样,文件类型也不同,因此符号链接可以跨分区访问 - 删除源文件后,符号链接依然存在,但是无法通过它访问到源文件
# 如何创建链接
# symbolic ink
ln -s myfile mysymlink
# hard link
ln myfile myhardlink
# pnpm 实现
在 pnpm 中,会将依赖安装到当前分区的 <home dir>/.pnpm-store
位置中,可以通过以下命令获得当前的 store
位置:
pnpm store path
然后利用 hard link
将所需的包从 node_modules/.pnpm
硬链接到 store
中,最后通过 symbolic link
将 node_modules
中的顶层依赖以及依赖的依赖符号链接到 node_modules/.pnpm
中,一个依赖 demo-foo@1.0.1
和 demo-baz@1.0.0
的例子 (opens new window),node_modules
结构如下:
node_modules
└─ .pnpm
└─ demo-bar@1.0.0
└─ node_modules
└─ demo-bar -> <store>/demo-bar
└─ demo-bar@1.0.1
└─ node_modules
└─ demo-bar -> <store>/demo-bar
└─ demo-baz@1.0.0
└─ node_modules
├─ demo-bar -> ../../demo-bar@1.0.0/node_modules/demo-bar
└─ demo-baz -> <store>/demo-baz
└─ demo-foo@1.0.1
└─ node_modules
├─ demo-bar -> ../../demo-bar@1.0.1/node_modules/demo-bar
└─ demo-foo -> <store>/demo-foo
└─ demo-baz -> ./pnpm/demo-baz@1.0.0/node_modules/demo-baz
└─ demo-foo -> ./pnpm/demo-baz@1.0.1/node_modules/demo-foo
这里引用了官网的截图帮助你更好地理解 symbolic ink
与 hard link
在项目结构中是如何组织的:
pnpm 对于链接的实际应用,以下是相关源码 (opens new window):
function createImportPackage (packageImportMethod?: 'auto' | 'hardlink' | 'copy' | 'clone' | 'clone-or-copy') {
// this works in the following way:
// - hardlink: hardlink the packages, no fallback
// - clone: clone the packages, no fallback
// - auto: try to clone or hardlink the packages, if it fails, fallback to copy
// - copy: copy the packages, do not try to link them first
switch (packageImportMethod ?? 'auto') {
case 'clone':
packageImportMethodLogger.debug({ method: 'clone' })
return clonePkg
case 'hardlink':
packageImportMethodLogger.debug({ method: 'hardlink' })
return hardlinkPkg.bind(null, linkOrCopy)
case 'auto': {
return createAutoImporter()
}
case 'clone-or-copy':
return createCloneOrCopyImporter()
case 'copy':
packageImportMethodLogger.debug({ method: 'copy' })
return copyPkg
default:
throw new Error(`Unknown package import method ${packageImportMethod as string}`)
}
}
# 其他能力
pnpm
目前可以脱离 Node.js
的 runtime
去安装使用,还可以通过 pnpm env
来对 Node.js
版本进行管理,类似 nvm
,与 npm/yarn
完整的功能比较详见:feature-comparison (opens new window)
# pnpm 的局限性
- 由于
symbolic link
在一些场景下有兼容性问题,目前Eletron
以及labmda
部署的应用上无法使用pnpm
,详见:discussion (opens new window)
可以通过在 .npmrc
中 node-linker=hoisted
可以创建一个没有符号链接的扁平的 node_modules
,此时 pnpm
创建的目录结构将与 npm/yarn
类似
- 由于全局共用同一份
store
,因此当需要修改node_modules
内的内容时,会直接影响全局store
中对应的内容,对其他项目也会造成影响
关于这个问题,其实最推荐的方式是 clone
(copy-on-write (opens new window)),使用写入时复制,默认多个引用指向同一个文件,只有当用户需要修改的时候才进行复制,这样就不会影响其他引用对于源文件内容的读取
但是并不是所有的操作系统都支持,pnpm
默认会尝试使用 clone
,如果不支持,则会退回至使用 hard link
,你也可以通过在 npmrc
中指定 package-import-method (opens new window) 来手动设置包的引用方式
# 其他工具
bun: https://github.com/oven-sh/bun
- 采用
Zig
写的一个JS runtime
,bun
也提供了包管理工具,但是bun
会有一些兼容性问题
- 采用
Volt:https://github.com/dimensionhq/volt
- 利用
Rust
写的Node.js
包管理器,速度极快,目前仍在 beta 阶段
- 利用
tnpm: https://github.com/cnpm/npminstall
- 来自 2022 SEE Conf 的演讲 (opens new window),利用 Filesystem in Userspace(FUSE (opens new window)) 和 OverlayFS (opens new window) 的特性来实现虚拟映射目录
# 未来展望
pnpm
很快,但是并不是所有的 pnpm
命令都很快,例如 pnpm run
比较慢,未来可能会使用 Rust
来写一些子命令的 cli wrapper,参见这个 discussion (opens new window)