包管理工具之从NPM到PNPM

2024-01-12  本文已影响0人  DLLCNX

前端包管理工具之从NPM到PNPM | DLLCNX的博客

近几年的前端开发者肯定了解npm是什么,而且使用过程中也开始接触yarnpnpm等等。那么到底为什么会有这么多的包管理工具,它们好像可以交替使用,但是好像又有点不同。我这边按照我的理解,整理搜索一下包管理器发展的历史。

一、包管理工具的发展

2010 年 1 月,一款名为 npm 的包管理器诞生。它确立了包管理器工作的核心原则。

npm 的发布诞生了一场革命,在此之前,项目依赖项都是手动下载和管理的。npm 引入了文件和元数据字段,将依赖项列表存储在 package.json 文件中,并且将下载的文件保存到 node_modules 文件夹中。

随后,又因为npm的缺陷或者旧版本的不足,大牛们造出一个又一个替代npm来进行包管理的轮子,例如:yarnyarn2pnpm等等。

1.1 NPM

是 Node.js 自带的包管理工具,也是最常用的包管理工具之一。它可以方便地安装、升级、卸载依赖包,还可以发布自己的包到 NPM 仓库。

很多人认为 npm 是 node package manager 的缩写,但其实不是,官方对此有过辟谣。

image.png

npm已经迭代过很多版本,也尝试解决大家对它缺陷或者不足的吐槽。

1.1.1 npm v1 & v2

此时期主要是采用简单的递归依赖方法,最后形成高度嵌套的依赖树。这种模式虽然模块依赖关系比较清晰,但是造成的问题更大。

例如: 项目依赖了A@1.0和 B@1.0,而 A@1.0 和 B@1.0依赖了不同版本的 C@1.0 和 C@2.0,node_modules 结构如下:

注意:为了方便,后边版本号都简单以两位数字表示,不是正确的语义化版本号

node_modules
├── A@1.0
│   └── node_modules
│       └── C@1.0
└── B@1.0
    └── node_modules
        └── C@2.0

在我们真实使用过程中,随着依赖的增多,重复冗余的包会越来越多,最终,node_modules会大量的占用磁盘。而且依赖嵌套的深度也会十分可怕,这个就是我们常说的依赖地狱(Dependency Hell)

1.1.2 npm v3

npm v3 版本作了较大的更新,开始采取扁平化的依赖结构。

