pnpm是一款安装速度快且能够有效节省磁盘空间的包管理器工具,其基于符号链接的node_modules结构一方面大大提高安装的速度,另一方面也解决了传统npm或者yarn上存在的幽灵依赖和npm包分身的问题。另外,pnpm workspace的特性在monorepo工程化方面也有着不错的表现。
这篇文章记录pnpm学习实践过程的收获,有问题欢迎指正。
硬连接与软连接的定义与区别
软连接(也称作符号链接——symbolic link, symlink or soft link),是一类特殊的文件,其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用。
一个符号链接文件仅包含有一个文本字符串,其被操作系统解释为一条指向另一个文件或者目录的路径。它是一个独立文件,其存在并不依赖于目标文件。如果删除一个符号链接,它指向的目标文件不受影响。如果目标文件被移动、重命名或者删除,任何指向它的符号链接仍然存在,但是它们将会指向一个不复存在的文件。这种情况被有时被称为被遗弃。
硬连接, 指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。
因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。
基于符号连接的node_modules结构
pnpm 的 node_modules 布局使用符号链接来创建依赖项的嵌套结构。
pnpm的node_modules结构中,通常情况下只会保留package.json中声明的依赖包,并且这些依赖包会以软连接(vscode中文件名右侧的箭头表示即代表软连接文件)的方式直接放在node_modules中
这些软连接文件会指向node_module/.pnpm文件夹下的相应文件,这里面的文件是 node_modules 中的唯一的“真实”文件。
node_modules/.pnpm中的文件是以扁平化的方式组织的,这里面的文件都是以硬连接的方式链接到pnpm的全局store中,pnpm每次install新的包时,都会先从全局store中检查是否存在,若存在,则直接建立硬连接,大大减少了依赖安装的时间。
如上图所示,
node_modules/.pnpm中依赖以及其依赖的依赖都硬连接到同一个node_modules文件中,这是必要的:
- 允许包自行导入自己
- 避免循环符号链接。 依赖以及需要依赖的包被放置在一个文件夹下。 对于 Node.js 来说,依赖是在包的内部
node_modules中或在任何其它在父目录node_modules中是没有区别的。
这里@eslint/core依赖的@types/json-sechma包也是通过软连接的方式扁平到.pnpm下
pnpm这样的node_modules设计与 Node 的模块解析算法完全兼容!解析模块时,Node 会忽略符号链接,而是解析到符号链接所指向的实际位置,由于实际位置的包也处于一个xxx/node_modules/xxx结构中,所以其依赖也可以被正确的解析。
这里放一张官方给的原理图,可以配合理解
幽灵依赖与npm包分身问题
在npm2版本之前,node_modules的结构是一个标准的嵌套树形结构,由于软件包可以依赖其他的包,这样的结构就导致大量公共的依赖包被重复安装到node_moudles中,所以在npm3之后,安装算法改成了将树扁平化,即将公共的依赖提升到根node_modules,这种提升就带来了幽灵依赖问题,也叫做幻影依赖。
幽灵依赖,指在项目中使用那些不被pakcage.json管理的依赖,由于npm的扁平化算法将部分公共依赖提升到了根node_modules,导致这些公共依赖可以被node的模块解析算法解析到,所以运行时不会出现问题。但在项目中的幽灵依赖很可能导致一些不符合预期的错误:
- 不兼容的版本,由于幽灵依赖不受pakcage.json的控制,当产生幽灵依赖的包升级导致幽灵依赖也升级时,很可能会导致版本的不兼容问题。
- 缺少依赖,当幽灵依赖来自一个开发依赖时,在生产构建时,由于不会安装开发依赖,那么构建时就会引发缺少依赖的错误。
同样的,由于npm node_modules扁平化的设计,在不同的软件包依赖同一个依赖包的不同版本时,npm只能将其中一个版本提到根node_modules,其他版本的依赖还是会保留在原先的树结构,这种被保留的npm包就称为npm分身。
npm分身在小项目中很少遇到,但在大型monorepo项目中常见,这会导致以下问题:
- 更慢的安装时间: 如今磁盘空间非常宝贵,但是假设你有 20 个依赖于 F1 的库,这会导致 20 份拷贝。假设这里有一个安装脚本,它会下载和解压大型的压缩包(例如 PhantomJS),这会在每个分身中重复执行,最终显著影响你的安装时间。
- 增大包体积: Web 项目经常使用诸如 webpack 等打包工具,它们会静态分析
require()语句,并将其收集到一个单一的打包产物中。这些产物应该尽可能保持小,因为它会直接影响页面应用的加载时间,假设出现了不符合预期的分身(例如由于npm install操作导致的 node_modules 树重排),这会导致一个库拷贝了两份之后被嵌入到产物中,进而极大增加了包体积。 - 非单一的: 假设 library-f 暴露了一个缓存对象的 API, 其目的是想让库那所有的消费者共享一个单例,当两个不同的组件调用
require("library-f")时,它们可能获取到两个不同的库,这意味着这里会有两个实例(也就是说,“全局”变量会从两个不同的闭包中获取)。这可能会导致一些难以调试的奇怪问题。
由于pnpm的node_modules结构基于软/硬连接而非扁平化算法,所以pnpm的node_modules不会导致幽灵依赖和npm包分身的问题。
pnpm workspace
pnpm workspace 是项目 monorepo 的一种解决方案。采用 pnpm-workspace.yaml 文件来管理多个npm包。
# pnpm-workspace.yaml
packages:
- apps/*
- packages/*
在pnpm workspace等monorepo仓库中,其实也存在幽灵依赖的问题。这是monorepo根工程和子仓库的node_modules形成的嵌套结构所造成的,但好在一般monorepo的根工程中不会引入大多业务相关的依赖,所以这个问题并不明显。
workspace工作空间协议
pnpm支持工作空间协议 workspace:, 可以将本地的基础包直接link到应用工程中进行调试验证。
当 link-workspace-packages 选项被设置为 false 时,这个协议将特别有用。 在这种情况下,仅当使用 workspace: 协议声明依赖,pnpm 才会从此 workspace 链接所需的包。
当使用pnpm publish发布包时,pnpm 会动态替换这些 workspace: 依赖
{
"dependencies": {
"foo": "workspace:*",
"bar": "workspace:~",
"qar": "workspace:^",
"zoo": "workspace:^1.5.0"
}
}
依赖提升设置
部分npm包会刻意依赖幽灵依赖的特性,比如:eslint/prettier,如果你知道只有某些有缺陷的包具有幻影依赖,你可以使用此选项专门提升幻影依赖(推荐做法)官方文档入口
当前版本pnpm 9+中会默认将 eslint/prettier 内置到 .npmrc public-hoist-pattern 配置中
...
'public-hoist-pattern': [
'*eslint*',
'*prettier*',
],
所以eslint/prettier以及其相关的依赖默认都会进行提升:
pnpm计划在下一个版本(10.x)移除这个默认配置,详情可见