NPM 存在的问题
考虑一个场景:
项目里依赖了A和C,A和C分别依赖B的不同版本,node应该如何读取依赖?
// A =>B@1.0
// C/D => B@2.0
node_modules
A
node_modules
B@1.0
C
node_modules
B@2.0
这里存在的问题:B是否支持多版本共存?
- 不支持(如core-js),需要尽早提示
- 支持,需要确保A和C各自加载到对应版本的依赖B
npm解决方式:依赖的node加载模块的路径查找算法和node_modules的目录结构来配合解决。
- 优先读取最近的node_modules的依赖
- 递归向上查找node_modules依赖
简单的做法带来了很多问题——
NPMv2: Nest mode
如果再依赖一个包D,这个包再依赖B v2.0?
// A =>B@1.0
// C/D => B@2.0
node_modules
A
node_modules
B@1.0
C
node_modules
B@2.0
D
node_modules
B@2.0
我们发现这里存在个问题,虽然C和D依赖了同一个版本的B,但是B却安装了两遍。
如果你的应用了很多的第三方库,同时第三方库共同依赖了一些很基础的第三方库,如lodash,你会发现你的node_modules里充满了各种重复版本的lodash,造成了极大的空间浪费,也导致npm install很慢,这既是臭名昭著的node_modules hell。
NPM v3: Flat mode
可以利用递归向上查找的特性来减少一些空间浪费:
// A =>B@1.0
// C/D => B@2.0
node_modules
A
node_modules
B@1.0
C
node_modules
D
node_modules
B@2.0
// B@2.0 is hoisted
better,但问题依然存在。
###doppelgangers - 分身
// A/E =>B@1.0
// C/D => B@2.0
node_modules
A
node_modules
B@1.0
C
node_modules
D
node_modules
B@2.0
E
node_modules
B@1.0
只能有一个提升的版本——
无论如何,都会存在重复包的问题。
版本重复的问题?
- 占用空间
- npm install 变慢
- 全局types冲突(见node_modules困境 - 杨健@知乎)
- 破坏单例模式
- ……
Phantom Dependency - 幽灵依赖
// package.json
{
...,
"dependencies": {
//这里没有B
}
"devDependencies": {
"C": "^1.2.0"
}
}
// file directory
// C => B@2.0
node_modules
C
node_modules
B@2.0
// in code
const b = require('B') // no errors
这里B不在我们的依赖里,但我们能引用,因为B是C的依赖,B被提升到了root-level的node_modules目录里。
但是如果将使用了phantom dependency的包对外发布了,使用者在安装时,不会安装devDependencids,因此在运行时会报错。
Monorepo的情况
因为我对Monorepo并不算特别熟悉,这一段基本是引用@杨健 的博客:
Monorepo 中的新问题
如果说第三方库里存在的依赖问题一定程度上还比较可控,那么当我们进入monorepo领域,问题就会被加倍放大。当我们用一个仓库管理多个package的时候,有两个比较严重的问题:
- 第三方依赖的重复安装问题,如果packageA和packageB里都使用了lodash的同一版本,没有优化的情况下,需要两个package都重复安装相同的lodash版本。
- link hell: 如果A依赖B,B依赖C和D,我们每次开发,都需要执行将C和Dlink到B里,如果拓扑图很复杂的话,手动做这些link操作是难以接受的。
lerna/yarn的解决方式
- 将所有package的依赖都尽量以flat模式安装到root level的node_modules里(即hoist),避免各个package重复安装第三方依赖,将有冲突的依赖,安装在自己package的node_modules里,解决依赖的版本冲突问题。
- 将各个package都软链到root level的node_modules里,这样各个package利用node的递归查找机制,可以导入其他package,不需要自己进行手动的link,解决link hell问题。
- 将各个package里node_modules的bin软链到root level的node_modules里,保证每个package的npm script能正常运行。
解决了依赖重复和link hell的问题,但:
- packageA可以轻松的导入packageB,即使没有在packageA里声明packageB为其依赖,甚者packageA可以轻松地导入packageB的第三方依赖,这实际上将Phantom dependency加剧放大了
- packageA里的依赖和packageB的第三方依赖的冲突可能性更大了,如packageA用了webpack3和packageB用了webpack4,这就很容易产生冲突,实际上是加剧了doppelgangers 问题
PNPM
PNPM: Explicit is better than implicit.
https://www.pnpmjs.cn/symlinked-node-modules-structure
PNPM 如何解决这些问题?
pnpm的做法
在往下阅读之前,请先阅读使用符号链接的 node_modules 结构,了解pnpm的node_modules结构设计。
####解决Phantom Dependency
一个例子,依赖关系:foo > bar > bar
node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ ├── bar -> <store>/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
├── foo@1.0.0
│ └── node_modules
│ ├── foo -> <store>/foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ └── qar -> ../../qar@2.0.0/node_modules/qar
└── qar@2.0.0
└── node_modules
└── qar -> <store>/qar
因为pnpm创建的root-levelnode_modules
目录下只有本项目的直接依赖,因此node.js的模块解析机制无法解析到间接依赖,从而解决了幽灵依赖的问题。
解决doppelgangers
还是上面的例子;pnpm通过软链接的方式,将依赖链接到virtual-store中(全局唯一的依赖储存位置)。所有的依赖在硬盘中都只会有一份!
参考文章: