npm、yarn到pnpm的发展历程
- 背景
- 价值点
- npm发展及存在的问题
- npm v1(树状结构)
- 安装原则
- 优点
- 不足:
- npm v3(扁平化结构)
- 安装原则
- 优点:
- 不足:
- 目录结构不确定
- 依赖A先安装
- 依赖A后安装
- npm v5
- 优点
- npm包分身
- 定义
- 隐患
- 幻影依赖
- 定义
- 隐患
- yarn
- 特点
- pnpm
- 特点
- 原理
- 三层结构
- 第一层
- 第二层
- 第三层
- 次级依赖
- 依赖提升
- 默认配置
- 严格模式
- 结论
- 总结
- 不足
- 参考文章
背景
团队要将各个项目的代码迁移到大仓,前端基于 pnpm 搭建 monorepo,本文记录前端迁移大仓为什么要选 pnpm 来管理依赖,以及这个过程中遇到的一些问题以及一些原理的思考和理解。
价值点
- 在日常前端开发中node包管理工具(npm) 可谓无处不在,必不可少。
- pnpm本质上也是node的一种包管理工具,所以理解这些原理后有助于我们平时在开发的时候能够更精准的定位和解决问题。
- 因此读完本文你可以大致了解 npm 和 yarn 的发展史,以及 pnpm 的原理。
npm发展及存在的问题
一种新技术的出现,往往是为了解决现有技术存在的不足。 正所谓知其然,还要知其所以然。那pnpm到底解决了npm/yarn存在的什么问题?下面将详细介绍npm/yarn的发展及其存在的问题。
我们知道,在执行npm install 后,依赖模块被安装到了 node_modules
目录下,那node_modules
目录下的依赖管理机制又是如何的呢?
npm v1(树状结构)
安装原则
npm在版本3之前处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。直到有子依赖包不在依赖其他模块。 举个例子,我们的项目 myProject 现在依赖了两个模块:A、B:
{
"name": "myProject",
"dependencies": {
"A": "^1.0.0",
"B": "^1.0.0",
}
"devDependencies": {
"C": "^1.0.0",
}
}
而模块A又依赖 D@1.0.0,模块B又依赖 D@2.0.0,模块C又依赖 D@2.0.0
那么,执行 npm install 后,node_modules 目录中的模块结构如下
优点
- node_modules 的结构和 package.json 结构一一对应,层级结构明显直观,并且保证了每次安装目录结构都是相同的。
不足:
- node_modules会十分庞大:需要安装的模块非常非常多,在不同层级的依赖中,就算是依赖同一个模块,都需要重新安装一次,因此会有大量的模块重复安装。(比如上图的模块 D@2.0.0)
- 层级嵌套很深:系统对文件路径都会有一个最大长度的限制,嵌套层级过深可能导致不可预知的问题(比如无法直接删除)。
npm v3(扁平化结构)
为了解决以上问题,NPM 在版本3 做了一次较大更新。其将之前的嵌套结构改为扁平结构。
安装原则
- 安装模块时,不管其是直接依赖还是子依赖的依赖(间接依赖),优先将其安装在 node_modules 根目录。
- 当安装到相同模块时,判断在 node_modules 根目录已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不用再次安装;不符合则在当前模块的 node_modules 下安装该版本的模块。
还是上面的例子,如果使用npm版本3安装依赖,则最后的依赖结构应该如下图:
因为在安装依赖 A@1.0.0 时,把 D@1.0.0 优先安装在了根node_modules 下,接着再安装依赖 B@1.0.0时,其依赖的 D@2.0.0也会优先考虑安装在根node_modules 下,但是根node_modules 下已经存在 D@1.0.0,所以只能将 D@2.0.0 安装在当前依赖 B@1.0.0 的node_modules下。最后再安装依赖C@1.0.0,也是同样的原则。
优点:
在满足 Node.js 的模块查找规则的同时降低了依赖层级,一定程度上缓解了占用磁盘空间(其实并没有完全解决,比如上图的依赖D@2.0.0还是会被安装多次)和路径过长的问题
不足:
- 依赖的目录结构不确定
- npm包分身 (npm一直都存在这个问题)
- 幻影依赖
下面将一一介绍存在的这些不足点。
目录结构不确定
在执行 npm install 的时候,按照 package.json 里依赖的顺序依次解析,则依赖在 package.json 的放置顺序则决定了 node_modules 的依赖结构
思考一个问题:依赖 A 先安装和后安装,其目录结构是否一样?
依赖A先安装
依赖A后安装
由于依赖 B@1.0.0 和依赖 C@1.0.0 都依赖了 D@2.0.0,所以依赖D@2.0.0 优先安装在根node_modules 目录下。当最后安装依赖A时,其间接依赖 D@1.0.0 只能安装在其自身的node_modules 目录下了。
依赖结构的不确定性可能会给程序带来不可预知的问题。
npm v5
为了解决 npm install 时依赖结构不确定的问题,在 npm版本5新增了 package-lock.json
文件
优点
- 固定依赖结构:
package-lock.json
的作用是锁定依赖结构,即只要目录下有 该文件,那么每次执行 npm install 后生成的 node_modules 目录结构是相同的。同时版本也会锁定在指定的范围内 - 提高安装速度:`package-lock.json`` 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,减少了大量网络请求,提高了安装速度。
虽然npm v5解决了安装依赖时结构不确定的问题,但其安装方式还沿用了 npm v3 的扁平化的方式,所以npm还是还是会遗留刚刚说的两个问题
- npm包分身
- 幻影依赖
npm包分身
定义
相同版本的子依赖包被不同的项目依赖所依赖时会安装两次,比如还是上面的例子(依赖D@2.0.0被安装了两次)
隐患
- 相同的包安装了两次,占用磁盘空间,相对的安装的速度也会变慢
- 破坏单例,如果是单例的库会使得不同的使用方拿不到相同的实例(代码都不是同一份)
幻影依赖
定义
一个库使用了没有在package.json 中声明的依赖。 还是一开始的例子,myProject的依赖可以直接访问到D依赖,虽然它们没有在package.json中声明。
隐患
- 不兼容的版本:依赖可能需要的D版本是2.0.0,但是引入的是却是版本1.0.0(这个通过
package-lock.json
文件可以解决) - 缺少依赖:当我们项目里面没有安装到它所依赖的包时,此时就会报错。
其实幻影依赖这种隐患在 npm 树状结构的情况下是能够尽量避免的,因为树状结构严格按照 package.json中声明的依赖安装。但是扁平化这种结构为了减少依赖重复安装,却使幻影依赖出现的可能性更大了。
针对这两个大问题,另外一个node包管理工具yarn是否解决了呢,其实并没有。
yarn
yarn 是在 2016 年发布的,那时 npm 还处于 V3 时期,还没有 package-lock.json 文件,就像上面我们提到的:不稳定性、安装速度慢等缺点经常会受到广大开发者吐槽。此时,yarn 就诞生了。 后来 npm 也意识到了自己的问题,进行了很多次优化,在后面的优化(lock文件、缓存)中,我们多多少少能看到 yarn 的影子,可见 yarn 的设计还是非常优秀的。
特点
yarn 也是采用的是 npm v3 的扁平结构来管理依赖,安装依赖后默认会生成一个 yarn.lock 文件
所以发展到现阶段来看,yarn最大的优点就是比npm安装速度快。
- npm:串行安装,按照队列安装每个 package,必须要等到当前 package 安装完成之后,才能继续后面的安装
- yarn:并行安装:同步安装所有包
所以yarn也会有npm同样的问题(依赖管理方式都一样了),那pnpm是怎么处理这些问题的呢,下面就来具体介绍pnpm。
pnpm
pnpm即Performant NPM,高性能npm。
特点
- 提高安装速度:不用安装那么多重复的包,
- 节约磁盘空间,安装过的依赖会复用缓存,甚至包版本升级带来的变化都只 diff
- 非常优雅的解决 npm和yarn的问题而不带入新的问题
想知道他为什么会有这些特点,以及是怎么解决npm和yarn存在的问题的,就得知道他的工作原理具体是怎样的。
原理
还是一开始的例子,当使用pnpm来管理依赖时,其目录结构如下图所示:
下面就逐步介绍pnpm是怎么管理依赖的。
三层结构
第一层
- 一定程度上沿用了 npm 版本3之前的树状结构,但又不完全是。取其精华,弃其糟粕。
- 第一层寻找依赖是 nodejs 或 webpack 等运行环境/打包工具进行的,他们在 node_modules 文件夹寻找依赖,并遵循就近原则,所以第一层依赖文件势必要写在
node_modules
下,一方面遵循依赖寻找路径,一方面没有将依赖都拎到上级目录,也没有将依赖打平。 - 项目的根 node_modules 里面依赖和 package.json 里声明的一一对应,简而言之,就是我们在项目的package.json里定义了什么包就只能依赖什么包,即只会有 package.json下声明的包,不会有次级依赖的包。
比如上面的例子,在根node_modules目录下的依赖只有三个 A@1.0.0、B@1.0.0、C@1.0.0,不会有他们的次级依赖。因为在我们项目myProject的package.json中声明的也就只有这三个依赖,没有其他的了。
但是在根node_modules目录下的依赖又不是他们真正的物理位置,接下来涉及到第二层结构。
第二层
- 项目的 node_modules 下有 .pnpm 文件夹,以平铺的形式储存着所有的包(包括次级依赖)
- 每个项目根
node_modules
下安装的包以软链接(符号链接)方式将内容指向node_modules/.pnpm
中的包(如上图的虚线代表的是软连接)。node解析到软链时,会解析这些依赖的真实位置。
这一层主要是解决npm/yarn包重复安装的问题(npm包分身)。每个包的位置:.pnpm/<name>@<version>/node_modules/<name>
,当我们的项目依赖里面使用多个重复的包时,只需要安装一次,使用软链指向同一个包即可。
所以到第二层时每个包寻址过程为:node_modules/<name> --> 软链接 node_modules/.pnpm/<name>@<version>/node_modules/<name>
经过两层结构,解决了在一个项目内的包重复安装的问题,但项目不止一个,多个项目对于同一个包的多份拷贝还是冗余了,因此还要来到第三层结构。
第三层
- 所有包都安装在磁盘全局目录
~/.pnpm-store/v3/files
下,.pnpm
目录下的所有依赖都是以硬链接方式指向这个位置。 - 全局统一管理路径,跨项目复用,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。
- 文件管理方式:基于内容寻址的文件系统CAS(content-addressable):通过文件内容生成内容地址(通常是hash算法),优势是单一实例存储,提高安装速度,节省磁盘空间。
所以,pnpm中每个包都要经过三层寻址路:node_modules/<name> --> 软链接 node_modules/.pnpm/<name>@<version>/node_modules/<name> --> 硬链接 ~/.pnpm-store/v3/files
那 pnpm 中是如何次级依赖的呢?
次级依赖
依赖和依赖的依赖(次级依赖)的实际位置位于同一目录级别,次级依赖再软链到.pnpm目录下。 还是上面的例子,模块A@1.0.0会依赖D@1.0.0,pnpm会把它们放在同一目录层级,D@1.0.0还是会通过软链指向node_modules/.pnpm/D@1.0.0/node_modules/D
。同理,B@1.0.0 和 C@1.0.0 所依赖的 D@2.0.0 也会指向同一个位置node_modules/.pnpm/D@2.0.0/node_modules/D
大费周章,终于解决了 npm包分身的问题。但是还有一个问题–幻影依赖。那pnpm到底是怎么解决这个问题的呢?这个涉及到了 pnpm的依赖提升。
依赖提升
注意到,pnpm默认情况下会存在这么一个目录node_modules/.pnpm/node_modules
,用来存储提升的所有依赖,当某个依赖有多个版本时,pnpm 也只是遍历依赖关系图并提升找到依赖的第一个版本,所以并不是将某个依赖的所有版本都提升。
默认配置
- 半严格
; 提升所有包到 node_modules/.pnpm/node_modules
hoist-pattern[]=*
; 提升所有名称包含types的包至根,以便Typescript能找到
public-hoist-pattern[]=*types*
; 提升所有ESLint相关的包至根
public-hoist-pattern[]=*eslint*
- 我们的项目将只能引用 package.json 中已声明的依赖,但项目所依赖的那些包将能访问任何其他的包
举个例子 我们的项目demo依赖 A@1.0.0 和 D@1.0.0,依赖A@1.0.0 又依赖模块B@1.0.0和C@1.0.0
{
"name": "demo",
"dependencies": {
"A": "^1.0.0",
"D": "^1.0.0",
}
}
使用 pnpm 安装依赖后的结构如下
注意到node_modules/.pnpm/node_modules
目录下有四个包A、B、C、D,这就是在半严格(默认)配置下将项目中的所有包都提升到这里了。
这时候,按照node/webpack模块加载机制,我们的项目demo也只是能引用到模块A@1.0.0和D@1.0.0,但是模块 A,B,C,D可以被我们项目中所有的依赖引用,即使他们自己的package.json中并没有声明(比如模块A就可以访问到模块D,但模块A中并没有声明D)。
介绍到这,大家是否有这个疑问:不是说pnpm也解决了幻影依赖的问题吗,那按照依赖提升这种情况,不也是会出现幻影依赖这种现象吗?
没错,如果直接使用pnpm的默认配置(半严格模式),确实会有这个问题。但既然说pnpm能够解决这个问题,自然有其他办法,那就是严格模式配置。
严格模式
- 设置
hoist=false
- 我们的项目以及项目所依赖的包都只能访问他们自己声明的依赖
设置为严格模式后,node_modules/.pnpm/node_modules
目录不存在,根据 node 模块加载机制,模块A就无法访问到模块D了。
但是,目前有不少的包都会直接使用没有在package.json声明的包,比如vue-template-compiler
就默认我们项目会安装vue,它自己内部没有声明就使用了。
所以解决这种问题,则可在项目根目录创建文件 .pnpmfile.cjs
,并使用一个 hook 将缺少的依赖项添加到包的清单中
module.exports = {
hooks: {
readPackage: (pkg) => {
if (pkg.name === "vue-template-compiler") {
pkg.dependencies['vue'] = pkg.version;
}
return pkg;
}
}
};
结论
所以,pnpm 可以解决幻影依赖的问题。但是,默认情况下 pnpm 安装的依赖也是会被提升的,也会有幻影依赖的问题。我们可以通过设置hoist=false
禁止依赖提升,结合.pnpmfile.cjs
,使用hooks将缺少的依赖安装。如此一来,幻影依赖的问题就彻底解决了。
总结
pnpm通过软链+硬链的方式管理依赖,同时严格按照 node 的模块加载机制,解决了npm/yarn存在的包分身和幻影依赖问题。通过创建全局的存储空间~/.pnpm-store/v3/files
保证了每个版本的包只会安装一次,还可以跨项目共享,可谓又快(安装快),又小(占用磁盘空间少)。
特点 | npm v1 | npm v3 | npm v5 | yarn | pnpm |
---|---|---|---|---|---|
依赖结构 | 树状:严格按照package.json中声明的结构 | 扁平化 ,优先安装在根node_modules下 | 扁平化 | 扁平化 | 平铺,全部安装在.pnpm 目录下 |
重复安装依赖 | 有且很严重 | 有,比v1一定程度上少了 | 有,比v1一定程度上少了 | 有,和npn v3 一样,但安装快了 | 无,全局空间~/.pnpm-store/v3/files 统一管理 |
幻影依赖 | 有,但尽可能可以避免 | 有,相比v1,出现几率更高 | 有 | 有 | 默认配置(半严格模式)有,严格模式无 |
有锁文件 | ❌ | ❌ | package-lock.json | yarn.lock | pnpm-lock.yaml |
安装速度 | 🌟 | 🌟🌟 | 🌟🌟🌟 | 🌟🌟🌟🌟 | 🌟🌟🌟🌟🌟 |
不足
世上没有一种东西时十全十美的,pnpm也不例外。在具体的实践过程中,个人觉得比较费劲的一个点是:设置hoist=false
开启严格模式后,有不少的包都会出现找不到的情况,这时候就得手动找出哪些包被谁引用,然后再通过hooks手动把这些缺少的包安装到对应的模块中。
参考文章
- Phantom dependencies
- NPM doppelgangers
- [npm 存在的问题以及 pnpm 是怎么处理的](