pnpm 解决我哪些痛点?

11,472 阅读6分钟

前言

本文主要目的是帮助大家理解现代包管理工具——pnpm,同时会对 npm/yarn 总结一下潜在缺陷的结论,帮助大家了解到 pnpm 是如何解决 npm/yarn 的设计缺陷的,以及 pnpm 是如何改进的。

npm/yarn 依赖管理

开开胃,简单介绍一下 npm/yarn 在依赖管理方面存在的缺陷问题,方便后续了解 pnpm 特性。

早期的

使用早期的 npm1/2 安装依赖,node_modules 文件夹会以递归的形式呈现,严格按照 package.json 结构以及次级依赖的 package.json 结构将依赖安装到它们各自的 node_modules 中,直到次级依赖不再依赖其它模块。

就像下面这样,foo 依赖 bar 作为次级依赖,bar 会安装到 foo 的 node_modules 里面:

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

假设项目的中的两个依赖同时依赖了相同的次级依赖,那么它们二者的次级依赖将会被重复安装。

node_modules
├─ foo1
│  ├─ index.js
│  ├─ package.json
│  └─ node_modules
│      └─ bar
│          ├─ index.js
│          └─ package.json
└─ foo2
   ├─ index.js
   ├─ package.json
   └─ node_modules
       └─ bar
           ├─ index.js
           └─ package.json