为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余,npm v3 将子依赖「提升」(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。

我们继续以上面的案例为例:

node_modules
├── A@1.0
├── B@1.0
    └── node_modules
        └── C@2.0
└── C@1.0

可以看到v3的版本中, A@1.0 的子依赖的 C@1.0 不再放在 A 1.0的 node_modules 下了,而是与 A@1.0 同层级。

而 B@1.0 依赖的 C@2.0 因为版本号原因还是嵌套在 B@1.0 的 node_modules 下。

这样的依赖结构可以很好的解决重复依赖的依赖地狱问题,层级也不会太深,但也形成了新的问题。

// package.json dependencies
{
  "dependencies": {
    "A": "^1.0",
    "B": "^1.0"
  }
由于 C@1.0 在安装时被提升到了和 A 1.0同样的层级,所以在项目中引用 C@1.0 还是能正常工作的。

幽灵依赖是由依赖的声明丢失造成的,如果某天某个版本的 A 依赖不再依赖 C@1.0 或者 C@1.0的版本发生了变化,那么就会造成依赖缺失或兼容性问题。
node_modules
├── A@1.0
├── B@1.0
│   └── node_modules
│       └── C@2.0
├── C@1.0
├── D@1.0
└── E@1.0
    └── node_modules
        └── C@2.0
可以看到 C@2.0 会被安装两次,实际上无论提升 C@1.0 还是 C@2.0,都会存在重复版本的 C 被安装,这两个重复安装的 C 就叫 `依赖分身 doppelgangers`。这会导致一些隐含的问题:

**1. 破坏单例模式**:假如模块B、E中引入了模块C2.0中导出的一个单例对象,但其实引用的不是同一个 C2.0,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行缓存或副作用操作,就会产生问题。

**2. types冲突**:虽然各个package的代码不会相互污染,但是他们的types仍然可以相互影响,因此版本重复可能会导致全局的types命名冲突。

当时npm还没解决这些问题的时候,社区就出来一个新的解决方案了,那就是 yarn

1.1.3 npm v5

为了解决上面出现的扁平化依赖算法耗时长问题,npm 引入 package-lock.json 机制,package-lock.json 的作用是锁定项目的依赖结构,保证依赖的稳定性。

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

感兴趣的话可以具体看看官方文档:package.json | npm Docs (npmjs.com)

注:其实在 package-lock.json 机制出现之前,可以通过 npm-shrinkwrap 实现锁定依赖结构,但是 npm-shrinkwrap 默认关闭,需要主动执行。

一致性

考虑上文案例,初始时安装生成package-lock.json如左图所示,depedencies对象中列出的依赖都是提升的,每个依赖项中的requires对象中为子依赖项。此时更新A依赖到2.0版本,如右图所示,并不会改变提升的子依赖版本。因此重新生成的node_modules目录结构将不会发生变化。

image.png
兼容性

语义化版本(Semantic Versioning)

依赖版本兼容性就不得不提到npm使用的SemVer版本规范,版本格式如下:

image.png

在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围,语义化版本范围规定:

语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此package-lock.json给每个模块子依赖标明了确定的版本,避免不兼容问题的产生。

1.2 Yarn

1.2.1 yarn

2016 年,yarn 发布0.x版本,随后迭代正式版本1.x,yarn 也采用扁平化 node_modules 结构。它的出现是为了解决 npm v3 几个最为迫在眉睫的问题:依赖安装速度慢,不确定性

yarn的一些特性是走在npm的前边的。 yarn 出现时,此时 npm 处于 v3 时期,其实当时 yarn 解决的问题基本就是 npm v5 解决的问题,包括使用 yarn.lock 等机制,锁定版本依赖,实现并发网络请求,最大化网络资源利用率,其次还有利用缓存机制,实现了离线模式

与npm v5之后推出的package-lock.json不同,yarn并没有采用JSON格式的文件,而是使用了自定义的格式,名字就叫做yarn.lock,与前者不同,后者的lockfile目录结构并不能复制出完完全全一样的node_modules拓扑结构,他只是把依赖到的所有库 flat 成根目录级别,这样更方便做diff。

安装速度
lockfile

yarn 更大的贡献是发明了 yarn.lock。

在依赖安装时,会根据 package.josn 生成一份 yarn.lock 文件。

lockfile 里记录了依赖,以及依赖的子依赖,依赖的版本,获取地址与验证模块完整性的 hash。

即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都能得到稳定的 node_modules 目录结构,保证了依赖安装的确定性。

所以 yarn 在出现时被定义为快速、安全、可靠的依赖管理。而 npm 在一年后的 v5 才发布了 package-lock.json。

其实后面npm v5上能看到 yarn 的机制的影子,上面的机制目前 npm 基本也都实现了,就目前而言 npm 和 yarn 其实并没有差异很大,具体使用 npm 还是 yarn 可以看个人需求。

弊端

yarn 依然和 npm 一样是扁平化的 node_modules 结构,并没有解决幽灵依赖依赖分身问题。

1.2.2 yarn v2(yarn berry)

在 pnpm 之后, yarn 感受到了对手的挑战,于是在 2020 年, yarn 2诞生了

yarn 2(也叫yarn berry,yarn 1 也叫 yarn classic)。它是对 yarn 的一次重大升级,其中一项重要更新就是 Plug’n’Play(Plug'n'Play = Plug and Play = PnP,即插即用)。

尽管yarn1看似并没有对node_modules作出太大改动,但是他们的团队并不是没有意识到node_modules的缺憾,他们做出了Plug’n’Play的尝试。npm 与 yarn 的依赖安装与依赖解析都涉及大量的文件 I/O,效率不高。开发 Plug’n’Play 最直接的原因就是依赖引用慢,依赖安装慢。

首先node_modules本身的局限性在于解析、安装依赖时产生的大量IO操作:

  1. 解析:当require某个第三方文件时,首先在当前目录寻找node_modules,找不到再去父级,找到之后,再去这个node_modules的子目录去寻找,直到找到该文件。因为node不认识包,只认识文件,而node_moduls的设计也就注定了他不允许包管理工具正确的删除重复的包数据
  2. 安装:解析出某个具体的版本号,下载 tar 包到离线镜像,从镜像解压到本地缓存;从缓存拷贝到node_modules,即使是pnpm的hard link,也只是优化了最后一步。

因此,berry做出了修改,与其让node去查找软件包,不如直接简明扼要的告诉node应该在哪里找到这个包。Plug’n’Play特性应运而生,他其实是省略了node_modules的拷贝,转而生成了一个.pnp.js的文件去记录包的版本,以及映射到的磁盘位置,即把每个包看作整体,压缩成一个zip;一个.yarn文件夹,里面又有cacheunplugged目录,前者存放压缩过的依赖包,后者可以通过unplugin指令解压某个想要手动修改的包。

berry一定程度解决了一些问题:

  1. 之前介绍的npm存在的两个问题,berry因为不会生成node_modules目录,因此不存在phantom dependency的问题,同时他采用的.pnp.js的静态映射而不是copy的方式也避免了重复安装依赖的问题。
  2. 基于 .pnp.js 和 zip loading 实现的零安装,即将.pnp.js及.yarn文件夹全部上传至gitlab,在有些情况下是可行的,但是这里使用 create-react-app 进行实测,yarn 为144Mb,berry为62Mb,只是正常的压缩体积的优化;随后拿React,Vue等包做了下实验,也基本都是这个比例(7.9Mb VS 5.1Mb)(17Mb VS 8.5Mb)。
  3. 最后一点说一下一些新的特性,如插件机制,方便我们在对berry的核心代码并不熟悉的情况下开发基于berry的扩展功能,官方实现的官方实现的typescript插件,在yarn add 时自动添加@types等。

当然也存在一些问题,最明显的就是首次安装依赖的时间并没有感觉到缩短,其次还有上面所说的.yarn/cache到底要不要放到远程仓库中也是有待商榷的事。

berry的改变

  1. 抛弃 node_modules

无论是 npm 还是 yarn,都具备缓存的功能,大多数情况下安装依赖时,其实是将缓存中的相关包复制到项目目录中 node_modules 里。而 yarn PnP 则不会进行拷贝这一步,而是在项目里维护一张静态映射表 pnp.cjs。

pnp.cjs 会记录依赖在缓存中的具体位置,所有依赖都存在全局缓存中。同时自建了一个解析器,在依赖引用时,帮助 node 从全局缓存目录中发现依赖,而不是查找 node_modules。

这样就避免了大量的 I/O 操作同时项目目录也不会有 node_modules 目录生成,同版本的依赖在全局也只会有一份,依赖的安装速度和解析速度都有较大提升。

注:pnpm 在 2020 年底的 v5.9 也支持了 PnP

  1. 脱离 node 生态

pnp 比较明显的缺点是脱离了 node 生态。

1.3 pnpm

pnpm - performant npm,在 2017 年正式发布,定义为快速的,节省磁盘空间的包管理工具,开创了一套新的依赖管理机制,成为了包管理的后起之秀。

与依赖提升和扁平化的 node_modules 不同,pnpm 引入了另一套依赖管理策略:内容寻址存储。该策略会将包安装在系统的全局 store 中,依赖的每个版本只会在系统中安装一次。

内容寻址存储

pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这样可以做到不会出现重复安装,在项目中需要使用到依赖的时候,pnpm 只会安装一次,之后再次使用都会直接硬链接指向该依赖,极大节省磁盘空间,并且加快安装速度。

注:硬链接是多个文件名指向同一个文件的实际内容,而软链接(符号链接)是一个独立的文件,指向另一个文件或目录的路径

在引用项目 node_modules 的依赖时,会通过硬链接与符号链接在全局 store 中找到这个文件。为了实现此过程,node_modules 下会多出 .pnpm 目录,而且是非扁平化结构。

还是使用上面 A,B,C 模块的示例,使用 pnpm 安装依赖后 node_modules 结构如下:

node_modules
├── .pnpm
│   ├── A@1.0
│   │   └── node_modules
│   │       ├── A => <store>/A@1.0
│   │       └── B => ../../B@1.0
│   ├── B@1.0
│   │   └── node_modules
│   │       └── B => <store>/B@1.0
│   ├── B@2.0
│   │   └── node_modules
│   │       └── B => <store>/B@2.0
│   └── C@1.0
│       └── node_modules
│           ├── C => <store>/C@1.0
│           └── B => ../../B@2.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C

<store>/xxx 开头的路径是硬链接,指向全局 store 中安装的依赖。

其余的是符号链接,指向依赖的快捷方式。

未来可期

这套全新的机制设计地十分巧妙,不仅兼容 node 的依赖解析,同时也解决了:

  1. 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。
  2. 依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。

同时,由于链接的优势,pnpm 的安装速度在大多数场景都比 npm 和 yarn 快 2 倍,节省的磁盘空间也更多。

但是,其实这种模式也存在一些弊端:

  1. 由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用。
  2. 因为依赖源文件是安装在 store 中,调试依赖或 patch-package 给依赖打补丁也不太方便,可能会影响其他项目。

扩展

也许有人说 yarn 默认也是扁平化安装方式,但是 yarn 有独特的 PnP 安装方式,可以直接去掉 node_modules,将依赖包内容写在磁盘,节省了 node 文件 I/O 的开销,这样也能提升安装速度,但是 yarn PnP 和 pnpm 机制是不同的,且总体来说安装速度 pnpm 是要快于 yarn PnP 的,详情请看下面官方文档

最后就是 pnpm 是默认支持 monorepo 多项目管理的,在日渐复杂的前端多项目开发中尤其适用,也就说我们不再需要 lerna 来管理多包项目,可以使用 pnpm + Turborepo 作为我们的项目管理环境

配置工作空间官方文档:工作空间(Workspace) | pnpm

image.png

还有就是 pnpm 还能管理 nodejs 版本,可以直接替代 nvm,命令如下所示

# 安装 LTS 版本
pnpm env use --global lts
# 安装指定版本
pnpm env use --global 16

1.4 总结

pnpm 起初看起来像 npm,因为它们的 CLI 用法相似,但管理依赖项却大不相同;pnpm 的方法带来更好的性能和最佳的磁盘空间效率。Yarn Classic 仍然很受欢迎,但它被认为是遗留软件,并且在不久的将来可能会放弃支持。Yarn Berry PnP 是新贵,但尚未看到它彻底改变包管理器领域的潜力。

目前还没有完美的依赖管理方案,可以看到在依赖管理的发展过程中,出现了:

库与开发者能够在这样优化与创新的发展过程中互相学习,站在巨人的肩膀上继续前进,不断推动前端工程领域的发展。

多年来,许多用户询问谁使用哪些包管理器,总体而言,人们似乎对 Yarn Berry PnP 的成熟度和采用特别感兴趣。但是国内我们能看到,pnpm似乎更受欢迎。

二、时间线梳理

请注意,以上只是列举了一些比较重要或者具有改革意义的主要版本,每个包管理器的发布策略可能会因实际情况而有所不同。此外,还有其它版本以及每个主要版本下可能还有许多次要版本和修订版本。

我试图严格的按发布顺序来完整展示几大包管理工具的历史,但是失败了,因为每个管理器对于包的版本定义以及小版本迭代,还有对发布测试版本还是正式版本为准定义不同,信息比较混乱,放弃了,也没太大意义,因为上面列举的是我们了解比较代表性的版本。下面十一张网图:

image.png

下面是chatGPT给的一种可能的排序方式,但是它也提示可能会因实际发布策略而有所不同,如果您需要确切的版本发布日期,请参阅官方文档、存储库或相应的发布历史记录,以获取最准确和最新的信息。

几大包管理工具更多版本大体的发布顺序如下:

  1. npm 1.x(2010年)
  2. Yarn 0.x(2016年)
  3. pnpm 1.x(2016年)
  4. npm 2.x(2014年)
  5. npm 3.x(2015年)
  6. Yarn 1.x(2017年)
  7. npm 4.x(2016年)
  8. npm 5.x(2017年)
  9. pnpm 2.x(2018年)
  10. npm 6.x(2018年)
  11. Yarn 2.x(2020年)
  12. npm 7.x(2020年)
  13. pnpm 3.x(2020年)
  14. ...

我们其实可以看到版本已经迭代了很多,但是以上列举的是比较能代表包管理工具从诞生,到改进,互相学习又改革的大体流程。

三、pnpm迁移

迁移过程中主要有如下问题:因为使用 npm 或 yarn 安装依赖项时,所有包都被提升到模块目录的根目录。因此,源代码可以访问未作为依赖项添加到项目的依赖项。但是默认情况下,pnpm 使用链接仅将项目的直接依赖项添加到模块目录的根目录中

这意味着如果 package.json 没有引用的依赖,那么它将无法解析。这是迁移中的最大障碍。可以使用 auto-install-peers设置自动执行此操作(默认情况下是false)

对于多个使用 npm 安装依赖的项目,单独删除依赖包很耗时间,我们可以使用 npkill ,该工具可以列出系统中的任何 node_modules 目录以及它们占用的空间。然后可以选择要删除的依赖以释放空间

image.png

迁移流程

首先全局安装包

npm i -g pnpm

迁移步骤如下

1.首先使用 npkill 删除 node_modules 依赖包

2.项目根目录创建 .npmrc,填写如下内容

auto-install-peers=true

3.导入依赖锁定文件(pnpm-lock.yaml)

保证根目录有如下依赖锁定文件(npm-shrinkwrap.json,package-lock.json,yarn.lock)

然后执行如下命令

pnpm import pnpm-lock.yaml

4,最后执行 pnpm i 安装依赖

问题

生成依赖文件警告

官方 issue 解释: Unmet peer dependencies and The command -- pnpm/pnpm (github.com)

生成 pnpm-lock.yaml 文件时出现如下警告

 WARN  Issues with peer dependencies found
.
└─┬ vuepress 1.9.9
  └─┬ @vuepress/core 1.9.9
    └─┬ vue-loader 15.10.1
      └─┬ @vue/component-compiler-utils 3.3.0
        └─┬ consolidate 0.15.1
          ├── ✕ unmet peer react-dom@^16.13.1: found 15.7.0
          └── ✕ unmet peer react@^16.13.1: found 15.7.0

这是因为在 npm 3 中,不会再强制安装 peerDependencies (对等依赖)中所指定的包,而是通过警告的方式来提示我们。pnpm 会在全局缓存已经下载过的依赖包,如果全局缓存的依赖版本与项目 package.json 中指定的版本不一致,就会出现这种 hint 警告

我们可以在项目的 package.json 中配置 peerDependencyRules 忽略对应的警告提示

{
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": [
        "react"
      ]
    }
  }
}

