新一代包管理工具 pnpm 使用心得

最近将几个项目的包管理器都由 npm 切换为了 pnpm,迁移体验非常棒,算得上是个人体验最好的一次工具迁移。以下是我本人使用 pnpm 的直观感受:

  1. 体验优良,依赖安装速度极快,占用磁盘空间小。

  2. 上手简单,绝大部分 npm / yarn 项目可以低成本完成迁移,官方也有较详尽的中文文档。

  3. pnpm 组织 node_modules 目录的方式兼容原生 Node,与打包工具配合良好,可以放心应用于生产环境。

  4. pnpm 依赖访问虽然严格,但是规则清晰,界限分明后,不再如以前一样容易出现依赖冲突,反而降低了使用时的心智负担,纠正了我之前的一些错误认知。

结合使用前的学习以及使用过程中的感受,下面将为大家介绍使用 pnpm 的注意事项,以及 pnpm 作为现代包管理器的优势所在。

参考文章:

关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn?

pnpm 文档

关于依赖管理

如果对于包管理器管理依赖的过程没有最基本的认识,那么从 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 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:

  1. 不会重复安装同一个包。用 npm / yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用
    hardlink(硬链接,不清楚的同学详见这篇文章 Linux软连接和硬链接)。
  2. 即使一个包的不同版本,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 中的只不过是“化身”。

[email protected]/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, 足以解决迁移过程中的大部分问题。

猜你喜欢

转载自blog.csdn.net/zhangdaiscott/article/details/131932340