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

1,091 阅读14分钟

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

  1. 体验优良,依赖安装速度极快,占用磁盘空间小。
  2. 上手简单,绝大部分 npm / yarn 项目可以低成本完成迁移,官方也有较详尽的中文文档。
  3. pnpm 组织 node_modules 目录的方式兼容原生 Node,与打包工具配合良好,可以放心应用于生产环境。
  4. pnpm 依赖访问虽然严格,但是规则清晰,界限分明后,不再如以前一样容易出现依赖冲突,反而降低了使用时的心智负担,纠正了我之前的一些错误认知。

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

参考文章:

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

pnpm 文档

关于依赖管理

如果对于包管理器管理依赖的过程没有最基本的认识,那么从 npm 转向 pnpm 是一定会有困惑的。

我们知道,每个项目的 package.json 文件内都声明了项目的依赖,其中有三种类型,dependenciesdevDependenciespeerDependencies

网上对于依赖类型dependenciesdevDependencies,有以下常见说法:

  • 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

截图.PNG 1656917138084.png

可见,丝毫没有任何的影响,我们甚至可以下这样的结论:

开发 Web 应用 时,即使将所有依赖声明在 devDependencies 中,也不会影响应用的成功构建、打包与运行。

因此 dependencies = 生产依赖,devDependencies = 开发依赖 的说法是片面的。 我们常说的 “生产环境”、“开发环境” 是构建时行为,构建并不是包管理器的职责,而是 webpackrollupvite 的工具的工作,此时包管理器起的作用仅仅是执行脚本而已。 各种包管理器处理 dependenciesdevDependencies 差异的行为都发生在依赖安装时期,即 npm install 的过程中。

dependencies 和 devDependencies 的区别

假设我们有项目 a,其 package.json 结构如下:

{
  "name": "a",
  "dependencies": {
    "b": "^1.0.0"
  },
  "devDependencies": {
    "c": "^1.0.0"
  }
}

a 的依赖 bc 的依赖信息如下:

// 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 的依赖树如下表示: 1656922014137.png

执行 npm install 后,anode_modules 目录最终内容如下

node_modules
├── b       // a 的 dependencies
├── c       // a 的 devDependencies   
├── d       // b 的 dependencies    
└── f       // c 的 dependencies

我们注意到,所安装的包都被平铺到 node_modules 目录下,这是 npmyarn 等上一代包管理器为了解决依赖层级过深而采用的方案。 然而这种方案会带来其他的困惑,pnpm 针对这些问题有所优化,这部分内容将在后文—— 传统包管理器的文件结构 中探讨。

可见,包管理器将以项目的 package.json 为起点,安装所有 dependenciesdevDependencies 中声明的依赖。 但是对于这些一级依赖项具有的更深层级依赖,在深度遍历的过程中,只会安装 dependencies 中的依赖,忽略 devDependencies 中的依赖。 因此,bcdevDependencies —— eg 被忽略, 而它们的 dependencies —— df 被安装。

为什么会这样呢?因为包管理器认为:作为包的使用者,我们当然不用再去关心它们开发构建时的依赖,所以会为我们忽略 devDependencies。 而 dependencies 是包产物正常工作所依赖的内容,当然有必要安装。

回到 Web 应用 开发的场景,Web 应用 的产物往往部署到服务器,不会发布到 npm 仓库供其他用户使用, 而包管理器对于一级依赖,无论 dependencies 还是 devDependencies 都会悉数安装。 这种情况下, dependenciesdevDependencies 可能真的只有语义化约定的作用了。

peerDependencies

peerDependencies 声明包的同步依赖。但是包管理器不会像 dependencies 一样,自动为用户安装好依赖,当用户使用包时,必须遵照该包的 peerDependencies 同步安装对应的依赖,否则包管理器会提示错误。

peerDependencies 的使用场景一般是核心库的周边插件,例如 vue 之于 vuex,或者 vite 之于 @vitejs/plugin-vue2,插件一般是不能独立于核心库而单独工作的。

以下演示一个正确使用 peerDependencies 的插件范例。 该插件适用于 vite,作用是解析 vue 2.7及以上版本的模板文件,因此对 vitevue 的版本进行了限制。

