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
在安装时是嵌套结构
一个简单的例子,demo-foo
和 demo-baz
中均依赖 example-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
的最长路径限制,在复杂项目场景依赖层级较深时,依赖的路径往往会超出长度限制
扁平结构
为了解决上述问题,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
版本,则会得到下面的结构,具体哪个版本会提升到最顶层则取决于安装时的顺序(示例):
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
的机制,只会提升一个,其他版本则可能会被重复安装,还是上面的例子,当依赖的 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 mysymlink
pnpm 实现
在 pnpm 中,会将依赖安装到当前分区的
位置中,可以通过以下命令获得当前的 store
位置:
pnpm store path
然后利用 hard link
将所需的包从 node_modules/.pnpm
硬链接到 store
中,最后通过 symbolic link
将 node_modules
中的顶层依赖以及依赖的依赖符号链接到 node_modules/.pnpm
中,一个依赖 [email protected]
和 [email protected]
的例子,node_modules
结构如下:
node_modules
└─ .pnpm
└─ [email protected]
└─ node_modules
└─ demo-bar -> /demo-bar
└─ [email protected]
└─ node_modules
└─ demo-bar -> /demo-bar
└─ [email protected]
└─ node_modules
├─ demo-bar -> ../../[email protected]/node_modules/demo-bar
└─ demo-baz -> /demo-baz
└─ [email protected]
└─ node_modules
├─ demo-bar -> ../../[email protected]/node_modules/demo-bar
└─ demo-foo -> /demo-foo
└─ demo-baz -> ./pnpm/[email protected]/node_modules/demo-baz
└─ demo-foo -> ./pnpm/[email protected]/node_modules/demo-foo
这里引用了官网的截图帮助你更好地理解 symbolic ink
与 hard link
在项目结构中是如何组织的:
其他能力
pnpm
目前可以脱离 Node.js
的 runtime
去安装使用,还可以通过 pnpm env
来对 Node.js
版本进行管理,类似 nvm
,与 npm/yarn
完整的功能比较详见:feature-comparison
pnpm 的局限性
- 由于
symbolic link
在一些场景下有兼容性问题,目前Eletron
以及labmda
部署的应用上无法使用pnpm
,详见:discussion
可以通过在 .npmrc
中 node-linker=hoisted
可以创建一个没有符号链接的扁平的 node_modules
,此时 pnpm
创建的目录结构将与 npm/yarn
类似
- 由于全局共用同一份
store
,因此当需要修改node_modules
内的内容时,会直接影响全局store
中对应的内容,对其他项目也会造成影响
关于这个问题,其实最推荐的方式是 clone
(copy-on-write),使用写入时复制,默认多个引用指向同一个文件,只有当用户需要修改的时候才进行复制,这样就不会影响其他引用对于源文件内容的读取
但是并不是所有的操作系统都支持,pnpm
默认会尝试使用 clone
,如果不支持,则会退回至使用 hard link
,你也可以通过在 npmrc
中指定 package-import-method 来手动设置包的引用方式
其他工具
采用 Zig
写的一个 JS runtime
,bun
也提供了包管理工具,但是 bun
会有一些兼容性问题
利用 Rust
写的 Node.js
包管理器,速度极快,目前仍在 beta 阶段
未来展望
pnpm
很快,但是并不是所有的 pnpm
命令都很快,例如 pnpm run
比较慢,未来可能会使用 Rust
来写一些子命令的 cli wrapper,参见这个 discussion
参考
关于
- 完整的示例代码可以参考:blog-samples/pnpm
- 本文首发于 github 和 个人博客,欢迎关注和 star