最近将几个项目的包管理器都由 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, 足以解决迁移过程中的大部分问题。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。