最近将几个项目的包管理器都由 npm
切换为了 pnpm
,迁移体验非常棒,算得上是个人体验最好的一次工具迁移。以下是我本人使用 pnpm
的直观感受:
- 体验优良,依赖安装速度极快,占用磁盘空间小。
- 上手简单,绝大部分
npm
/yarn
项目可以低成本完成迁移,官方也有较详尽的中文文档。 pnpm
组织node_modules
目录的方式兼容原生 Node,与打包工具配合良好,可以放心应用于生产环境。pnpm
依赖访问虽然严格,但是规则清晰,界限分明后,不再如以前一样容易出现依赖冲突,反而降低了使用时的心智负担,纠正了我之前的一些错误认知。
结合使用前的学习以及使用过程中的感受,下面将为大家介绍使用 pnpm
的注意事项,以及 pnpm
作为现代包管理器的优势所在。
参考文章:
关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn?
关于依赖管理
如果对于包管理器管理依赖的过程没有最基本的认识,那么从 npm
转向 pnpm
是一定会有困惑的。
我们知道,每个项目的 package.json
文件内都声明了项目的依赖,其中有三种类型,dependencies
、devDependencies
、peerDependencies
。
网上对于依赖类型dependencies
和 devDependencies
,有以下常见说法:
dependencies
是正式依赖,是项目产物所依赖的包。devDependencies
是开发依赖,只用在本地开发和测试的包。
这种说法不能说完全错误,但至少是不够清晰的,我们很难因此真正理解它们,所以就会在日常工作中经常踩依赖包版本的坑。
甚至有一种精简化后更广为流传的说法:dependencies = 生产依赖,devDependencies = 开发依赖
,更是对我们产生了误导。
dependencies 和“生产环境”有关吗
我们来创建一个最简单的 vite & vue
项目:
npm create vite@latest my-vue-app -- --template vue
vite
和相关插件是本地开发环境的依赖,我们暂且不提。但是 vue
显然是应用运行的主要依赖,生产环境中也是一定要运行的,如果我们将其移入 devDependencies
中,会不会就无法打包了呢?
{
"name": "my-vue-app",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
- "vue": "^3.2.25"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.3",
"vite": "^2.9.9"
+ "vue": "^3.2.25"
}
}
修改后,执行安装、打包与预览命令:
npm i
npm run build
npm run preview
可见,丝毫没有任何的影响,我们甚至可以下这样的结论:
开发 Web 应用
时,即使将所有依赖声明在 devDependencies
中,也不会影响应用的成功构建、打包与运行。
因此 dependencies = 生产依赖,devDependencies = 开发依赖
的说法是片面的。
我们常说的 “生产环境”、“开发环境” 是构建时行为,构建并不是包管理器的职责,而是 webpack
、rollup
、vite
的工具的工作,此时包管理器起的作用仅仅是执行脚本而已。
各种包管理器处理 dependencies
和 devDependencies
差异的行为都发生在依赖安装时期,即 npm install
的过程中。
dependencies 和 devDependencies 的区别
假设我们有项目 a
,其 package.json
结构如下:
{
"name": "a",
"dependencies": {
"b": "^1.0.0"
},
"devDependencies": {
"c": "^1.0.0"
}
}
a
的依赖 b
和 c
的依赖信息如下:
// node_modules/b/package.json
{
"name": "b",
"dependencies": {
"d": "^1.0.0"
},
"devDependencies": {
"e": "^1.0.0"
}
}
// node_modules/c/package.json
{
"name": "c",
"dependencies": {
"f": "^1.0.0"
},
"devDependencies": {
"g": "^1.0.0"
}
}
我们用实线表示 dependencies
依赖,用虚线表示 devDependencies
依赖,项目 a
的依赖树如下表示:
执行 npm install
后,a
的 node_modules
目录最终内容如下
node_modules
├── b // a 的 dependencies
├── c // a 的 devDependencies
├── d // b 的 dependencies
└── f // c 的 dependencies
我们注意到,所安装的包都被平铺到 node_modules
目录下,这是 npm
、yarn
等上一代包管理器为了解决依赖层级过深而采用的方案。
然而这种方案会带来其他的困惑,pnpm
针对这些问题有所优化,这部分内容将在后文—— 传统包管理器的文件结构 中探讨。
可见,包管理器将以项目的 package.json
为起点,安装所有 dependencies
与 devDependencies
中声明的依赖。
但是对于这些一级依赖项具有的更深层级依赖,在深度遍历的过程中,只会安装 dependencies
中的依赖,忽略 devDependencies
中的依赖。
因此,b
和 c
的 devDependencies
—— e
和 g
被忽略,
而它们的 dependencies
—— d
和 f
被安装。
为什么会这样呢?因为包管理器认为:作为包的使用者,我们当然不用再去关心它们开发构建时的依赖,所以会为我们忽略 devDependencies
。
而 dependencies
是包产物正常工作所依赖的内容,当然有必要安装。
回到 Web 应用
开发的场景,Web 应用
的产物往往部署到服务器,不会发布到 npm
仓库供其他用户使用,
而包管理器对于一级依赖,无论 dependencies
还是 devDependencies
都会悉数安装。
这种情况下, dependencies
与 devDependencies
可能真的只有语义化约定的作用了。
peerDependencies
peerDependencies
声明包的同步依赖。但是包管理器不会像 dependencies
一样,自动为用户安装好依赖,当用户使用包时,必须遵照该包的 peerDependencies
同步安装对应的依赖,否则包管理器会提示错误。
peerDependencies
的使用场景一般是核心库的周边插件,例如 vue
之于 vuex
,或者 vite
之于 @vitejs/plugin-vue2
,插件一般是不能独立于核心库而单独工作的。
以下演示一个正确使用 peerDependencies
的插件范例。
该插件适用于 vite
,作用是解析 vue
2.7及以上版本的模板文件,因此对 vite
和 vue
的版本进行了限制。
// @vitejs/plugin-vue2 的 package.json
{
"name": "@vitejs/plugin-vue2",
// ...
"peerDependencies": {
"vite": ">=2.5.10",
"vue": "^2.7.0-0"
},
// dependencies、devDependencies 与其他字段 ...
}
相比起 dependencies
默认自动安装依赖,peerDependencies
通过安装时的提示信息,可以指导用户正确安装核心依赖,一定程度上能避免一些依赖版本冲突。
传统包管理器的文件结构
继续看上文—— dependencies 和 devDependencies 的区别 中的例子。
对于以上依赖树,若根据 node_modules
的生成规则,则目录如下:
node_modules
├── b // a 的 dependencies
| └── node_modules
| └── d // b 的 dependencies
├── c // a 的 devDependencies
| └── node_modules
| └── f // c 的 dependencies
可想而知,如果 d
和 f
还有自己的依赖,那么生成的目录结构将会过深,某些操作系统的文件系统将难以支持。
我们常用的 npm
、 yarn
,为了解决依赖层级过深的问题,都通过扁平化依赖解决问题,所有的依赖都被拍平到 node_modules
目录下,不再有很深层次的嵌套关系。
node_modules
├── b // a 的 dependencies
├── c // a 的 devDependencies
├── d // b 的 dependencies
└── f // c 的 dependencies
在上面的例子中,假设 a
又增添了依赖 d
,由于 b
的依赖 d
已经被拍平到 node_modules
。
require()
方法在 b
中未发现 node_modules
时,会继续向上级目录寻找 node_modules
,能够找到拍平后的依赖,因此包管理器无需重复安装 d
。
于是,扁平化依赖的另一个好处就是:在安装新的包时,包管理器也会不停往上级的 node_modules
当中去找,如果找到相同版本的包就不会重新安装,同时解决了大量包重复安装的问题。
npm
/ yarn
虽然解决了很多问题,但是依然存在很多优化空间:
- 扁平化依赖算法复杂,需要消耗较多的性能,依赖安装还有提速空间。
- 大量文件需要重复下载,一方面对磁盘空间的利用率不足,另外大量的解压、IO操作也会进一步降低执行效率。
- 扁平化依赖虽然解决了不少问题,但是随即带来了依赖非法访问的问题,项目代码在某些情况下可以在代码中使用没有被定义在 package.json 中的包,这种情况就是我们常说的幽灵依赖。
pnpm 优势 - 硬链接节约磁盘空间
由于个人对操作系统的文件系统了解有限,这里引用文章 关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn? 中的相关描述,基本清晰表明了 pnpm 在磁盘空间利用方面的优势。
pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:
- 不会重复安装同一个包。用
npm
/yarn
的时候,如果 100 个项目都依赖lodash
,那么lodash
很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但使用pnpm
只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink(硬链接,不清楚的同学详见这篇文章 Linux软连接和硬链接)。- 即使一个包的不同版本,
pnpm
也会极大程度地复用之前版本的代码。举个例子,比如lodash
有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink,仅仅写入那一个新增的文件。
pnpm 优势 - 软链接优化依赖管理
我们以 vue
的安装为例,首先使用 npm
进行安装:
npm i vue -S
可以看到,npm
的扁平化依赖管理导致 node_modules
中还有很多乱七八糟的东西。
再使用 pnpm
试一下:
pnpm i vue -S
多么纯净!多么赏心悦目!
这时,会有同学开始疑惑了,根目录的 node_modules
中只有 vue
,vue
的目录下也没有 node_modules
,
那么 vue
所需的依赖不就缺失了吗?
这正是 pnpm
的巧妙之处!我们展开 .pnpm
目录,会发现别有一番洞天。必要依赖原来在这里。
但是这种目录结构,不符合 require()
不断向上寻找 node_modules
中依赖的规则,vue
怎么获取这些资源呢?
仔细观察,我们会发现,node_modules
的 vue
其实只是一个软链接(常用 Windows 的同学可以理解为快捷方式)。
它真正指向的位置是 .pnpm
目录中对应的包。
可见,.pnpm
中的 vue
才是“元神”所在,node_modules
中的只不过是“化身”。
在 vue@3.2.27/node_modules/
目录下的 vue
,自然可以从上层 node_modules
中找到 @vue/
中的几个依赖,
我们先前担心的依赖丢失问题迎刃而解!
巧妙的是,这几个依赖其实也是软链接“化身”,他们的本体也以同样地结构安装在 .pnpm
中。
下图简单标注了依赖和链接的情况。
pnpm
将包本身和依赖放在同一个 node_modules
下面,实现了与原生 require()
的兼容。
依赖都是以软链接的形式引入,其本体也以同样的结构组织起来。
于是,所有的包的依赖文件结构,都与其 package.json
中的声明保持一致,不再如先前一般让人眼花缭乱。
pnpm 优势 - 更安全地访问依赖
默认情况下禁止幽灵依赖,是 pnpm
基于软链接的依赖管理模式带来的好处。
pnpm
的依赖文件结构与 package.json
中的声明保持一致,因此,我们将不能再访问 package.json
中未声明的包。
这解决了 npm
/ yarn
一直依赖的幽灵依赖问题,提升了依赖访问的安全性。
举一个幽灵依赖产生的场景,以 上一节 中用 npm
安装依赖的项目为例,我们写出以下代码。
代码可以成功运行:
PS D:\learning\npm> node a.js
{ asyncWalk: [AsyncFunction: asyncWalk], walk: [Function: walk] }
这里的 estree-walker
的依赖关系如此:
estree-walker
-> @vue/complier-core
-> @vue/complier-dom
-> vue
我们的 package.json
中只声明了 vue
,却可以使用与 vue
有着三层依赖关系的包。
表面上看没什么问题,但是如果 vue
哪一天更新版本,不再依赖于 estree-walker
,那么我们的代码就会报错,这就是非法访问依赖带来的风险。
当然,这种行为在 pnpm
中显然是行不通了。想要在项目代码中使用的包,必须老老实实地在 package.json
中正确声明。
虽然禁止当前正在开发的项目访问幽灵依赖,但是,由于历史原因,很多已经发布的包都或多或少存在幽灵依赖的问题。
pnpm
为了兼容它们,降低用户的迁移与使用成本,默认情况下,会将所有的依赖包都提升一份到 .pnpm/node_modules
下。
这部分涉及到 pnpm
的依赖提升策略,通过配置项目根目录下的 .npmrc
文件可以修改,甚至可以让 pnpm
对访问幽灵依赖的任性行为提供支持,具体可以参见官方文档 .npmrc | 依赖提升设置
pnpm 基本使用
如果你曾经是 npm
/ yarn
的用户,迁移 pnpm
在命令使用方面基本是没有什么成本的。这方面,官方文档 中也有非常详细的介绍。
下面,我们将实战迁移一个 vue2
的祖传项目到 pnpm
。祖传项目的 package.json
中声明的依赖关系如下:
{
// ...
"dependencies": {
"axios": "^0.21.0",
"cropperjs": "^1.5.11",
"echarts": "^4.8.0",
"echarts-liquidfill": "^2.0.6",
"element-ui": "^2.13.2",
"file-saver": "^2.0.5",
"highlight.js": "^9.0.0",
"js-base64": "^3.7.2",
"lodash": "^4.17.19",
"marked": "^1.2.7",
"moment": "^2.24.0",
"qs": "^6.10.2",
"save": "^2.4.0",
"sortablejs": "^1.13.0",
"v-viewer": "^1.5.1",
"video.js": "^7.10.2",
"vue": "^2.6.11",
"vue-bus": "^1.2.1",
"vue-clipboard2": "^0.3.1",
"vue-contextmenu": "^1.5.10",
"vue-cropper": "^0.5.5",
"vue-py": "0.0.4",
"vue-qr": "^4.0.9",
"vue-router": "^3.4.3",
"vue-ueditor-wrap": "^2.4.4",
"vuedraggable": "^2.24.3",
"vuex": "^3.4.0",
"xlsx": "^0.16.9",
"xss": "^1.0.10"
},
"devDependencies": {
"@types/echarts": "^4.9.12",
"@types/file-saver": "^2.0.4",
"@types/lodash": "^4.14.178",
"@types/node": "^17.0.16",
"@types/qs": "^6.9.7",
"@types/sortablejs": "^1.10.7",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.11.0",
"@vitejs/plugin-legacy": "^1.7.1",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-plugin-vue": "^8.4.0",
"prettier": "^1.18.2",
"sass": "~1.32.13",
"stylelint": "^14.3.0",
"stylelint-config-recess-order": "^3.0.0",
"stylelint-config-recommended-vue": "^1.1.0",
"stylelint-config-standard-scss": "^3.0.0",
"typescript": "^4.4.4",
"vite": "^2.8.6",
"vite-plugin-html-env": "^1.1.1",
"vite-plugin-vue2": "^1.9.3",
"vue-eslint-parser": "^8.2.0",
"vue-template-compiler": "^2.6.11",
"vue-tsc": "^0.31.1"
},
// ...
}
首先,删除 package-lock.json
文件以及 node_modules
目录。
确保通过 npm i -g pnpm
安装好 pnpm
的前提下,执行 pnpm install
安装全部依赖。
与 npm
类似,pnpm
通过以下命令进行依赖安装与卸载:
# 根据 package.json 中的依赖声明安装全部依赖
pnpm install
# 安装指定依赖,并在 dependencies 中声明依赖
pnpm install -S xxx
# 安装指定依赖,并在 devDependencies 中声明依赖
pnpm install -D xxx
# 卸载指定依赖
pnpm uninstall xxx
安装后,pnpm
果然报出警告:
ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependencies
.
├─┬ eslint-config-airbnb-base
│ └── ✕ missing peer eslint-plugin-import@^2.25.2
├─┬ eslint-config-airbnb-typescript
│ └── ✕ missing peer eslint-plugin-import@^2.25.3
├─┬ stylelint-config-recommended-vue
│ ├── ✕ missing peer postcss-html@^1.0.0
│ └─┬ stylelint-config-html
│ └── ✕ missing peer postcss-html@^1.0.0
├─┬ stylelint-config-standard-scss
│ └─┬ stylelint-config-recommended-scss
│ └─┬ postcss-scss
│ └── ✕ missing peer postcss@^8.3.3
└─┬ echarts-liquidfill
└── ✕ missing peer zrender@^4.3.1
Peer dependencies that should be installed:
eslint-plugin-import@">=2.25.3 <3.0.0" postcss-html@">=1.0.0 <2.0.0" postcss@^8.3.3 zrender@^4.3.1
这是因为 pnpm
没有自动为我们安装 peerDependencies
,按照提示要求安装所有的 peerDependencies
即可:
pnpm i -D eslint-plugin-import postcss-html postcss
pnpm i -S zrender@^4.3.1
与 npm
一致,pnpm
也通过 pnpm run
执行脚本,执行以下命令,运行应用:
pnpm run dev
运行应用以后,出现报错:
这是一个典型的非法访问幽灵依赖的问题,我们可以在 pnpm-lock.yaml
中检查依赖关系,发现 viewerjs
是 v-viewer
的依赖项,进一步打开 node_modules
目录进行确认。
// node_modules/v-viewer/package.json
{
"name": "v-viewer",
// ...
"dependencies": {
"throttle-debounce": "^2.0.1",
"viewerjs": "^1.5.0"
}
}
npm
由于依赖扁平化处理(参见:传统包管理器的文件结构),使得我们原本可以访问 viewerjs
。
切换为 pnpm
后,在默认情况下不允许访问未声明的依赖,因此我们需要补充安装 viewerjs
。
pnpm i -S viewerjs
这一次,我们成功运行起了项目,迁移完成:
当然,幽灵依赖问题也可以通过在根目录下创建 .npmrc
文件,在其中配置 public-hoist-pattern
或者 shamefully-hoist
字段,将依赖提升到根node_modules
目录下解决。参考:依赖提升设置
# .npmrc
# 提升含有 eslint(模糊匹配)、prettier(模糊匹配)、viewerjs(精确匹配) 的依赖包到根 node_modules 目录下
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=viewerjs
# 提升所有依赖到根 node_modules 目录下,相当于 public-hoist-pattern[]=*,与上面一种方式一般二选一使用
shamefully-hoist=true
当然,极不推荐用这样的方式解决依赖问题,这样没有充分利用 pnpm
依赖访问安全性的优势,又走回了 npm
/ yarn
的老路。
对于大部分的项目,按照以上思路基本能平稳由 npm
向 pnpm
过渡,官方也有足够详尽的 FAQ, 足以解决迁移过程中的大部分问题。