Contents

包管理发展史

npm

2010年npm发布。

嵌套结构

主依赖在 node_modules 下,子依赖嵌套在主依赖的 node_modules 中。但会造成依赖地狱。

npm最开始的 node_modules 采用嵌套结构。比如项目依赖了 A 和 C,而 A 和 C 依赖了不同版本的 B@1.0B@2.0node_modules 结构如下:

node_modules
├── A@1.0.0
│ └── node_modules
│   └── B@1.0.0
└── C@1.0.0
  └── node_modules
    └── B@2.0.0

如果 D 也依赖 B@1.0,会生成如下的嵌套结构:

node_modules
├── A@1.0.0
│ └── node_modules
│   └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│   └── B@2.0.0
└── D@1.0.0
  └── node_modules
    └── B@1.0.0

可以看到同版本的 B 分别被 A 和 D 安装了两次。即依赖地狱。

依赖地狱 Dependency Hell

在真实场景下,依赖增多,冗余的包也变多,node_modules 最终会堪比黑洞,很快就能把磁盘占满。而且依赖嵌套的深度也会十分可怕,这个就是依赖地狱。

扁平结构

子依赖和主依赖会尽量平铺在主依赖项所在的目录中,但会造成新问题:幽灵依赖,不确定性,依赖分身。

为了解决依赖地狱。npm v3将 采用扁平的 node_modules 结构。

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
  └── node_modules
    └── B@2.0.0

幽灵依赖 Phantom dependencies

由于扁平结构子依赖提升,导致在package.json中没有声明的依赖,仍然在项目中正常被 import。

比如我们只安装了 A 和 C,在package.json中:

{
  "dependencies": {
    "A": "^1.0.0",
    "C": "^1.0.0"
  }
}

由于 B 在安装时被提升到了和 A 同样的层级,所以在项目中引用 B 还是能正常工作的。如果某天 A 依赖不再依赖 B 或者 B 的版本发生了变化,那么就会造成依赖缺失或兼容性问题。

不确定性 Non-Determinism

同样的 package.json 文件,install 依赖后可能不会得到同样的 node_modules 目录结构

比如A 依赖 B@1.0,C 依赖 B@2.0,依赖安装后究竟应该提升 B 的 1.0 还是 2.0:

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
  └── node_modules
    └── B@2.0.0

或者:

node_modules
├── A@1.0.0
│ └── node_modules
│   └── B@1.0.0
├── B@2.0.0
└── C@1.0.0

这取决于用户的安装顺序。但如果有 package.json 变更,本地需要删除 node_modules 重新 install,否则可能会导致生产环境与开发环境 node_modules 结构不同,代码无法正常运行。

依赖分身 Doppelgangers

相同版本的依赖被重复安装

假设继续再安装依赖 B@1.0 的 D 模块和依赖 @B2.0 的 E 模块,此时:

  • A 和 D 依赖B@1.0
  • C 和 E 依赖B@2.0

此时 B@1.0node_modules 结构:

node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
│ └── node_modules
│   └── B@2.0.0
└── E@1.0.0
  └── node_modules
    └── B@2.0.0

可以看到 B@2.0 会被安装两次,而且虽然看起来模块 C 和 E 都依赖 B@2.0,但其实引用的不是同一个 B,假设 B 在导出之前做了一些缓存或者副作用,那么使用者的项目就会因此而出错。

yarn

2016 年,yarn 发布,yarn 也采用扁平化 node_modules 结构。它的出现是为了解决 npm v3 几个重要的问题:

  • 依赖安装速度慢
  • 不确定性

提升安装速度

在 npm 中安装依赖时,安装任务是串行的,会按包顺序逐个执行安装,这意味着它会等待一个包完全安装,然后再继续下一个。

为了加快包安装速度,yarn 采用了并行操作,在性能上有显著的提高。而且在缓存机制上,yarn 会将每个包缓存在磁盘上,在下一次安装这个包时,可以脱离网络实现从磁盘离线安装。

