2

一、包管理工具的发展

2010 年 1 月,一款名为 npm 的包管理器诞生。它确立了包管理器工作的核心原则。
npm 的发布诞生了一场革命,在此之前,项目依赖项都是手动下载和管理的。npm 引入了文件和元数据字段,将依赖项列表存储在 package.json 文件中,并且将下载的文件保存到 node_modules 文件夹中。
后来因为 npm 的缺陷或者旧版本的不足,又出现了一个个替代 npm 来进行包管理的轮子,例如:yarn,yarn2,pnpm等。

1.1 NPM

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

1.1.1 npm v1 & v2

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

  • 重复依赖嵌套地狱,空间资源浪费:大量重复的包被安装,文件体积超级大
  • 安装速度过慢文件路径过长:尤其在 window 系统下,路径过长会导致爆错,最多260多个字符。
  • 模块实例不能共享:虽然安装的是两个相同并且版本也相同的依赖包,但在两个不同包引入的不是同一个模块实例,因此无法共享内部变量和生命周期,导致一些不可预知的 bug。

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

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

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

1.1.2 npm v3

npm v3 版本作了较大的更新,开始采取扁平化的依赖结构。为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余。
npm v3 将子依赖「提升」,采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。我们继续以上面的案例为例:node_modules:

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

可以看到 v3 的版本中, A@1.0 和 D@1.0 的子依赖的 C@1.0 不再放在各自的 node_modules 下了,而是与 A、D 同层级。而 B@1.0 依赖的 C@2.0 因为版本号原因还是嵌套在 B@1.0 的node_modules 下。
优势:这样的依赖结构可以很好的解决重复依赖的依赖地狱问题,层级也不会太深。
但也形成了新的问题:

  • 扁平化依赖算法耗时长:npm@3 wants to be faster
  • 幽灵依赖 问题:在 package.json 中未定义的依赖,但项目中依然可以正确地被引用到。

    • 比如上方的示例其实我们项目只安装了 A@1.0 和 B@1.0,C@1.0其实是A@1.0的依赖,由于 C@1.0 在安装时被提升到了和 A 1.0同样的层级,所以在项目中引用 C@1.0 还是能正常工作的。
    • 幽灵依赖是由依赖的声明丢失造成的,如果某天某个版本的 A、D 依赖不再依赖 C@1.0 或者 C@1.0的版本发生了变化,那么就会造成依赖缺失或兼容性问题。
    // package.json dependencies
    {
    "dependencies": {
      "A": "^1.0",
      "B": "^1.0"
    }
  • 不确定性:同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构。

    • 还是之前的例子,A@1.0 依赖 C@1.0,B@1.0依赖 C@2.0,依赖安装后究竟应该提升 C 的 1.0 还是 2.0 ?这取决于用户的安装顺序。
    • 如果有 package.json 变更,本地需要删除 node_modules 重新 install,否则可能会导致生产环境与开发环境 node_modules 结构不同,代码无法正常运行。
  • 依赖分身:假设继续再安装依赖 C@1.0 的 D 模块和依赖 C@2.0 的 E 模块,此时:A 和 D 依赖 C@1.0,B 和 E 依赖 C@2.0。可以看到 C@2.0 会被安装两次,实际上无论提升 C@1.0 还是 C@2.0,都会存在重复版本的 C 被安装,这两个重复安装的 C 就叫 依赖分身。以下是提升 C@1.0 的 node_modules 结构:node_modules,这会带来一些问题:

      1. 破坏单例模式:假如模块B、E中引入了模块 C2.0 中导出的一个单例对象,但其实引用的不是同一个 C2.0,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行缓存或副作用操作,就会产生问题。
      1. types冲突:虽然各个 package 的代码不会相互污染,但是他们的 types 仍然可以相互影响,因此版本重复可能会导致全局的 types 命名冲突。
    ├── 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

    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-lock.json 机制出现之前,可以通过 npm-shrinkwrap 实现锁定依赖结构,但是 npm-shrinkwrap 默认关闭,需要主动执行。

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
安装速度

  • 并行:在 npm 中安装依赖时,安装任务是串行的,会按包顺序逐个执行安装,这意味着它会等待一个包完全安装,然后再继续下一个。为了加快包安装速度,yarn 采用了并行操作,在性能上有显著的提高。
  • 离线缓存:像npm一样,yarn使用本地缓存。与npm不一样的是,yarn的缓存机制是将每个包缓存在磁盘上,在下一次安装这个包时,无需互联网链接就能安装本地缓存的依赖项,它提供了离线模式。这个功能在2012年的npm项目中就被提出来过,但一直没有实现。

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 操作

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

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

  • 之前介绍的 npm 存在的两个问题,berry 因为不会生成 node_modules 目录,因此不存在幽灵依赖的问题,同时他采用的 .pnp.js 的静态映射而不是 copy 的方式也避免了重复安装依赖的问题。
  • 基于 .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)。
  • 最后一点说一下一些新的特性,如插件机制,方便我们在对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
  2. 脱离 node 生态
    pnp 比较明显的缺点是脱离了 node 生态。因为使用 PnP 不会再有 node_modules 了,但是 Webpack,Babel 等各种前端工具都依赖 node_modules。虽然很多工具比如 pnp-webpack-plugin 已经在解决了,但难免会有兼容性风险。PnP 自建了依赖解析器,所有的依赖引用都必须由解析器执行,因此只能通过 yarn 命令来执行 node 脚本。

    1.3 pnpm

    pnpm - performant npm,在 2017 年正式发布,定义为快速的,节省磁盘空间的包管理工具,开创了一套新的依赖管理机制,成为了包管理的后起之秀。
    与依赖提升和扁平化的 node_modules 不同,pnpm 引入了另一套依赖管理策略:内容寻址存储。该策略会将包安装在系统的全局 store 中,依赖的每个版本只会在系统中安装一次。
    内容寻址存储pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这样可以做到不会出现重复安装,在项目中需要使用到依赖的时候,pnpm 只会安装一次,之后再次使用都会直接硬链接指向该依赖,极大节省磁盘空间,并且加快安装速度。

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

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

  • 硬链接 Hard link:硬链接可以理解为源文件的副本,项目里安装的其实是副本,它使得用户可以通过路径引用查找到全局 store 中的源文件,而且这个副本根本不占任何空间。同时,pnpm 会在全局 store 里存储硬链接,不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间。
  • 符号链接 Symbolic link:也叫软连接,可以理解为快捷方式,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 的依赖解析,同时也解决了:

  • 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖。
  • 依赖分身问题:相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。同时,由于链接的优势,pnpm 的安装速度在大多数场景都比 npm 和 yarn 快 2 倍,节省的磁盘空间也更多。

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

  • 由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用。
  • 因为依赖源文件是安装在 store 中,调试依赖或 patch-package 给依赖打补丁也不太方便,可能会影响其他项目。
    扩展
    也许有人说 yarn 默认也是扁平化安装方式,但是 yarn 有独特的 PnP 安装方式,可以直接去掉 node_modules,将依赖包内容写在磁盘,节省了 node 文件 I/O 的开销,这样也能提升安装速度,但是 yarn PnP 和 pnpm 机制是不同的,且总体来说安装速度 pnpm 是要快于 yarn PnP 的,详情请看下面官方文档。最后就是 pnpm 是默认支持 monorepo 多项目管理的,在日渐复杂的前端多项目开发中尤其适用,也就说我们不再需要 lerna 来管理多包项目,可以使用 pnpm + Turborepo 作为我们的项目管理环境配置工作空间官方文档:工作空间(Workspace) | pnpm
    图片