这只是简单的例子,在真实的开发场景中其问题还会更加恶劣:

  • 依赖层级太深,会导致文件路径过长(在 Windows 系统下会出现一些问题

  • 重复的包被安装,导致 node_modules 文件体积巨大,占用过多的磁盘空间

转折点

自 npm3/yarn 开始,相比 npm1/2 项目依赖管理的方式有了很大的改变,不再是以往的“嵌套式”而是采用了“扁平化”方式去管理项目依赖。

这里继续拿上面的例子,foo1 和 foo2 都依赖了 bar,依赖安装后呈现的是下面的这种扁平化目录:

node_modules
├─ bar
│  ├─ index.js
│  └─ package.json
├─ foo1
│  ├─ index.js
│  └─ package.json
└─ foo2
   ├─ index.js
   └─ package.json

扁平化的目录的确解决了上一小节暴露的一些问题,同时也暴露了新的问题:

  • Phantom dependencies

称为“幽灵依赖”,指的是在项目内引用未在 package.json 中定义的包。这个问题在 npm3 展现,因为早期的树形结构导致了依赖冗余和路径过深的问题,npm3 之后采用扁平化的结构,一些第三方包的次级依赖提升到了与第三方包同级。

一旦出现幽灵依赖的问题,可能会导致意想不到的错误,所以一定要正视:

  • 不兼容的版本(例如某一个 api 进行了重大更新

  • 有可能会丢失依赖(某依赖不再依赖呈现在我们项目中的幽灵依赖

// bar 就属于是幽灵依赖,因为它是属于 foo1、foo2 的次级依赖。
import bar from 'bar';
  • NPM doppelgangers

称为“分身依赖”,在 monorepo 项目中非常常见,项目中依赖的第三方包以及第三方包所依赖的同名包都会被重复安装。

常见的问题:

  • 项目打包会将这些“重身”的依赖都进行打包,增加产物体积

  • 无法共享库实例,引用的得到的是两个独立的实例

  • 重复 TypeScript 类型,可能会造成类型冲突

在实际开发中也会出现这样的情景,假设 foo1、foo2、依赖 lodash@3.6.0,bar 依赖 lodash@4.5.0,这时候会造成依赖冲突,解决冲突的方式会将对应的冲突包放到对应依赖目录的 node_mudules 中,类似下面结构:

node_modules
├─ lodash@4.5.0
│  ├─ index.js
│  └─ package.json
├─ foo1
│  ├─ index.js
│  ├─ package.json
│  └─ node_modules
│      └─ lodash@3.6.0
│          ├─ index.js
│          └─ package.json
├─ foo2
│  ├─ index.js
│  ├─ package.json
│  └─ node_modules
│      └─ lodash@3.6.0
│          ├─ index.js
│          └─ package.json
└─ bar
   ├─ index.js
   └─ package.json

这时候你可能会发现一个问题,foo1、boo2 node_modules 下都有重复且版本相同的 lodash@3.6.0,这个问题就是我们正在所说的“分身依赖”的问题。

可能你还会有另外一个疑惑,什么不扁平 lodash@3.6.0,这样能减少一份所占用的空间,还能够解决“分身依赖”的问题。这是因为具体是扁平谁这是根据依赖的顺序决定的。因为开发者不关注依赖的顺序,所以存在很大的不确定性。

结论

  • 扁平化的 node_modules 结构允许访问没有在 package.json 中声明的依赖。
  • 安装效率低,大量依赖被重复安装,磁盘空间占用高。
  • 多个 FE 项目之间已经安装过的的包不能共享,每次都是重新安装。

主角登场

它是什么?

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

性能基准

先来看一下官方 benchmarks 对 npm、pnpm、yarn、yarnPnP 对多个情景下的性能基准测试,涵盖了很多使用场景:

  • clean install:全新安装,不包含锁文件,没有缓存,没有安装依赖。
  • with cachewith lockfilewith node_modules:第一次安装完成后,再次运行安装命令。
  • with cachewith lockfile:当开发人员获取 repo 并首次运行安装时。
  • with cache:与上面的相同,但包管理器没有可用的锁文件。
  • with lockfile:当安装在 CI 服务器上运行时。
  • with cachewith node_modules:锁文件被删除,再次运行安装命令。
  • with node_moduleswith lockfile:包缓存被删除,再次运行安装命令。
  • with node_modules:包缓存和锁文件被删除,再次运行安装命令。
  • update:通过更改版本 package.json 并再次运行安装命令来更新您的依赖项。

\

结合多个使用场景与官方产出的性能基准报告可以看到 pnpm 的效率要远远高于 npm/yarn。

这是我使用 pnpm 安装的,如下表格展示 pnpm 安装依赖后 node_modules 的目录大小,这效果是非常显著的。不仅仅是在依赖安装的效率方面要优于,在磁盘占用空间方面也大大的节省了,据官方数据统计 pnpm 相比 npm/yarn 效率要高于 2 倍。

yarnpnpm
EV 后台2.6GB1.6GB
运营后台1.4GB524MB

依赖安装

使用 pnpm 安装,pnpm 会将依赖存储在位于 ~/.pnpm-store 目录下。只要你在同一机器下,下次安装依赖的时候 pnpm 会先检查 store 目录,如果有你需要的依赖则会通过一个硬链接丢到到你的项目中去,而不是重新安装依赖。

也可以通过命令直接获取 store 目录所在的位置:pnpm`` store path

下面是包管理重复安装的输入结果:

pnpm 在输出易懂方面也略胜一筹,可以看到你复用了多少包和需要重新下载了多少包。反之 yarn 会是把所有关联的包全部陈列出来,但是这些很大概率我们都是不关心的。

$ pnpm i express

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

dependencies:
+ express 4.17.1
$ yarn add express
yarn add v1.22.11
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Saved lockfile.
success Saved 29 new dependencies.
info Direct dependencies
└─ express@4.17.1
info All dependencies
├─ accepts@1.3.7
├─ ...
└─ vary@1.1.2
✨  Done in 1.14s.
$ npm i express

npm WARN npm@1.0.0 No description
npm WARN npm@1.0.0 No repository field.

+ express@4.17.1
added 50 packages from 37 contributors and audited 50 packages in 4.309s
found 0 vulnerabilities

依赖管理原理

上面刚刚说了,使用 pnpm 安装,pnpm 会将依赖存储在 store 目录下。这个目录其实起到了很关键的作用,它所解决的是多个 FE 项目之间已经安装过的包不能共享与每次安装依赖都会重新安装的问题, 这样对于已经安装过的包能够直接在多个项目之间重复使用,而不是像 npm/yarn 每次都是去重新安装他们。

可以尝试看看下面的流程图,其中会有一些额外内容。可能会有一些不懂,别担心下面会重点介绍一下。

前置理解

在开始前理解 hard link 和 soft link 尤为重要,有助于理解 pnpm 是如何进行依赖复用和共享依赖的。

hard link & soft link

Linux 中包括两种链接:

  • 硬链接(hard link)

  • 软链接(soft link),软链接又称为符号链接(symbolic link)

inode

每一个文件都有一个唯一的 inode,它包含文件的元信息,在访问文件时,对应的元信息会被 copy 到内存去实现文件的访问。

可以通过 stat 命令去查看某个文件的元信息。

stat README.md
hard link

硬链接可以理解为是一个相互的指针,创建的 hardlink 指向源文件的 inode,系统并不为它重新分配 inode。

硬链接不管有多少个,都指向的是同一个 inode 节点,这意味着当你修改源文件或者链接文件的时候,都会做同步的修改。

每新建一个 hardlink 会把节点连接数增加,只要节点的链接数非零,文件就一直存在,不管你删除的是源文件还是 hradlink。只要有一个存在,文件就存在(类似引用计数的概念。

\

soft link

软链接可以理解为是一个单向指针,是一个独立的文件且拥有独立的 inode,永远指向源文件,这就类比于 Windows 系统的快捷方式。

hard link 机制

使用 pnpm 安装,pnpm 会在全局的 store 目录里存储项目的依赖,这点在上我们已经说过了。假设有这么一个情景,我拿到了一个新的项目,正准备安装项目所需要的依赖。pnpm 会这么做,如果 store 目录里面拥有即将需要下载的依赖,下载将会跳过,会向对应项目 node_modules 中去建立硬链接,并非去重新安装它。这里就表明为什么 pnpm 性能这么突出了,最大程度节省了时间消耗和磁盘空间。

基于软链接的 node_modules

pnpm 输出的 node_modules 与 npm/yarn 有很大的出入,并非是先者那样的“扁平化目录”而是“非扁平化目录”。听起来是不是很匪夷所思,那我们就继续吧。

node_modules
├─ .pnpm
│   └─ dayjs@1.10.7
└─ dayjs

呐,就像上面这样。可能你会有一个疑问,这样的设计会来什么额外的收益?到此你需要知道 pnpm 的 node_modules 目录使用软链接来创建依赖项的嵌套结构以及引用目标,.pnpm 中的每个文件都是来自内容可寻址存储的硬链接, 就像下面这样:

.pnpm 里面全部都是我们项目所需要的依赖,它们是真实文件,唯一不同的它们是来自 store 目录的硬链接(如果不理解硬链接,请阅读“前置理解”节点。

node_modules
└─ .pnpm
    └─ dayjs@1.10.7
        └─ node_modules
            └─ dayjs -> <store>/dayjs
                ├─ index.js
                └─ package.json

如果你打开 .pnpm 目录你会发现这些依赖都被“扁平化”了,但是很奇怪,它们都携带着自己的版本号。pnpm 这样设计的目的我理解其实是为了解决“分身依赖”的问题。

假设我们有这么一个情景,项目中依赖了 foo@1.0.0、bar@1.0.0。bar 也依赖了 foo@1.0.0 那它引用关系是这样的:

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

那么 .pnpm 目录的作用以及特点我们理解了,.pnpm 目录之外的呢?其实也好理解,目录之外的其实是我们在日常开发中实际引用的是依赖,但是它对于 pnpm 来说它是软链接,最终软链接实际引用的还是 .pnpm 中的真实依赖。

我们可以用命令在终端列举出这些软链接具体的指向:

进入 node_modules,在执行命令

ls -al

让我们来想想为什么需要通过软链接的方式去引用实际的依赖呢。其实这样设计的目的是解决“幽灵依赖”的问题,只有声明过的依赖才会以软链接的形式出现在 node_modules 目录中。在实际项目中引用的是软链接,软链接指向的是 .pnpm 的真实依赖,所以在日常开发中不会引用到未在 package.json 声明的包。

Workspace

现代前端工程中居多都是使用 Lerna 管理 monorepo 类型的项目,每个人都清楚它的作用,而 pnpm 也是对此进行了友好的支持。与 Lerna 不同的是 pnpm 使用特殊的包选择器语法限制命令,不像 Lerna 那样需要很长难记的命令去标识。

一个 monorepo 工程,目录中必须要拥有管理工作区的配置文件,相比其它包管理工具的工作区文件其实都大同小异,之后会介绍一些常用于管理 monorepo 的命令。

packages:
  # 所有在 packages/ 和 components/ 子目录下的 package
  - 'packages/**'
  - 'components/**'
  # 不包括在 test 文件夹下的 package
  - '!**/test/**'
  • 精确选择一个 repo <@scope/package>,或选择一组 repo <@scope/*>,再或者相对路径选择。
pnpm dev --filter @byted-ehi/basic-list
pnpm dev --filter apps/*
pnpm dev --filter ./apps/admin-order-manage
  • 选择一个 repo 以及所属 repo 的依赖项,例如:会运行 basic-list 下的所有依赖的 dev
pnpm dev --filter @byted-ehi/basic-list...
  • 只选择某个 repo 的依赖项,与上面的区别是不包含 repo。例如:会运行 repo 下所有依赖的 dev,不包含repo 本身。
pnpm dev --filter @byted-ehi/basic-list^...
  • 选择指定目录下的所有 repo。
pnpm dev --filter ./apps

那关于 pnpm 是如何管理 monorepo 项目的,我们就先介绍到这里。官网还有一些比较针对一些特性场景的命令方式,可以自行去查阅。其实将 pnpm 作为项目的 monorepo 管理,也不输于 Lerna,反而在使用上有些舒服。不需要记住一大坨命令,而 pnpm 你只需要记住 --filter 就能够满足绝大多数的开发场景。

pnpm 锁文件

pnpm 产出的是一个 pnpm-lock.yaml 格式的锁文件,与 npm/yarn 区别不是很大,只是格式的问题。阅读官网发现 pnpm 提供了命令支持根据项目原有的锁文件产出符合 pnpm 格式的锁文件,这样避免迁移到 pnpm 会导致一些固定依赖版本发生变更的问题。

支持的锁文件:

  • packag-lock.json
  • npm-shrinkwrap.json
  • yarn.lock
pnpm import

基本命令

整章下来可能感觉 pnpm 给人非常复杂的感觉,但是在实际使用起来反而相反,几乎不需要任何成本,你也能很快上手 pnpm。

$ pnpm install express

$ pnpm update express

$ pnpm remove express

$ pnpm list

$ pnpm run <scripts>

$ pnpm publish

额外补充

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

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

感官

pnpm 可以称得上是高性能的新一代包管理工具,解决了 npm/yarn 潜在的一些问题。最后希望看这篇文章的你可以去动手实践一下 pnpm,了解一下它真实的特性能力。

看到这里了你可以回顾一下上面提到的 pnpm 依赖管理的原理图,去理解它:

最后,希望这篇文章对你有收获~

参考

pnpm.io/zh/

pnpm.io/zh/limitati…

www.takeshape.io/articles/wh…

javascript.plainenglish.io/an-abbrevia…

javascript.plainenglish.io/what-is-pnp…