从npm的deduped特性谈,我为什么优先推荐pnpm和yarn
发布时间: 2025-07-10
背景
最近遇到一个比较头疼的npm安装依赖版本的问题,就是同一个依赖,在多个包中依赖的版本不一样时,安装的版本不符合预期,代码运行不起来。 通常大家知道npm依赖的安装时,如果遇到两个包的依赖版本不一样时是会在各自的依赖目录中安装需要的版本的,可事实上真的如此吗?今天来看看我遇到的 实际案例,当然这个案例并不是一定出现的,只有一些特定的情况下才会出现。
问题还原
依赖deduped特性
为了更好的理解后面的内容,开始之前,先说说npm从v3开始支持的deduped特性。
当多个依赖包(或嵌套依赖)需要同一个版本的某个包时,npm 会尝试将这个包提升到较高的层级(如项目根目录的 node_modules),而不是在每个子依赖的 node_modules 中都安装一份。
我的项目依赖情况
先来简单介绍一下我的项目情况,为了方便大家理解,简单说一下我的项目依赖关系。项目是project
,有2个直接依赖包A
和B
,
其中B
依赖A
,B
也依赖C
,C
又依赖了A
,简单来说就是依赖的这些A他们的版本不一样,如下图:
project
├── A @1.3.0
└─┬ B @1.0.0
├─┬ C @1.0.0
│ ├─┬ D @1.0.0
│ │ └── A @1.0.0
│ └── A @1.0.0
└── A @1.3.0
我们直接说核心问题:项目依赖A@1.3版本
和B
,B
依赖A@1.3
版本,B
依赖C
,C
依赖A@1.0
和D@1.0
,D
也依赖A@1.0
,D
也依赖A@1.0
.
因此我们基于上面的deduped特性可以推测出,安装出来的目录应该是:
project/node_modules
├── A @1.3.0
└─┬ B @1.0.0
└─┬ node_modules
└─┬ C @1.0.0
└─┬ node_modules
├── D @1.0.0
└── A @1.0.0
可以看出,我们认为理论上,基于dudepud特性,以及node在当前目录找不到node_modules中的对应依赖则会去上一级目录查找,我们认为npm会在:
project/node_modules/
中安装一个A@1.3.0。
project/node_modules/B/node_moduels/C/node_modules
中安装A@1.0.0版本
为了避免重复安装
project/node_modules/B/node_moduels/C/node_modules/D/node_modules
目录中则不再安装A@1.0.0。
project/node_modules/B/node_moduels/
也不会安装A@1.3.0
这样的好处就是节省空间,避免依赖地狱。
实际的安装结果
很遗憾,实际上npm安装后,我的项目开始报错了,提示 B
项目中找不到 A
依赖的一个函数,我去查看才发现问题不对了。
npm ls A
得到如下一张依赖树:
project
├── A @1.3.0
└─┬ B @1.0.0
├─┬ C @1.0.0
│ ├─┬ D @1.0.0
│ │ └── A @1.0.0 deduped invalid: "1.3.0" from node_modules/B
│ └── A @1.0.0 deduped invalid: "1.3.0" from node_modules/B
└── A @1.0.0 invalid: "1.3.0" from node_modules/B
可以明显的看到,B
中依赖的 A
变成了1.0.0,并且可以看到 C
以及 D
下的 A
后面都写了 deduped
,表示这些依赖被去重提升到了 B
下。
我 B
的package.json里可以是明确写了依赖的 A
版本是1.3.0的,它在依赖提升时,竟然选择优先满足子依赖的需求版本。
预计生成目录
从前面可以得知,我们预期的目录是这样的:
project/node_modules
├── A @1.3.0
└─┬ B @1.0.0
└─┬ node_modules
└─┬ C @1.0.0
└─┬ node_modules
├── D @1.0.0
└── A @1.0.0
实际生成目录
但实际得到的是这样的:
project/node_modules
├── A @1.3.0
└─┬ B @1.0.0
└─┬ node_modules
├─┬ C @1.0.0
| └──┬ node_modules
| └── D @1.0.0
└── A @1.0.0
和我们的预期是不是不一样了?
当我们遇到这种情况时,怎么解决?
如果不更换包管理器的话,解决的方式就是在当前项目package中覆写版本号,这样会把所有依赖、依赖的子依赖都强制指定安装位你设置的覆写版本。
npm8.3+
8.3+支持在package.json中写override字段
{
"overrides": {
// 覆写整个项目
"A": "1.3.0",
// 假如你只覆写某个子依赖的依赖
"B": {
"A": "1.3.0"
},
}
}
低版本npm
但是在低版本的npm中,不支持override字段,则可以通过一个三方包来实现
npm install npm-force-resolutions --save-dev
{
"resolutions": {
"A": "1.3.0"
},
"scripts": {
"preinstall": "npx npm-force-resolutions"
}
}
这个插件的原理是:preinstall 脚本会在安装前修改 package-lock.json,强制使用指定版本。
强制override版本的风险
主要是如果不同的依赖版本有不兼容的问题,则覆写后,会导致某个依赖它使用到不符合它要求的版本,因此代码还是会出现问题。
升级这些深层依赖的版本
如果依赖都是你自己发布的,那么可以考虑升级这些深层次依赖,可以精准的控制并解决问题,但是解决问题的速度是比override慢的,取舍由你。
pnpm和yarn他们是则呢么做的?
为了彻底避免这种问题,事实上选用pnpm,又或者选择yarn是更加优秀的选择,可以很好的避免这种情况,yarn(v1)和npm最像但是yarn的版本策略是符合预期的。
yarn v1
因为我目前主要用的yarn v1,这里就不过多说明yarn了,v1和npm几乎是一样的依赖目录、只是他能正确的处理成我们预期的目录结构,拿上面的示例来说,他是能正确的安装成正确目录结构的:
project/node_modules
├── A @1.3.0
└─┬ B @1.0.0
└─┬ node_modules
└─┬ C @1.0.0
└─┬ node_modules
├── D @1.0.0
└── A @1.0.0
pnpm
而pnpm则更牛了,它基于软硬链接的形式,既保证了所有版本的依赖都只安装一次,又保证了安装速度,又还不破坏依赖层级, 我们来剖析一下pnpm安装这个依赖后,他的目录结构是是怎么样的。
pnpm怎么处理依赖
首先pnpm安装后在你的node_modules目录中是只会出现你package.json中写明的依赖的,对于你没有写的依赖(包括子依赖的依赖)全部都在 node_modules/.pnpm
目录中,所以当我们安装依赖后,我们的node_modueles目录是这样的
project/node_modules
├─┬ .pnpm
| ├── A @1.0.0
| ├── A @1.3.0
| ├── B @1.0.0
| ├── C @1.0.0
| └── D @1.0.0
├── A @1.3.0
└── B @1.0.0
此时你看到的node_moduels下的所有目录,除了 .pnpm
目录以外都是软链接,指向的是 .pnpm目录
中的具体文件,比如我们这个项目中
project/node_moduels/A
指向-> project/node_moduels/.pnpm/A@1.3.0
project/node_moduels/B
指向-> project/node_moduels/.pnpm/B@1.0.0
当我们引用 B
依赖时,访问的是 project/node_moduels/.pnpm/B@1.0.0
,我们继续追踪,进入 project/node_moduels/.pnpm/B@1.0.0
目录下的 node_moduels
目录中,看它引用的 A 指向的哪里
进入目录 project/node_moduels/.pnpm/B@1.0.0/node_moduels
后输入
ls -l
输出软链接:
project/node_moduels/.pnpm/B@1.0.0/node_moduels/A
-> project/node_moduels/.pnpm/A@1.3.0
project/node_moduels/.pnpm/B@1.0.0/node_moduels/C
-> project/node_moduels/.pnpm/C@1.0.0
再继续追踪,进入project/node_moduels/.pnpm/C@1.0.0/node_moduels
看下它的软链接
project/node_moduels/.pnpm/C@1.0.0/node_moduels/A
-> project/node_moduels/.pnpm/A@1.0.0
至此我们可以得出,pnpm给我们虚构了一个node_moduels目录,表面上看起来是
project/node_modules
├─┬ .pnpm
| ├── A @1.0.0
| ├── A @1.3.0
| ├── B @1.0.0
| ├── C @1.0.0
| └── D @1.0.0
├── A @1.3.0
└── B @1.0.0
实际对于程序跑起来,node查找依赖的时候,对node来说它的目录等同于:
project/node_modules
├── A @1.3.0
└─┬ B @1.0.0
└─┬ node_modules
└─┬ C @1.0.0
└─┬ node_modules
├── D @1.0.0
└── A @1.0.0
pnpm的软链接和硬链接
pnpm最牛逼的管理包的方式是他用了软硬链接,你安装一个依赖它的过程是这样的,首先会看你系统的pnpm缓存里有没有已经安装这个包,如果有则直接在你的项目 node_modules/.pnpm中建立一个硬链接,而你项目内的所有依赖都是软链接到.pnpm目录下的硬链接上,这样做到了所有依赖不同版本都只会安装一次,绝对不会重复安装。
我们可以进入node_modules/中执行
ls -l
系统会输出诸如这样的链接关系,这就是软链接:
lrwxrwxrwx 1 simonju users 73 Jul 9 19:22 A -> ../.pnpm/xxxxxxx
而你进入 node_modules/.pnpm
中的所有文件都是硬链接到你的电脑上全局缓存的
pnpm store path #可以拿到缓存的位置
也就是你所有项目的共同依赖都是在全局缓存上的,并且共享同一个版本,只会安装一次。这样可以极大的加快依赖安装速度。
我个人也十分推荐使用pnpm来代替npm管理依赖,尤其是中大型项目上,直接支持单仓模式,性能高,稳定可靠,可完全替代npm使用。