包管理工具之从NPM到PNPM
近几年的前端开发者肯定了解npm
是什么,而且使用过程中也开始接触yarn
,pnpm
等等。那么到底为什么会有这么多的包管理工具,它们好像可以交替使用,但是好像又有点不同。我这边按照我的理解,整理搜索一下包管理器发展的历史。
一、包管理工具的发展
2010 年 1 月,一款名为 npm 的包管理器诞生。它确立了包管理器工作的核心原则。
npm 的发布诞生了一场革命,在此之前,项目依赖项都是手动下载和管理的。npm 引入了文件和元数据字段,将依赖项列表存储在 package.json 文件中,并且将下载的文件保存到 node_modules 文件夹中。
随后,又因为npm的缺陷或者旧版本的不足,大牛们造出一个又一个替代npm来进行包管理的轮子,例如:yarn
,yarn2
,pnpm
等等。
1.1 NPM
是 Node.js 自带的包管理工具,也是最常用的包管理工具之一。它可以方便地安装、升级、卸载依赖包,还可以发布自己的包到 NPM 仓库。
很多人认为 npm 是 node package manager 的缩写,但其实不是,官方对此有过辟谣。
npm已经迭代过很多版本,也尝试解决大家对它缺陷或者不足的吐槽。
1.1.1 npm v1 & v2
此时期主要是采用简单的递归依赖方法,最后形成高度嵌套的依赖树。这种模式虽然模块依赖关系比较清晰,但是造成的问题更大。
重复依赖嵌套地狱,空间资源浪费:大量重复的包被安装,文件体积超级大。比如跟 foo 同级目录下有一个baz,两者都依赖于同一个版本的lodash,那么 lodash 会分别在两者的 node_modules 中被安装,也就是重复安装。
安装速度过慢
文件路径过长:尤其在 window 系统下,路径过长会导致爆错,最多260多个字符。
模块实例不能共享:比如 React 有一些内部变量,在两个不同包引入的 React 不是同一个模块实例,因此无法共享内部变量,导致一些不可预知的 bug。
例如: 项目依赖了A@1.0和 B@1.0,而 A@1.0 和 B@1.0依赖了不同版本的 C@1.0 和 C@2.0,node_modules 结构如下:
注意:为了方便,后边版本号都简单以两位数字表示,不是正确的语义化版本号
1 | node_modules |
在我们真实使用过程中,随着依赖的增多,重复冗余的包会越来越多,最终,node_modules会大量的占用磁盘。而且依赖嵌套的深度也会十分可怕,这个就是我们常说的**依赖地狱(Dependency Hell)**。
1.1.2 npm v3
npm v3 版本作了较大的更新,开始采取扁平化的依赖结构。
为了将嵌套的依赖尽量打平,避免过深的依赖树和包冗余,npm v3 将子依赖「提升」(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项所在的目录中。
我们继续以上面的案例为例:
1 | node_modules |
可以看到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 下。
这样的依赖结构可以很好的解决重复依赖的依赖地狱问题,层级也不会太深,但也形成了新的问题。
扁平化依赖算法耗时长:官方仓库 issue 的解释:npm@3 wants to be faster · Issue #8826 · npm/npm (github.com)
幽灵依赖 Phantom dependencies:在 package.json 中未定义的依赖,但项目中依然可以正确地被引用到。
比如上方的示例其实我们项目只安装了 A@1.0 和 B@1.0,C@1.0其实是A@1.0的依赖:
1 | // package.json dependencies |
由于 C@1.0 在安装时被提升到了和 A 1.0同样的层级,所以在项目中引用 C@1.0 还是能正常工作的。
幽灵依赖是由依赖的声明丢失造成的,如果某天某个版本的 A 依赖不再依赖 C@1.0 或者 C@1.0的版本发生了变化,那么就会造成依赖缺失或兼容性问题。
不确定性 Non-Determinism:同样的 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 结构不同,代码无法正常运行。
依赖分身 Doppelgangers:假设继续再安装依赖 C@1.0 的 D 模块和依赖 C@2.0 的 E 模块,此时:
以下是提升 C@1.0 的 node_modules 结构:
1 | node_modules |
可以看到 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目录结构将不会发生变化。
兼容性
语义化版本(Semantic Versioning)
依赖版本兼容性就不得不提到npm使用的SemVer版本规范,版本格式如下:
- 主版本号:不兼容的 API 修改
- 次版本号:向下兼容的功能性新增
- 修订号:向下兼容的问题修正
在使用第三方依赖时,我们通常会在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。
安装速度
- 并行:在 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文件夹,里面又有cache和unplugged**目录,前者存放压缩过的依赖包,后者可以通过unplugin指令解压某个想要手动修改的包。
berry一定程度解决了一些问题:
- 之前介绍的npm存在的两个问题,berry因为不会生成node_modules目录,因此不存在phantom dependency的问题,同时他采用的.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的改变
- 抛弃 node_modules
无论是 npm 还是 yarn,都具备缓存的功能,大多数情况下安装依赖时,其实是将缓存中的相关包复制到项目目录中 node_modules 里。而 yarn PnP 则不会进行拷贝这一步,而是在项目里维护一张静态映射表 pnp.cjs。
pnp.cjs 会记录依赖在缓存中的具体位置,所有依赖都存在全局缓存中。同时自建了一个解析器,在依赖引用时,帮助 node 从全局缓存目录中发现依赖,而不是查找 node_modules。
这样就避免了大量的 I/O 操作同时项目目录也不会有 node_modules 目录生成,同版本的依赖在全局也只会有一份,依赖的安装速度和解析速度都有较大提升。
注:pnpm 在 2020 年底的 v5.9 也支持了 PnP
- 脱离 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 结构如下:
1 | node_modules |
<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,命令如下所示
1 | # 安装 LTS 版本 |
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 目录以及它们占用的空间。然后可以选择要删除的依赖以释放空间
迁移流程
首先全局安装包
1 | npm i -g pnpm |
迁移步骤如下
1.首先使用 npkill 删除 node_modules 依赖包
2.项目根目录创建 .npmrc
,填写如下内容
1 | auto-install-peers=true |
3.导入依赖锁定文件(pnpm-lock.yaml)
保证根目录有如下依赖锁定文件(npm-shrinkwrap.json,package-lock.json,yarn.lock)
然后执行如下命令
1 | pnpm import pnpm-lock.yaml |
4,最后执行 pnpm i
安装依赖
问题
生成依赖文件警告
官方 issue 解释: Unmet peer dependencies and The command – pnpm/pnpm (github.com)
生成 pnpm-lock.yaml
文件时出现如下警告
1 | WARN Issues with peer dependencies found |
这是因为在 npm 3 中,不会再强制安装 peerDependencies
(对等依赖)中所指定的包,而是通过警告的方式来提示我们。pnpm 会在全局缓存已经下载过的依赖包,如果全局缓存的依赖版本与项目 package.json
中指定的版本不一致,就会出现这种 hint
警告
我们可以在项目的 package.json
中配置 peerDependencyRules
忽略对应的警告提示
1 | { |
或者说直接在 .npmrc
配置文件中直接关闭严格的对等依赖模式,可以添加 strict-peer-dependencies=false
到配置文件中,或者执行如下命令
1 | 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)
1 | pnpm i -S viewerjs |
或者说某些版本 pnpm 会自动爆出幽灵依赖错误 missing peer ...
,也可以直接不使用上面的扫描工具,直接自行安装后面的 ...
依赖
2.找到.npmrc
文件,在其中配置 public-hoist-pattern
或者 shamefully-hoist
字段,将依赖提升到根node_modules
目录下解决,也就是所谓的依赖提升
依赖提升官方文档:.npmrc | pnpm
1 | # .npmrc |
当然,极不推荐用这样的方式解决依赖问题,这样没有充分利用 pnpm
依赖访问安全性的优势,又走回了 npm
/ yarn
的老路。
四、参考文章
pnpm、npm、yarn 包管理工具『优劣对比』及『环境迁移』 - 知乎 (zhihu.com)