或者说直接在 .npmrc 配置文件中直接关闭严格的对等依赖模式,可以添加 strict-peer-dependencies=false 到配置文件中,或者执行如下命令

npm config set strict-peer-dependencies=false

然后也可能会出现警告 deprecated subdependencies found,暂时可以忽略

幽灵依赖问题

在最后安装依赖的时候可能会出现幽灵依赖问题,幽灵依赖就是没有在package.json中,但是项目中,或者引用的包中使用到的依赖

举个例子,比如我们现在使用 npm 安装了 v-viewer 依赖,同时 viewerjsv-viewer 的依赖项,由于扁平化依赖机制,我们可以在 node_modules/v-viewer/package.json 中看到声明的 viewerjs 依赖,即使项目根目录下的 package.json 没有声明 viewerjs 依赖,我们仍旧可以使用,这就是幽灵依赖

而现在我们切换为 pnpm 后,在默认情况下不允许访问未声明的依赖,有以下两种解决方案

1.自行安装未声明依赖项

幽灵依赖自动扫描工具:@sugarat/ghost - npm (npmjs.com)

pnpm i -S viewerjs

或者说某些版本 pnpm 会自动爆出幽灵依赖错误 missing peer ...,也可以直接不使用上面的扫描工具,直接自行安装后面的 ... 依赖

2.找到.npmrc 文件,在其中配置 public-hoist-pattern 或者 shamefully-hoist 字段,将依赖提升到根node_modules 目录下解决,也就是所谓的依赖提升

依赖提升官方文档:.npmrc | pnpm

# .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 的老路。

四、参考文章

pnpm、npm、yarn 包管理工具『优劣对比』及『环境迁移』 - 知乎 (zhihu.com)

深入浅出 npm & yarn & pnpm 包管理机制-CSDN博客

yarn yarn2 and pnpm的一些总结 | 码农家园 (codenong.com)

上一篇下一篇

猜你喜欢

热点阅读