pnpm:下一代包管理工具?

1,213 阅读10分钟

TL;DR

Pnpm 是一款现代包管理工具,全称是 performant npm,即“高性能的 npm”,官网地址:pnpm.io/

作者 Zoltan Kochan 对 yarn 相对于 npm 做的“微小”的优化感到失望,因此原本对 yarn 寄予厚望的 Zoltan Kochan 开始全身心投入到 pnpm 中。在 2017 年,pnpm 已经实现 yarn 相对于 npm 的附加功能,比如:安全性、离线模式、安装速度等。

当前,pnpm 已经越来越多用于项目中,尤其是 monorepos 项目。可以参见:pnpm 的 2021 年

我们今年最大的新用户是 Bytedance(TikTok 背后的公司)。

此外,许多优秀的开源项目开始使用 pnpm。 有些人转而使用 pnpm 是因为它对 monorepos 的大力支持:

有些人之所以切换为 pnpm,是因为他们喜欢 pnpm 的高效、快速和美观:

包管理工具

这里先简单介绍一下包管理工具的演变历史。详细的请阅读:前端包管理器探究

目前常见的包管理工具有:npm、yarn、cnpm、pnpm 等。我们通过包管理工具生成 node_modules 下的项目依赖。

Npm v1/v2 :依赖嵌套

在 npm v3 之前,npm 使用最简单的嵌套模式管理项目依赖。比如项目依赖模块 foo,模块 foo 依赖模块 bar,此时项目的依赖结构如下:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json

这种嵌套的方式,虽然看起来简单明了,但是会带来依赖地狱(Dependency Hell) 问题。假设项目的另一个依赖模块 baz 也同样依赖 bar,这时依赖 bar 就会在同一个项目下被安装两次。

Npm v3 :扁平化

为了解决上述问题,npm 提出了扁平化 node_modules 结构,将子依赖项安装在主依赖项所在的目录中 (hoisting) 。这时的依赖结构如下:

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json

扁平化的结构虽然解决了依赖嵌套问题,减少包的重复安装,但是却带来幽灵依赖(Phantom dependency) 问题


所谓幽灵依赖是指某个包未在 package.json 中定义,但项目中依然可以引用到。上面的模块 bar 是模块 foo 的依赖,而非项目的依赖,但是在项目中却可以访问到 bar。

此外,如果不同模块(A、C、D、E)依赖同一模块(B)的不同版本,仍然会存在重复安装的情形,导致多重依赖(doppelgangers), 从而产生“破坏单例模式”和“types冲突”等问题。

A、C、D 依赖 B v2.0;E 依赖 B v1.0

用户在开发时先安装依赖 E,再安装依赖 A、C、D

当用户开发完成,上传代码至仓库,另一开发者 clone 代码,执行 npm install 之后,生产node_modules 结构如下:

很显然,前后生成的 node_modules 结构不一样,这就是依赖安装的不确定性(Non-Determinism)。

Npm v5:扁平化+lock

正是由于依赖安装的不确定性,以及依赖安装版本的 SemVer 版本规范。Yarn 创造性地提出了用 lock 文件来明确各依赖的层级和版本。

随后,在 npm v5 中也新增了package-lock.json。当项目有 package.json 文件并首次执行 npm install 安装后,会自动生成一个package-lock.json文件,该文件里面记录了package.json依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过 package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。

Yarn v2:PnP模式

Yarn v2.x 版本推出了 Plug'n'Play(PnP)模式。

以 .pnp.cjs 文件代替 node_modules,该文件维护了依赖包到磁盘位置与子依赖项列表的映射。同时 .pnp.js 还实现了 resolveRequest 方法处理 require 请求,该方法会直接根据映射表确定依赖在文件系统中的位置,从而避免了在node_modules查找依赖的 I/O 操作。

这种模式的缺点就是自建 node require 实现,脱离了 node 生态,导致兼容性不好。

Pnpm 特点

根据上文,基于扁平化的 node_modules 结构,可以解决依赖地狱问题;通过 lock 文件,可以解决依赖一致性问题,但是幽灵依赖多种依赖仍然存在。

Pnpm 的出现解决了 npm 和 yarn 的上述问题。Pnpm 具有如下特点:

  • 安装速度更快
  • 节省磁盘空间
  • 安全性好
  • 支持 monorepos
  • 非平铺的 node_modules 结构

Pnpm 工作原理

硬链接和符号链接(软链接)

在介绍 pnpm 工作原理之前,我们需要先了解硬链接和符号链接(软链接)。假设存在一个文件 A,基于文件 A 创建一个硬链接的文件 B 和一个符号链接的文件 C。文件 A 和 文件 B 指向同一个文件在硬盘中的地址,所以修改 B ,A 的内容也会改变。而文件 C 中保存的是文件 A 的绝对路径,它本身就是一个文件,有独立的存储区块。

总结:

  • 硬链接: 与普通文件没什么不同,指向同一个文件在硬盘中的区块
  • 软链接: 保存了其代表的文件的绝对路径,是另外一种文件,在硬盘上有独立的区块,访问时替换自身路径。

5分钟让你明白“软链接”和“硬链接”的区别

软链接和硬链接

统一的依赖 store

当我们使用 npm 或者 yarn 时,如果有 100 个 react 项目,那么就会有 100 份 react 存在硬盘上。而使用 pnpm 时,所有的依赖会被存在一个统一的地方(store)。所以:

  1. 所有文件都会存储在硬盘上的某一位置。 当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖。

  2. 如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。 例如,如果某个包有100个文件,而它的新版本只改变了其中1个文件。那么 pnpm update 时只会向存储中心额外添加1个新文件,而不会因为仅仅一个文件的改变复制整新版本包的内容。

