Skip to main content

pnpm 包管理工具详解

· 12 min read

介绍 pnpm 的优势、原理、基本用法以及用其管理 monorepo 的最佳实践

pnpm 介绍

pnpm 是 一个兼容 npm 的 JavaScript 包管理工具,它在依赖 安装速度磁盘空间利用 方面都有显着的改进。它与 npm/yarn 非常相似,它们都是 使用相同的 package.json 文件管理依赖项,同时也会像 npm/yarn 利用锁文件 去确保跨多台机器时保证依赖结构和版本的一致性。

它的安装也非常简单。

$ npm i -g pnpm

pnpm 相较于 npmyarn 有如下特点:

  • 包安装的速度快
  • 磁盘空间利用率高
  • 项目根目录下的 node_modules 是非扁平化结构
  • 支持 monorepo

下面会详细分析 pnpm 的原理,来说明为什么它有这些特点。

pnpm 原理

索引节点(inode)

在 Linux 的文件系统中,保存在磁盘分区中的每一个文件都有唯一的 inode,它包含文件的元信息,在访问文件时,对应的元信息会被 copy 到内存去实现文件的访问。

使用 stat 指令可以获取一个文件的元信息。

$ stat README.md

硬链接的作用是能够让计算机文件系统中的多个文件,平等地共享同一个文件存储单元,这样就够让一个文件拥有多个有效路径名。当你修改源文件或者链接文件的时候,都会做同步的修改(双向引用)。

需要注意硬链接文件和源文件共用一个 inode,本质上它们就是同一个文件,关系完全平等。当我们删除一个硬链接文件时并不影响源文件本身和其他的链接,只有当最后一个链接被删除后,文件的数据块及目录的连接才会被释放,也就是说,文件才会被真正删除。

软链接又称符号链接,它是一个类特殊的文件或者目录,其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用,类似于 Windows 中的快捷方式。对软链接文件进行读写的程序会表现得像直接对目标文件进行操作。

需要注意软链接文件和源文件是两个不同的文件,因此软链接文件具有不同的 inode,并且当你删除软链接文件时,并不会影响源文件。当你移动、重命名、删除源文件后,软链接文件会记录一个不存在路径信息(被遗弃)。

pnpm-store

在项目中使用 pnpm 首次安装依赖,pnpm 会将依赖存在到 .pnpm-store 目录下,你可以使用如下命令来查看 store 的路径。

$ pnpm store path # 输出 /Users/yukee-798/.pnpm-store/v3

如果在 store 中已经存在你需要的依赖,则会通过一个 硬链接 将该依赖丢到你项目中去,而不是重新下载依赖。

在命令行中你可以看见有多少个包是复用的(reused),有多少个包是重新下载的(downloaded)。

$ pnpm i express

Packages: +52
++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 52, reused 52, downloaded 0, added 0, done

dependencies:
+ express 4.17.1

通过这种方式就解决了 项目之间已经安装过的包不能共享与每次安装依赖都会重新安装的问题,提高了包的安装速度和磁盘利用率。

important

包存储在了 store 中,为什么我的 node_modules 还是占用了磁盘空间?

pnpm 创建从 store 到项目下 node_modules 文件夹的硬链接,但是硬链接本质还是和原始文件共享的是相同的 inode。 因此,它们二者其实是共享同一个空间的,看起来占用了 node_modules 的空间。所有始终只会占用一份空间,而不是两份。

非扁平化的依赖结构

pnpm 在安装项目依赖后,项目根目录中的 node_modules 是非扁平化结构,下面以安装 express 为例。

当我们执行 pnpm i express 后,项目根目录中的 node_modules 结构如下:

node_modules
├── .pnpm
└── express

这里的 express 目录实际上是一个软链接,当 NodeJS 解析依赖时会通过软链接得到源文件所在位置。当我们打开这软链接后并没有发现 node_modules 文件夹,那么它的源文件和次级依赖在哪?

答案是 .pnpm 中。

.pnpm 目录中,会将项目所有依赖的包,不管是 直接依赖 还是 次级依赖,通过 扁平化 的方式以 <name>@<version> 的目录名呈现。现在我们打开 .pnpm,它是如下结构:

node_modules
├── .pnpm
│ ├── accepts@1.3.5
│ │ └── node_modules
│ │ ├── accepts # 硬链接 <store>/accepts
│ │ ...
│ ├── array-flatten@1.1.1
│ │ └── node_modules
│ │ ├── array-flatten # 硬链接 <store>/array-flatten
...
│ ├── express@4.16.3
│ │ └── node_modules
│ │ ├── accepts # 软链接 -> ../../accepts@1.3.5
│ │ ├── array-flatten # 软链接 --> ../../array-flatten@1.1.1
│ │ ...
│ │ ├── express # 硬链接 --> <store>/express
│ │ ...
│ │
├── express

