前段时间对现有项目进行了改造,由原来的单仓库单包管理转换为单仓库多包的monorepo项目管理方式,采用pnpm对项目模块进行管理,本文对在项目改造过程中遇到的种种问题进行记录和分享。
项目背景
现有项目是一个提供给业务前端开发的cli工具,旨在将代码中的开发配置全部进行解耦,与常见的vue-cli
不一样的是,这个cli安装在全局而不是分别安装在项目中,开发者只需全局安装一次,就可以在任意位置启动业务项目,每个业务项目只保留src和必要的env配置环境,项目结构看起来更简洁。
项目基于webpack5,配合常见的babel转码,postcss处理,eslint代码检查等,现需要对此cli进行升级,在原来只支持vue2+webpack的基础上支持vite及vue3,如果直接在现在项目中添加vue3的vue-sfc
作为依赖,那必将会和vue2的vue-template-compiler
版本检查冲突,而且后续的功能迭代也是已模块迭代为主,因此对项目进行monorepo改造势在必行。
npm管理的弊端
在介绍pnpm之前,我们先来了解下npm的使用痛点。
1.Phantom dependencies
Phantom dependencies
又称幽灵依赖
、幻影依赖
。在npm@3之前,项目的依赖都有自己的node_modules文件夹,在package.json中指定了所有依赖项,项目的node_modules结构是干净可预测的,以下面一个具体的项目例子来看:
// my-library/package.json
{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
},
"devDependencies": {
"rimraf": "^2.6.2"
}
}
在npm@3之前执行npm install
,得到的node_modules结构如下:
node_modules
└─ minimatch
├─ minimatch.js
├─ package.json
└─ node_modules
└─ brace-expansion // minimatch的package.json依赖了brace-expansion
├─ index.js
├─ package.json
└─ node_modules
└─ balanced-match // brace-expansion的package.json依赖了balanced-match
├─ index.js
├─ package.json
└─ node_modules // 如果还有更深的依赖关系则会创建更深的目录结构
├─ ...
这样的目录结构优点是依赖关系一目了然,但缺点也很明显:
- node_modules目录树结构会很深,在windows上很容易出现文件路径过长的问题
- 每个依赖都有自己依赖,因此也会有很多重复的依赖
这个情况从npm@3发生了变化,将node_modules目录扁平化,生成的node_modules(经过简化)如下:
├── node_modules
│ ├── minimatch
│ │ ├── lib
│ │ │ └── path.js
│ │ ├── minimatch.js
│ │ └── package.json
│ ├── balanced-match
│ │ ├── index.js
│ │ └── package.json
│ ├── brace-expansion
│ │ ├── index.js
│ │ └── package.json
│ ├── concat-map
│ │ ├── example
│ │ │ └── map.js
│ │ ├── index.js
│ │ ├── package.json
│ │ └── test
│ │ └── map.js
....
这样解决了npm@3之前的嵌套路径过长的问题,同时模块也可以得到最大程度的复用,但随时引入了新的问题,我们先来看这个项目中一个有效的代码:
var minimatch = require('minimatch');
var expand = require('brace-expansion'); // ???
var glob = require('glob'); // ???
// (使用这些库的代码)
以上代码是可以正常运行有效的,有没有看出来问题?————有两个库 brace-expansion
和 glob
两个库并没在 package.json
文件中声明为依赖。那它们是如何运行的呢?答案是 brace-expansion
是 minimatch
的依赖,glob
是 rimraf
的依赖。安装时,NPM 会将 my-library/node_modules
下的文件夹铺平,由于 NodeJS 的 require()
函数不需要考虑 package.json
文件,所以它找到这些库。
我们就把项目中使用到package.json中没有声明的模块称之为Phantom dependencies(幻影依赖)
。幻影依赖会带来以下问题:
-
出现不兼容版本导致运行失败。比如上面的项目例子,我们只是在项目里声明了
minimatch
的版本为3,但没有声明brace-expansion
的版本,一旦brace-expansion
在随后的更新中出现重大的API更新,使用了幽灵依赖的项目运行就可能会出现问题,这也是实际遇到的一些陈年老项目无法运行的重要原因之一。 -
依赖缺失。还是上面的项目为例,库
glob
来自于devDependencies
中,这意味着只有开发my-library
的开发者才会安装这些库。对于其他人,require("glob")
将会因 glob 未安装而立即抛错。只要我们发布了my-library
, 就会立即听到这个反馈,对吧?其实并不是,实际情况中,由于某些原因(例如自身使用了rimraf
),绝大部分用户都有glob
这个库,所以看起来可以运行。只有一小部分用户会遇到导入失败的问题,这使得它看起来像是一个难以重现的问题。
幻影依赖在node模块中十分常见,在我实际项目中,幻影依赖问题最严重的就是webpack及其附属开发插件,例如进行配置合并的webpack-merge
和打包优化插件terser-webpack-plugin
就是webpack官方直接推荐开箱即用无需额外安装的依赖,在实际代码中一旦使用该模块而package.json中没有声明,就是妥妥的幻影依赖了。
2.NPM doppelgangers
NPM doppelgangers
翻译过来就是NPM分身
,也可以称为依赖分身
。npm@3之前的深层次node_modules导致依赖无法重用就是依赖分身问题,到了npm@3后号称使用偏平依赖结构解决依赖复用问题,但同一个包不同版本重复安装的问题依旧存在。先来看一个例子:
先有以下library-a
项目,项目的package.json下声明了4个依赖:
{
"name": "library-a",
"version": "1.0.0",
"dependencies": {
"library-b": "^1.0.0",
"library-c": "^1.0.0",
"library-d": "^1.0.0",
"library-e": "^1.0.0"
}
}
每个依赖的子依赖如下:
B 和 C 都依赖于 F@1:
{
"name": "library-b",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}
{
"name": "library-c",
"version": "1.0.0",
"dependencies": {
"library-f": "^1.0.0"
}
}
D 和 E 都依赖 F@2:
{
"name": "library-d",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}
{
"name": "library-e",
"version": "1.0.0",
"dependencies": {
"library-f": "^2.0.0"
}
}
在看下面的解释之前,大家可以先脑补下子依赖的公共模块F存在两个不同版本,最后项目根目录的node_modules会如何处理这个F模块?
事实上,在npm@3之后,F会有一个版本会提升到扁平目录,也就是项目node_modules根目录进行模块共享,另一个版本会被安装在对应的依赖node_modules下,例如node_modules 树可以把 F@1 放在树的顶部来实现共享,但是需要把 F@2 拷贝到子目录中:
- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- library-c/
- package.json
- library-d/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0 独立模块
- library-e/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@2.0.0 独立模块
- library-f/
- package.json <-- library-f@1.0.0 共享模块
当然也有另一种处理方式,也就是F@1和F@2互换,F@2共享,F@1在子目录中:
- library-a/
- package.json
- node_modules/
- library-b/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0 独立模块
- library-c/
- package.json
- node_modules/
- library-f/
- package.json <-- library-f@1.0.0 独立模块
- library-d/
- package.json
- library-e/
- package.json
- library-f/
- package.json <-- library-f@2.0.0 共享模块
至于用哪个版本的F模块作为共享模块,取决于哪个版本模块先安装,不管是共享哪个模块,最终只有一个模块被提升,其余模块还是会像npm@2时代一样在子目录node_modules中处理
这里再回头看具体的项目例子,也就是幻影依赖中的library项目:
// my-library/package.json
{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
},
"devDependencies": {
"rimraf": "^2.6.2"
}
}
最后的依赖分身情况如下:
node_modules
│ ├── brace-expansion
│ │ ├── index.js
│ │ └── package.json
| ├── minimatch
│ │ ├── lib
│ │ │ └── path.js
│ │ ├── minimatch.js
│ │ └── package.json
│ ├── glob
│ │ ├── common.js
│ │ ├── glob.js
│ │ ├── node_modules
│ │ │ ├── brace-expansion -> 版本为1.1.11
│ │ │ │ ├── index.js
│ │ │ │ └── package.json
│ │ │ └── minimatch
│ │ │ ├── minimatch.js -> 版本为3.1.2
│ │ │ └── package.json
│ │ ├── package.json
│ │ └── sync.js
因为glob的package.json中声明的brace-expansion
和minimatch
版本比项目的版本低,因此在glob中单独设置了一个node_modules特殊处理这两个模块
3.node_modules文件夹删除时间过长
从上面幻影依赖和依赖分身中可以看到,不管是npm哪种版本,最后的node_modules目录结构层次都会特别深,而且一个依赖会裂变为N个依赖,最后项目根目录下的node_modules小文件也会特别多,因此在删除node_modules时操作系统会预先进行文件夹删除检索,删除时间也会特别长。
pnpm包管理原理
现在来正式介绍pnpm,pnpm通过将模块真实源文件在pnpm store
的公共区域,在项目模块进行依赖安装时,通过硬链接和软链接的方式将模块引入到项目的node_modules
,从而解决了依赖重复安装的问题,下面将对pnpm的特性一一进行介绍。
1.全局store实现内容地址存储
pnpm将依赖安装在store-dir,可以通过pnpm store path
查看store路径:
$ pnpm store path
> /Users/lijiahao/Library/pnpm/store/v3
Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘的根目录下,比如C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3)。
home dir实际是当前操作系统的环境变量根目录,也就是
~
所在的路径,可以通过以下命令查看验证:
$ cd ~
$ pwd
> /Users/lijiahao
值得一提的是,macOS通常不进行分盘,因此pnpm的store在上面通常只有一个路径。但在windows环境下,用户通常不止一个分区(除了C盘还分了其他区),这时候store会在每个磁盘的根目录,也就是C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3),这是因为因为pnpm的硬链接模块机制(下面马上就会介绍到),硬链接只能在发生在同一文件系统同一分区上,因此可以想到一种情况,就是挂载一个移动存储(比如U盘),在这个移动存储设备中发生pnpm安装,那么在这个存储设备的根目录也会生成一个pnpm store
。
可以通过
npm config set store-dir
命令修改pnpm store地址,但不推荐跨分区设置,因为硬链接只支持该分区链接,设置跨分区store后,pnpm也是把另一个分区的store的内容复制到当前分区,也是占用了两个磁盘文件大小。
执行pnpm i
成功安装模块后pnpm也会提示store
的路径:
Packages are hard linked from the content-addressable store to the virtual store.
Content-addressable store is at: /Users/lijiahao/Library/pnpm/store/v3
Virtual store is at: node_modules/.pnpm
Progress: resolved 14, reused 13, downloaded 1, added 14, don
这个输出有两个关键字:
-
Content-addressable store
: 内容地址存储,一种常见的高效内容存储方式,根据文件内容进行存储,可以查看我的另一篇内容寻址存储原理及实际使用场景 -
Virtual store
: 虚拟存储目录。在项目node_modules
下会有一个.pnpm
的隐藏文件夹,文件夹的内容指向store的硬链接,所有直接和间接依赖项都链接到此目录中。
2.pnpm中的硬链接
2.1 硬链接介绍
在介绍硬链接之前先来了解文件Inode
。操作系统会给每个文件分配一个唯一的inode
,它包含了文件的元信息(所有者、权限、创建日期、修改日期、文件大小等),在访问文件时,对应的元信息就会被拷贝到内存中实现文件的访问。值得一提的是,不是硬链接创建的文件,不管是文件复制,同名文件或是内容完全相同的文件,它们的inode都是不一样的。
我们可以通过stat filename
查看文件inode:
在macOS会显示如下:
windows会显示如下:
也可以通过ls -i
命令只查看文件inode
:
$ ls -i
122311848 LICENSE 122361533 package-lock.json
122311849 README.md 122311887 package.json
122311850 README.zh-CN.md 122311888 pnpm-lock.yaml
122362780 dist 122311889 pnpm-workspace.yaml
122311851 examples 122311890 src
122311886 index.d.ts 122311897 tsconfig.json
122354287 node_modules
通常情况下一个inode
指向一个文件,但硬链接可以实现多个文件同时指向一个inode
,即使文件名不同。可以通过ln <source file> <destination file>
命令创建一个文件的硬链接,同时查看inode:
$ ln README.md README-HARDLINK.md
$ ls -i
122311849 README-HARDLINK.md
122311849 README.md
windows下使用
fsutil hardlink list <filename>
查看硬链接
文件硬链接不管有多少个,都指向的是同一个 inode
节点,这意味着当你修改源文件或者链接文件的时候,都会做同步的修改。
每新建一个硬链接会把节点连接数增加,只要节点的链接数非零,文件就一直存在。因此不管你是删除硬链接还是源文件,文件就一直生效。
通过硬链接, 可以实现通过不同的路径引用方式去找到某个文件,需要注意的是一般用户权限下只能硬链接到文件,不能用于目录。
2.2 硬链接在pnpm中的使用
pnpm在项目node_modules下使用硬链接的方式引用模块,硬链接存放在虚拟存储目录.pnpm
下:
$ tree ./node_modules/.pnpm
├── balanced-match@1.0.2
│ └── node_modules
│ └── balanced-match
│ ├── LICENSE.md
│ ├── README.md
│ ├── index.js
│ └── package.json
├── brace-expansion@1.1.11
│ └── node_modules
│ ├── balanced-match -> ../../balanced-match@1.0.2/node_modules/balanced-match
│ ├── brace-expansion
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── index.js
│ │ └── package.json
可以看到balanced-match
和brace-expansion
模块的源码都放在<module@version>/node_modules/<module>
下,带有->
标识的是软连接方式(下面会讲到)。可以验证源码是不是从前面提到的pnpm store
中硬链接出来的:
$ pnpm store path
/Users/lijiahao/Library/pnpm/store/v3
$ find /Users/lijiahao/Library/pnpm/store/v3 -type f -samefile package.json
/Users/lijiahao/Library/pnpm/store/v3/files/24/144b4624231200c7e50b47649fe94e048d5079b971c9888b6f044232db5e520d07e83c332df57adf578298934ae093888069ce408dd57c400426c9172d601b
因此可以确定pnpm对项目安装依赖的时候,如果某个依赖在 store
目录中存在了话,那么就会直接从 store
目录里面去 hard-link
,避免了二次安装带来的时间消耗,如果依赖在 store
目录里面不存在的话,就会去下载一次:
pnpm install --> pnpm store --> CAS found --> hard link to node_modules
|
|--> CAS not found --> download to store --> hard link to node_modules
pnpm通过store + hard link
的方式解决了npm/yarn的依赖分身
问题,甚至不同项目之间的依赖也能等得到很好的复用!
3.软链接模块
硬链接只能链接到文件,但是node_modules是树状结构,文件夹的链接就靠软链接(soft-link)
来实现。
软链接(soft-link)
和windows中的快捷方式很相似,与硬链接
不同的是,软链接可以作用于文件或文件夹,是源文件的一种引用,如果源文件被移动或被删除,软链接就会失效。
通过前面的讲解,我们知道了pnpm在全局通过Store来存储所有的node_modules
依赖,并且在.pnpm/node_modules
中存储项目的硬链接
,通过硬链接
来链接真实的文件资源,项目中的node_modules
则通过symbolic link
链接到.pnpm/node_modules
目录中,依赖放置在同一级别避免了循环的软链。
├── index.js
├── node_modules
│ ├── minimatch -> .pnpm/minimatch@5.1.0/node_modules/minimatch
│ └── rimraf -> .pnpm/rimraf@3.0.2/node_modules/rimraf
├── package.json
至于项目依赖的子依赖,也是在.pnpm
目录下使用嵌套node_modules
然后使用软链接的方式引入子依赖,比如下面的brace-expansion
子依赖balanced-match
:
$ tree ./node_modules/.pnpm
├── balanced-match@1.0.2
│ └── node_modules
│ └── balanced-match
│ ├── LICENSE.md
│ ├── README.md
│ ├── index.js
│ └── package.json
├── brace-expansion@1.1.11
│ └── node_modules
│ ├── balanced-match -> ../../balanced-match@1.0.2/node_modules/balanced-match // 子依赖软链
│ ├── brace-expansion
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── index.js
│ │ └── package.json
现在可以看到,pnpm
的node_modules
目录结构也不是完全的扁平化结构,反而有点像npm@2的目录结构,只是用硬链+软链这种巧妙的方式引入模块,完全符合nodejs的模块规范,也避免了幽灵依赖和依赖分身的问题。
pnpm官网也有一个软链接和硬链接的示意图,大家可以根据我上面的解释再好好体会下:
项目改造遇到的问题
vue-template-compiler版本混乱问题
因为pnpm使用<module>@<version>
的方式存储模块,因此在monorepo项目中可以很好的隔离同一依赖不同版本的包。但个别模块没有设置PeerDependencies
的话,pnpm就会把这个模块提升到公共模块使用,在我的项目中有vue@2和vue@3,因此vue-template-compiler
被提升后其子依赖vue
就会导致找到vue@3而报错。解决办法是在monorepo项目根目录package.json
添加pnpm.packageExtensions
为vue-template-compiler
声明前置依赖:
// package.json
"pnpm": {
"packageExtensions": {
"vue-template-compiler": {
"peerDependencies": {
"vue": "2.6.11"
}
}
}
}
部分模块被提升
在monorepo项目中根目录package.json
通常没有声明依赖,但在实际执行pnpm i
将各个项目的依赖安装后,在根node_modules会多出一些package.json
里没有声明的依赖。
$ pnpm i
node_modules
.bin/
.pnpm/
@eslint/eslintrc
eslint
eslint-scope
eslint-utils
eslint-visitor-keys
.modules.yaml
这是因为pnpm的public-hoist-pattern
默认值为['*eslint*', '*prettier*']
,因此所有带eslint
、prettier
关键字的模块都会提升到根模块目录中,提升至根模块目录中意味着应用代码可以访问到幻影依赖,详细配置可以查看public-hoist-pattern。
pnpm link失败
即使使用monorepo,也有在其他项目中pnpm link
进行调试的场景。在实际使用中发现pnpm安装时并没有自动设置pnpm global path
,导致在运行pnpm link
提示无法link在全局目录,而pnpm
推荐使用的pnpm setup
修复命令也无法正常运行(可能是个bug),这时候需手动设置pnpm global path
。
pnpm硬链接存储空间问题
硬链接看着使用了两份存储空间,但由于源文件和各个硬链接都是使用同一份inode,使用的存储空间其实只有一份。
store目录越来越大
随着项目及安装的依赖数量的增加,全局pnpm store
目录也难免越来越大,这时候可以通过pnpm store prune
将硬链接数量为0的文件进行删除,尽可能腾出空间。
幻影依赖处理
改造成monorepo结构后每个子项目运行时可能会出现xxx module not found
问题,这时候大概率是依赖的子依赖存在幻影依赖问题,这时候可以通过pnpm.packageExtensions
或手动将依赖添加到项目的package.json
中解决。
包发布管理问题
pnpm主打依赖管理,但对于workspace
内的包发布管理支持很弱,pnpm文档在发布工作流有推荐使用changesets
和rush
进行包发布管理,但我个人认为如果想把发布管理简单化可以直接使用,目前lerna已经不再维护,且不支持lerna
的发布管理workspace
协议,在monorepo本地调试发布存在缺陷,这里建议使用lerna-lite替代lerna来处理monorepo中的本地依赖关系。
总结
本文先从npm当前依赖管理痛点进行分析,引出幽灵依赖
和依赖分身
这两个npm大痛点,pnpm
解决了这个痛点并给开发者更好的npm依赖管理体验,个人也认为pnpm
更符合未来npm/yarn的包管理方式,最后再罗列了一些本人在具体monorepo项目中使用pnpm遇到的问题和解释,希望读者可以在实际应用中举一反三。
(完)