pnpm

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。

cover

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

只能有一个提升的版本——

无论如何,都会存在重复包的问题。

版本重复的问题?

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中(全局唯一的依赖储存位置)。所有的依赖在硬盘中都只会有一份!

参考文章:

node_modules困境 - 杨健@知乎

Symlinked node_modules structure