// @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 的区别 中的例子。 1656922014137.png

对于以上依赖树,若根据 node_modules 的生成规则,则目录如下:

node_modules
├── b                 // a 的 dependencies
|   └── node_modules
|       └── d         // b 的 dependencies 
├── c                 // a 的 devDependencies
|   └── node_modules
|       └── f         // c 的 dependencies   

可想而知,如果 df 还有自己的依赖,那么生成的目录结构将会过深,某些操作系统的文件系统将难以支持。

我们常用的 npmyarn,为了解决依赖层级过深的问题,都通过扁平化依赖解决问题,所有的依赖都被拍平到 node_modules 目录下,不再有很深层次的嵌套关系。

node_modules
├── b       // a 的 dependencies
├── c       // a 的 devDependencies   
├── d       // b 的 dependencies    
└── f       // c 的 dependencies

在上面的例子中,假设 a 又增添了依赖 d,由于 b 的依赖 d 已经被拍平到 node_modulesrequire() 方法在 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 中还有很多乱七八糟的东西。

1658283249005.png

再使用 pnpm 试一下:

pnpm i vue -S

多么纯净!多么赏心悦目!

1658283738177.png

这时,会有同学开始疑惑了,根目录的 node_modules 中只有 vuevue 的目录下也没有 node_modules, 那么 vue 所需的依赖不就缺失了吗? 这正是 pnpm 的巧妙之处!我们展开 .pnpm 目录,会发现别有一番洞天。必要依赖原来在这里。

1658284022042.png

但是这种目录结构,不符合 require() 不断向上寻找 node_modules 中依赖的规则,vue 怎么获取这些资源呢?

仔细观察,我们会发现,node_modulesvue 其实只是一个软链接(常用 Windows 的同学可以理解为快捷方式)。

1658284309844.png

它真正指向的位置是 .pnpm 目录中对应的包。

1658284471056.png

可见,.pnpm 中的 vue 才是“元神”所在,node_modules 中的只不过是“化身”。

vue@3.2.27/node_modules/ 目录下的 vue,自然可以从上层 node_modules 中找到 @vue/ 中的几个依赖, 我们先前担心的依赖丢失问题迎刃而解! 巧妙的是,这几个依赖其实也是软链接“化身”,他们的本体也以同样地结构安装在 .pnpm 中。 下图简单标注了依赖和链接的情况。

1658285795115.png

pnpm 将包本身和依赖放在同一个 node_modules 下面,实现了与原生 require() 的兼容。 依赖都是以软链接的形式引入,其本体也以同样的结构组织起来。 于是,所有的包的依赖文件结构,都与其 package.json 中的声明保持一致,不再如先前一般让人眼花缭乱。

pnpm 优势 - 更安全地访问依赖

默认情况下禁止幽灵依赖,是 pnpm 基于软链接的依赖管理模式带来的好处。

pnpm 的依赖文件结构与 package.json 中的声明保持一致,因此,我们将不能再访问 package.json 中未声明的包。 这解决了 npm / yarn 一直依赖的幽灵依赖问题,提升了依赖访问的安全性。

举一个幽灵依赖产生的场景,以 上一节 中用 npm 安装依赖的项目为例,我们写出以下代码。

1658287803922.png

代码可以成功运行:

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 中正确声明。

1658288347738.png

虽然禁止当前正在开发的项目访问幽灵依赖,但是,由于历史原因,很多已经发布的包都或多或少存在幽灵依赖的问题。 pnpm 为了兼容它们,降低用户的迁移与使用成本,默认情况下,会将所有的依赖包都提升一份到 .pnpm/node_modules 下。

1658368951036.png

这部分涉及到 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

运行应用以后,出现报错:

1658716877460.png

这是一个典型的非法访问幽灵依赖的问题,我们可以在 pnpm-lock.yaml 中检查依赖关系,发现 viewerjsv-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

这一次,我们成功运行起了项目,迁移完成:

1658801589518.png

当然,幽灵依赖问题也可以通过在根目录下创建 .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 的老路。

对于大部分的项目,按照以上思路基本能平稳由 npmpnpm 过渡,官方也有足够详尽的 FAQ, 足以解决迁移过程中的大部分问题。