解决不确定性

yarn 更大的贡献是发明了 yarn.lock。在依赖安装时,会根据 package.josn 生成一份 yarn.lock 文件。lockfile 里记录了依赖,以及依赖的子依赖,依赖的版本,获取地址与验证模块完整性的 hash

即使是不同的安装顺序,相同的依赖关系在任何的环境和容器中,都能得到稳定的 node_modules 目录结构,保证了依赖安装的确定性.

而 npm 在一年后的 v5 才发布了 package-lock.json

但幽灵依赖和依赖分身的问题依然没有解决。

于是诞生了新的轮子pnpm

pnpm

pnpm解决了几个问题:

  1. 相同依赖重复下载
  2. 幽灵依赖
  3. 依赖分身

项目的 node_modules 文件夹只有当前 package.json 中所声明的各个依赖(的软连接),而真正的模块文件,存在于 node_modules/.pnpm,由 模块名@版本号 形式的文件夹扁平化存储(解决依赖重复安装)。同时这样设计,也很好的避免了之前可以访问非法 npm 包的问题(幽灵依赖),因为当前项目的 node_modules 只有我们声明过的依赖,这也让 node_modules 里面的文件看起来非常的直观。

node_modules/.pnpm 中存储的文件其实是 pnpm 实际缓存文件的「硬链接」,从而避免了多个项目带来多份相同文件引起的空间浪费问题(解决依赖分身)

内容寻址存储 CAS

多个项目依赖一个相同的包,例如react@17.0.13 ,用npmyarn时,每个项目都会在各自的node_modules 保存的React包,。

解决方法:集中化保存依赖。

如果使用pnpm 安装依赖,它首先会将依赖下载到一个公共仓库(~/.pnpm-store),依赖的每个版本只会在系统中安装一次。

在项目中的node_modules中创建依赖的硬链接指向公共仓库,而不会将包保存到node_modules

硬链接 Hard link:硬链接可以理解为源文件的副本(不同的文件名对应到同一个存储块节点),项目里安装的其实是副本,它使得用户可以通过路径引用查找到全局 store 中的源文件,而且这个副本根本不占任何空间。同时,pnpm 会在全局仓库里存储硬链接,不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间

符号链接 Symbolic link:也叫软连接,可以理解为快捷方式,pnpm 可以通过它找到对应磁盘目录下的依赖地址

使用 pnpm 安装依赖后 node_modules 结构如下:

node_modules
├── .pnpm
│   ├── A@1.0.0
│   │   └── node_modules
│   │       ├── A => <store>/A@1.0.0
│   │       └── B => ../../B@1.0.0
│   ├── B@1.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@1.0.0
│   ├── B@2.0.0
│   │   └── node_modules
│   │       └── B => <store>/B@2.0.0
│   └── C@1.0.0
│       └── node_modules
│           ├── C => <store>/C@1.0.0
│           └── B => ../../B@2.0.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C

这样设计解决了:

  • 幽灵依赖问题:只有直接依赖会平铺在 node_modules 下,子依赖不会被提升,不会产生幽灵依赖
  • 依赖分身问题:相同的依赖只会在全局仓库中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身

弊端:

  • 由于 pnpm 创建的 node_modules 依赖软链接,因此在不支持软链接的环境中,无法使用 pnpm,比如 Electron 应用
  • 因为依赖源文件是安装在 store 中,调试依赖或 patch-package 给依赖打补丁也不太方便,可能会影响其他项目。
  • pnpm 相当于所有项目都依赖了同一个文件,在一个项目中修改了某个 npm 包的文件,就会影响到其他项目,不过默认会使用 copy-on-write 的方式来进行处理,也就是如果尝试对内容进行修改的话,会复制一份文件而不会影响到源文件

最后贴一个依赖管理的原理图:

/../static/images/2579a1f5207a4837a049fd7c6619211e_tplv-k3u1fbpfcp-zoom-in-crop-mark_3024_0_0_0.png