TL;DR
Pnpm 是一款现代包管理工具,全称是 performant npm,即“高性能的 npm”,官网地址:pnpm.io/。
作者 Zoltan Kochan 对 yarn 相对于 npm 做的“微小”的优化感到失望,因此原本对 yarn 寄予厚望的 Zoltan Kochan 开始全身心投入到 pnpm 中。在 2017 年,pnpm 已经实现 yarn 相对于 npm 的附加功能,比如:安全性、离线模式、安装速度等。
当前,pnpm 已经越来越多用于项目中,尤其是 monorepos 项目。可以参见:pnpm 的 2021 年。
我们今年最大的新用户是 Bytedance(TikTok 背后的公司)。
此外,许多优秀的开源项目开始使用 pnpm。 有些人转而使用 pnpm 是因为它对 monorepos 的大力支持:
有些人之所以切换为 pnpm,是因为他们喜欢 pnpm 的高效、快速和美观:
包管理工具
这里先简单介绍一下包管理工具的演变历史。详细的请阅读:前端包管理器探究。
目前常见的包管理工具有:npm、yarn、cnpm、pnpm 等。我们通过包管理工具生成 node_modules 下的项目依赖。
Npm v1/v2 :依赖嵌套
在 npm v3 之前,npm 使用最简单的嵌套模式管理项目依赖。比如项目依赖模块 foo,模块 foo 依赖模块 bar,此时项目的依赖结构如下:
node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json
这种嵌套的方式,虽然看起来简单明了,但是会带来依赖地狱(Dependency Hell) 问题。假设项目的另一个依赖模块 baz 也同样依赖 bar,这时依赖 bar 就会在同一个项目下被安装两次。
Npm v3 :扁平化
为了解决上述问题,npm 提出了扁平化 node_modules 结构,将子依赖项安装在主依赖项所在的目录中 (hoisting) 。这时的依赖结构如下:
node_modules
├─ foo
| ├─ index.js
| └─ package.json
└─ bar
├─ index.js
└─ package.json
扁平化的结构虽然解决了依赖嵌套问题,减少包的重复安装,但是却带来幽灵依赖(Phantom dependency) 问题 。
所谓幽灵依赖是指某个包未在 package.json 中定义,但项目中依然可以引用到。上面的模块 bar 是模块 foo 的依赖,而非项目的依赖,但是在项目中却可以访问到 bar。
此外,如果不同模块(A、C、D、E)依赖同一模块(B)的不同版本,仍然会存在重复安装的情形,导致多重依赖(doppelgangers), 从而产生“破坏单例模式”和“types冲突”等问题。
A、C、D 依赖 B v2.0;E 依赖 B v1.0
用户在开发时先安装依赖 E,再安装依赖 A、C、D
当用户开发完成,上传代码至仓库,另一开发者 clone 代码,执行 npm install 之后,生产node_modules 结构如下:
很显然,前后生成的 node_modules 结构不一样,这就是依赖安装的不确定性(Non-Determinism)。
Npm v5:扁平化+lock
正是由于依赖安装的不确定性,以及依赖安装版本的 SemVer 版本规范。Yarn 创造性地提出了用 lock 文件来明确各依赖的层级和版本。
随后,在 npm v5 中也新增了package-lock.json。当项目有 package.json 文件并首次执行 npm install 安装后,会自动生成一个package-lock.json文件,该文件里面记录了package.json依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过 package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。
Yarn v2:PnP模式
Yarn v2.x 版本推出了 Plug'n'Play(PnP)模式。
以 .pnp.cjs 文件代替 node_modules,该文件维护了依赖包到磁盘位置与子依赖项列表的映射。同时 .pnp.js 还实现了 resolveRequest 方法处理 require 请求,该方法会直接根据映射表确定依赖在文件系统中的位置,从而避免了在node_modules查找依赖的 I/O 操作。
这种模式的缺点就是自建 node require 实现,脱离了 node 生态,导致兼容性不好。
Pnpm 特点
根据上文,基于扁平化的 node_modules 结构,可以解决依赖地狱问题;通过 lock 文件,可以解决依赖一致性问题,但是幽灵依赖和多种依赖仍然存在。
Pnpm 的出现解决了 npm 和 yarn 的上述问题。Pnpm 具有如下特点:
- 安装速度更快
- 节省磁盘空间
- 安全性好
- 支持 monorepos
- 非平铺的 node_modules 结构
Pnpm 工作原理
硬链接和符号链接(软链接)
在介绍 pnpm 工作原理之前,我们需要先了解硬链接和符号链接(软链接)。假设存在一个文件 A,基于文件 A 创建一个硬链接的文件 B 和一个符号链接的文件 C。文件 A 和 文件 B 指向同一个文件在硬盘中的地址,所以修改 B ,A 的内容也会改变。而文件 C 中保存的是文件 A 的绝对路径,它本身就是一个文件,有独立的存储区块。
总结:
- 硬链接: 与普通文件没什么不同,指向同一个文件在硬盘中的区块
- 软链接: 保存了其代表的文件的绝对路径,是另外一种文件,在硬盘上有独立的区块,访问时替换自身路径。
统一的依赖 store
当我们使用 npm 或者 yarn 时,如果有 100 个 react 项目,那么就会有 100 份 react 存在硬盘上。而使用 pnpm 时,所有的依赖会被存在一个统一的地方(store)。所以:
-
所有文件都会存储在硬盘上的某一位置。 当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖。
-
如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。 例如,如果某个包有100个文件,而它的新版本只改变了其中1个文件。那么 pnpm update 时只会向存储中心额外添加1个新文件,而不会因为仅仅一个文件的改变复制整新版本包的内容。
因此,在磁盘上节省了大量空间,并且安装速度也要快得多!
非扁平化的依赖结构
使用 npm 或 yarn 安装依赖项时,所有包都被提升到模块目录的根目录。 因此,项目可以访问到未被添加进当前项目的依赖。
Pnpm 使用软链的方式将项目的直接依赖添加进模块文件夹的根目录。那么如何解决依赖地狱问题呢?
Pnpm 会在 node_modules 文件夹下创建一个 .pnpm 文件夹,内部是扁平化的项目所有依赖,只有这里的依赖是实际存在的,通过硬链接指向 pnpm store。整体加载逻辑:
- 项目中引入依赖(如:bar),这些依赖在 node_modules 的根目录下,否则无法引入。
- 依赖 bar 通过符号链接指向 .pnpm 下的真实依赖(硬链接)
- 模块 bar 依赖同一 node_modules 下的 foo
- foo 通过符号链接指向 .pnpm 下的真实依赖(硬链接)
特别需要注意的是只有 .pnpm 根目录下的依赖真实存在(硬链接),其他的都是符号链接。另外有一个点可能比较奇怪:为什么 bar 和他的依赖 foo 会在同一个 node_modules 下?这个问题暂且不表,稍后再回答。
pnpm 构建上述依赖结构的整个过程如下:
假设安装依赖于 bar@1.0.0 的 foo@1.0.0。 pnpm 会将两个包硬链接到 node_modules 下的 .pnpm 文件夹,如下所示:
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
│ ├── index.js
│ └── package.json
└── foo@1.0.0
└── node_modules
└── foo -> <store>/foo
├── index.js
└── package.json
这是 node_modules 中的唯一的“真实”文件,硬链接到内容可寻址的 store。
你可能已经注意到,上面的依赖和它的依赖被放置在同一个 node_modules 文件夹下。这样做的目的:
- 允许包自己导入自己
- 避免循环符号链接
接下来是符号链接依赖项。foo 依赖于 bar,因此 bar 被符号链接到 foo@1.0.0/node_modules 下。
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
└── foo@1.0.0
└── node_modules
├── foo -> <store>/foo
└── bar -> ../../bar@1.0.0/node_modules/bar
最后,处理项目的直接依赖。 foo 被符号链接至 node_module 下。
node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
└── foo@1.0.0
└── node_modules
├── foo -> <store>/foo
└── bar -> ../../bar@1.0.0/node_modules/bar
假设 qar@2.0.0 作为 bar 和 foo 的依赖项。 新的结构:
node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ ├── bar -> <store>/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
├── foo@1.0.0
│ └── node_modules
│ ├── foo -> <store>/foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
└── qar@2.0.0
└── node_modules
└── qar -> <store>/qar
Pnpm 使用
虽然原理上相差很多,但是在使用上,pnpm 基本上和 npm 相似,几乎没有上手成本。
| npm 命令 | pnpm 等效 |
|---|---|
| npm install | pnpm install |
| npm i | [pnpm add ] |
| npm run | [pnpm ] |
有几个特殊的指令:
// 设置pnpm-store的位置
pnpm config set store-dir /path/to/.pnpm-store
// 从源中获取包而不将其安装为依赖项,热加载,并运行它公开的任何默认命令二进制文件。类似npx
pnpm dlx
pnpm dlx create-react-app ./my-app
// 切换 node 版本。类似 nvm 工具,无需另外安装
pnpm env use --global 16
局限性
- 符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
- 子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
- 不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。
- 目前 node 生态中有很多包存在幽灵依赖问题,从而导致在 pnpm 无法正常工作。
目前,pnpm 的坑已经趟得差不多了,不少项目也都迁移到了 pnpm。同时,为了你那少得可怜的计算机内存,可以考虑使用 pnpm 。
One More Thing: cnpm/tnpm
cnpm/tnpm 的依赖管理是借鉴了 pnpm ,通过符号链接方式创建非扁平化的 node_modules 结构,最大限度提高了安装速度。安装的依赖包都是在 node_modules 文件夹以包名命名,然后再做符号链接到 版本号 @包名的目录下。与 pnpm 不同的是,cnpm 没有使用硬链接,也未把子依赖符号链接到单独目录进行隔离。
node_modules
├── _foo@1.0.0
├── _bar@1.0.0
│ └── node_modules
│ └── foo -> ../_foo@1.0.0
└── foo -> ./_foo@1.0.0
└── bar -> ./_bar@1.0.0
此外,tnpm 新推出的 rapid 模式使用用户态文件系统(FUSE)对依赖管理做了一些新的优化,通过 FUSE 可以接管一个目录的文件系统操作逻辑。传送门:深入浅出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒。
一句话解释:直接把 node_modules 通过 FUSE + 依赖关系图 映射到 tar 归档文件,省去了解压的文件 IO。
核心点在在于:
-
网络 IO:服务端生成依赖树,省去元数据请求。
-
文件 IO:合并写入 tar,节省磁盘写入次数;FUSE 映射,不解压文件。