还有就是 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 是新贵,但尚未看到它彻底改变包管理器领域的潜力。
目前还没有完美的依赖管理方案,可以看到在依赖管理的发展过程中,出现了:

  • 不同的 node_modules 结构,有嵌套,扁平,甚至没有 node_modules,不同的结构也伴随着兼容与安全问题。
  • 不同的依赖存储方式来节约磁盘空间,提升安装速度。每种管理器都伴随新的工具和命令,不同程度的可配置性和扩展性,影响开发者体验。
  • 这些包管理器也对 monorepo 有不同程度的支持,会直接影响项目的可维护性和速度。

库与开发者能够在这样优化与创新的发展过程中互相学习,站在巨人的肩膀上继续前进,不断推动前端工程领域的发展。
多年来,许多用户询问谁使用哪些包管理器,总体而言,人们似乎对 Yarn Berry PnP 的成熟度和采用特别感兴趣。但是国内我们能看到,pnpm似乎更受欢迎。

二、时间线梳理

请注意,以上只是列举了一些比较重要或者具有改革意义的主要版本,每个包管理器的发布策略可能会因实际情况而有所不同。此外,还有其它版本以及每个主要版本下可能还有许多次要版本和修订版本。我试图严格的按发布顺序来完整展示几大包管理工具的历史,但是失败了,因为每个管理器对于包的版本定义以及小版本迭代,还有对发布测试版本还是正式版本为准定义不同,信息比较混乱,放弃了,也没太大意义,因为上面列举的是我们了解比较代表性的版本。下面十一张网图:
图片
下面是chatGPT给的一种可能的排序方式,但是它也提示可能会因实际发布策略而有所不同,如果您需要确切的版本发布日期,请参阅官方文档、存储库或相应的发布历史记录,以获取最准确和最新的信息。
几大包管理工具更多版本大体的发布顺序如下:
npm 1.x(2010年)
Yarn 0.x(2016年)
pnpm 1.x(2016年)
npm 2.x(2014年)
npm 3.x(2015年)
Yarn 1.x(2017年)
npm 4.x(2016年)
npm 5.x(2017年)
pnpm 2.x(2018年)
npm 6.x(2018年)
Yarn 2.x(2020年)
npm 7.x(2020年)
pnpm 3.x(2020年)

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

三、pnpm迁移

迁移过程中主要有如下问题:因为使用 npm 或 yarn 安装依赖项时,所有包都被提升到模块目录的根目录。因此,源代码可以访问未作为依赖项添加到项目的依赖项。但是默认情况下,pnpm 使用链接仅将项目的直接依赖项添加到模块目录的根目录中。
这意味着如果 package.json 没有引用的依赖,那么它将无法解析。这是迁移中的最大障碍。可以使用 auto-install-peers设置自动执行此操作(默认情况下是false)。
对于多个使用 npm 安装依赖的项目,单独删除依赖包很耗时间,我们可以使用 npkill ,该工具可以列出系统中的任何 node_modules 目录以及它们占用的空间。然后可以选择要删除的依赖以释放空间
图片

迁移流程

首先全局安装包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 依赖,同时 viewerjs 是 v-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 目录下解决,也就是所谓的依赖提升依赖提升

四、参考文章

  • pnpm、npm、yarn 包管理工具『优劣对比』及『环境迁移』 - 知乎 (zhihu.com)
  • 深入浅出 npm & yarn & pnpm 包管理机制-CSDN博客
  • yarn yarn2 and pnpm的一些总结 | 码农家园 (codenong.com)
  • 包管理工具之从NPM到PNPM

specialCoder
2.2k 声望169 粉丝

前端 设计 摄影 文学