.pnpm 中的每个包下只有一个 node_modules 目录,该目录下保存了该依赖的 所有直接依赖的软链接(不包含次级依赖)和 依赖本身的硬链接。软链接指向的是 .pnpm 中对应的包,硬链接指向的是 pnpm-store 中对应的包。

在 vscode 中,可以根据一个目录最右侧是否有 "链接符号",来判断它是否是一个软链接。

pic.sm

.pnpm 内的所有目录都会与其内部的 node_modules 进行硬链接,而 node_modules 中唯一的真实目录就是与其同名(去掉版本号)的目录,它与 pnpm-store 中的下载好的依赖进行硬链接,因此简单来说,.pnpm 中的每个包都会和 pnpm-store 中相应的依赖进行硬链接。

pic.sm

通过 ls -al 可以获取一个目录中软链接的指向。

my-app/node_modules
$ ls -al
express -> .pnpm/express@4.17.2/node_modules/express

下面配合官方给的图,可以进行如下总结:

  • 根目录的 node_modules 是非扁平化的,里面有 .pnpm 目录整个项目的直接依赖的软链接(不包含次级依赖)
  • .pnpm 存放的是 整个项目所有的依赖(包含次级依赖)的硬链接,指向 pnpm-store 对应的文件
  • 整个项目的直接依赖的软链接 指向的是 .pnpm 对应依赖的硬链接
  • .pnpm 存放的每一个依赖中只有一个 node_modules 目录,该目录内包含了 该依赖的所有直接依赖软链接该依赖本身的硬链接,其中软链接指向的是 .pnpm 中对应的包,硬链接指向的是 pnpm-store 中对应的包

pnpm-lock.yaml 文件

它的作用和 package-lock.jsonyarn.lock 差不多,pnpm 官方也提供了从其他包管理器的 lock 文件转换为 pnpm 的 lock 文件方案,只需要运行如下命令:

$ pnpm import

常用命令

pnpm 兼容 npm 的大部分命令,比如 installuninstalladdremoveupdate ...

-w 参数

只会将依赖安装到项目根目录的 node_modules 中。

$ pnpm i react react-dom -w

-r 参数

将依赖安装到根目录的 node_modules 和 packages 下所有项目的 node_modules 中。

$ pnpm i lodash -r

--filter 参数

可以将依赖安装到指定的包下。

$ pnpm i classnames -r --filter @auro/auro-ui

实际开发中我们差不多就只用得上这些参数,具体更多参数可以 查阅文档

Workspace

pnpm 使用了 workspace 来管理 monorepo,下面会讲解它的基本配置和相关命令。

important

推荐一个使用 pnpm 搭建的 monorepo 最佳实践

pnpm-workspace.yaml

对于一个 monorepo 项目,我们需要在项目根目录新建一个 pnpm-workspace.yaml 文件,内容如下:

packages:
# the root package.json
- '.'
# all packages in subdirs of packages/
- 'packages/**'
# exclude packages that are inside test/ directories
- '!**/test/**'

配置 package.json 的 name 字段

假如我们有如下结构的 monorepo:

auro-design
...
├── packages
├── auro-ui
├── auro-icons
...
...

首先我们给项目根目录的 package.jsonname 字段设置为 "auro"。

那么 packages/auro-ui 下的 package.jsonname 就要设置为 "@auro/auro-ui",packages/auro-icons 下的 package.jsonname 就要设置为 "@auro/auro-icons"。

monorepo 中各包的相互引入

如果在项目的根目录,我们要使用 @auro/auro-ui@auro/auro-icons,只需要像正常安装依赖那样执行如下指令:

$ pnpm i @auro/auro-ui @auro/auro-icons -w

当然,如果我们要在 @auro/auro-ui 中引入 @auro/auro-icons,则可以执行如下指令:

$ pnpm i @auro/auro-icons -r --filter @auro/auro-icons

这样在根目录的 package.json 下就会添加如下依赖。

"dependencies": {
"@auro/auro-icons": "workspace:^1.0.0",
"@auro/auro-ui": "workspace:^1.0.0"
}

在我们根目录的项目中就可以直接引入 @auro/auro-icons@auro/auro-ui 了。

index.js
import { Button } from "@auro/auro-ui";
import { SearchIcon } from "@auro/auro-icons";

Reference