因此,在磁盘上节省了大量空间,并且安装速度也要快得多!

非扁平化的依赖结构

使用 npm 或 yarn 安装依赖项时,所有包都被提升到模块目录的根目录。 因此,项目可以访问到未被添加进当前项目的依赖。

Pnpm 使用软链的方式将项目的直接依赖添加进模块文件夹的根目录。那么如何解决依赖地狱问题呢?

Pnpm 会在 node_modules 文件夹下创建一个 .pnpm 文件夹,内部是扁平化的项目所有依赖,只有这里的依赖是实际存在的,通过硬链接指向 pnpm store。整体加载逻辑:

  • 项目中引入依赖(如:bar),这些依赖在 node_modules 的根目录下,否则无法引入。
  • 依赖 bar 通过符号链接指向 .pnpm 下的真实依赖(硬链接)
  • 模块 bar 依赖同一 node_modules 下的 foo
  • foo 通过符号链接指向 .pnpm 下的真实依赖(硬链接)

特别需要注意的是只有 .pnpm 根目录下的依赖真实存在(硬链接),其他的都是符号链接。另外有一个点可能比较奇怪:为什么 bar 和他的依赖 foo 会在同一个 node_modules 下?这个问题暂且不表,稍后再回答。

pnpm 构建上述依赖结构的整个过程如下:

基于符号链接的 node_modules 结构

假设安装依赖于 bar@1.0.0 的 foo@1.0.0。 pnpm 会将两个包硬链接到 node_modules 下的 .pnpm 文件夹,如下所示:

node_modules
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    │           ├── index.js
    │           └── package.json
    └── foo@1.0.0
        └── node_modules
            └── foo -> <store>/foo
                ├── index.js
                └── package.json

这是 node_modules 中的唯一的“真实”文件,硬链接到内容可寻址的 store。

你可能已经注意到,上面的依赖和它的依赖被放置在同一个 node_modules 文件夹下。这样做的目的:

接下来是符号链接依赖项。foo 依赖于 bar,因此 bar 被符号链接到 foo@1.0.0/node_modules 下。

node_modules
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    └── foo@1.0.0
        └── node_modules
            ├── foo -> <store>/foo
            └── bar -> ../../bar@1.0.0/node_modules/bar

最后,处理项目的直接依赖。 foo 被符号链接至 node_module 下。

node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    └── foo@1.0.0
        └── node_modules
            ├── foo -> <store>/foo
            └── bar -> ../../bar@1.0.0/node_modules/bar

假设 qar@2.0.0 作为 bar 和 foo 的依赖项。 新的结构:

node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       ├── bar -> <store>/bar
    │       └── qar -> ../../qar@2.0.0/node_modules/qar
    ├── foo@1.0.0
    │   └── node_modules
    │       ├── foo -> <store>/foo
    │       ├── bar -> ../../bar@1.0.0/node_modules/bar
    │       └── qar -> ../../qar@2.0.0/node_modules/qar
    └── qar@2.0.0
        └── node_modules
            └── qar -> <store>/qar

Pnpm 使用

虽然原理上相差很多,但是在使用上,pnpm 基本上和 npm 相似,几乎没有上手成本。

npm 命令pnpm 等效
npm installpnpm install
npm i [pnpm add ]
npm run [pnpm ]

有几个特殊的指令:

// 设置pnpm-store的位置
pnpm config set store-dir /path/to/.pnpm-store

// 从源中获取包而不将其安装为依赖项,热加载,并运行它公开的任何默认命令二进制文件。类似npx
pnpm dlx
pnpm dlx create-react-app ./my-app

// 切换 node 版本。类似 nvm 工具,无需另外安装
pnpm env use --global 16

局限性

  • 符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
  • 子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
  • 不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。
  • 目前 node 生态中有很多包存在幽灵依赖问题,从而导致在 pnpm 无法正常工作。

目前,pnpm 的坑已经趟得差不多了,不少项目也都迁移到了 pnpm。同时,为了你那少得可怜的计算机内存,可以考虑使用 pnpm 。

One More Thing: cnpm/tnpm

cnpm/tnpm 的依赖管理是借鉴了 pnpm ,通过符号链接方式创建非扁平化的 node_modules 结构,最大限度提高了安装速度。安装的依赖包都是在 node_modules 文件夹以包名命名,然后再做符号链接到 版本号 @包名的目录下。与 pnpm 不同的是,cnpm 没有使用硬链接,也未把子依赖符号链接到单独目录进行隔离。

node_modules
├── _foo@1.0.0
├── _bar@1.0.0
│   └── node_modules
│       └── foo -> ../_foo@1.0.0
└── foo -> ./_foo@1.0.0
└── bar -> ./_bar@1.0.0
          

此外,tnpm 新推出的 rapid 模式使用用户态文件系统(FUSE)对依赖管理做了一些新的优化,通过 FUSE 可以接管一个目录的文件系统操作逻辑。传送门:深入浅出 tnpm rapid 模式 - 如何比 pnpm 快 10 秒

一句话解释:直接把 node_modules 通过 FUSE + 依赖关系图 映射到 tar 归档文件,省去了解压的文件 IO。

核心点在在于:

  • 网络 IO:服务端生成依赖树,省去元数据请求。

  • 文件 IO:合并写入 tar,节省磁盘写入次数;FUSE 映射,不解压文件。

参考