历史变迁
从npm->yarn->pnpm可以看成一个包管理工具变迁的进程。本文主要梳理目前不同包管理工具之间的 差异 和 缺陷,便于日后使用中能够根据场景选择。
1.1 为什么需要npm
在npm发布之前,都是通过手动的方式下载和管理依赖项的,诸多不便。因此,形成了如今我们常用的包管理方式------将所需依赖名添加到package.json文件中,并将下载的依赖文件添加到node_modules中。那么不同版本的npm的包结构都是什么样:
npm v1 & v2
kotlin
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
- 重复依赖:不同模块下的相同依赖被重复安装
- 依赖地狱:依赖嵌套层级很深,体积过大,安装速度慢
- 实例不共享:同一个依赖分包引入,两个包可能存在不在同一模块的情况,导致无法共享变量,导致bug问题
npm v3
kotlin
node_modules
├── A@1.0
├── B@1.0
└── node_modules
└── C@2.0
└── C@1.0
A的C依赖不再放在A的路径下,而是变成扁平结构 ,和A、B同级。但是由于C存在两个相同版本,顶级提出C@1.0而C@2.0版本放在B的依赖下
- 算法耗时长:由于扁平树的计算,耗时比过往版本更长
- 幽灵依赖 :例如上述的
C@1.0依赖,虽然项目中没有添加,但是依旧可以在项目中引入使用。这造成了依赖不可控的问题,后续假设A@1.0不再使用C@1.0以后,项目引入的C@1.0就会存在问题 - 依赖分身 :虽然象征性的提升了子依赖层级,但是其实并没有根本的解决重复依赖的问题。在下面的依赖结构中,还是存在相同依赖
C@2.0被重复引入的问题。
kotlin
node_modules
├── A@1.0
├── B@1.0
│ └── node_modules
│ └── C@2.0
├── C@1.0
├── D@1.0
└── E@1.0
└── node_modules
└── C@2.0
- 破坏单例模式 :如上所示,虽然
C@2.0是同一版本的同一依赖,但是本质上是不同的对象。如果依赖中存在单例模式如下,则会出现破坏单例的情况
js
// 在某些情况下,可能会有多个版本的同一个包
// 导致单例失效
// package-a 使用的版本
const dbConnection1 = require('my-db-singleton'); // 来自顶层
// package-b 使用的版本
const dbConnection2 = require('my-db-singleton'); // 来自嵌套目录
// 在 npm v3 中,这两个可能是不同的实例!
console.log(dbConnection1 === dbConnection2); // 可能为 false
1.2 为什么需要yarn
为解决npm v3上述问题,yarn率先提出了:
- 锁版本的版本控制方式 :
yarn.lock - 并发的网络请求:npm是串行的包安装模式,一个包安装完再去安装下一个。与此不同的是,yarn实现了并行的安装模式,提升了包安装速度。
- 新增离线缓存机制:yarn会将包安装到本地磁盘,当第二次再进行安装时,可支持离线安装。
json
// package-lock.json 锁定了具体版本
{
"dependencies": {
"my-singleton": {
"version": "1.2.5", // 锁定具体版本
"integrity": "sha512-..."
},
"dep-a": {
"version": "1.0.0",
"requires": {
"my-singleton": "^1.0.0" // 解析为 1.2.5 (满足 ^1.0.0)
}
},
"dep-b": {
"version": "2.0.0",
"requires": {
"my-singleton": "^1.2.0" // 解析为 1.2.5 (满足 ^1.2.0)
}
}
}
}
结果目录结构:
kotlin
node_modules/
├── my-singleton@1.2.5/ # 唯一版本,所有依赖共享
├── dep-a@1.0.0/
└── dep-b@2.0.0/
1.3 为什么需要pnpm
yarn虽然提升了包安装速度,解决了部分npm问题,但是依旧没有解决幽灵依赖 、依赖分身 的问题
json
// 无法统一的情况
{
"dependencies": {
"dep-a": {
"version": "1.0.0",
"requires": {
"my-singleton": "^1.0.0" // 需要 >=1.0.0 <2.0.0
},
"dependencies": {
"my-singleton": {
"version": "1.5.0" // 嵌套安装
}
}
},
"dep-b": {
"version": "2.0.0",
"requires": {
"my-singleton": "^2.0.0" // 需要 >=2.0.0 <3.0.0
},
"dependencies": {
"my-singleton": {
"version": "2.1.0" // 嵌套安装
}
}
}
}
}
kotlin
// 生成包结构
node_modules/
├── dep-a@1.0.0/
│ └── node_modules/
│ └── my-singleton@1.5.0/ # 分身 1
└── dep-b@2.0.0/
└── node_modules/
└── my-singleton@2.1.0/ # 分身 2
而pnpm就采用内容寻址方式,很好的解决了yarn遗留的问题
pnpm解决幽灵依赖
2.1 什么是幽灵依赖
幽灵依赖是指你的代码能够导入和使用某个包,但这个包并没有在你的 package.json 中声明的现象。
json
// package.json
{
"dependencies": {
"antd": "^4.24.0"
}
}
jsx
// 你的代码
import React from 'react'; // 幽灵依赖!antd 带来的
import moment from 'moment'; // 幽灵依赖!antd 带来的
import { Button } from 'antd';
// 这些代码能工作,但很危险
const App = () => {
const now = moment(); // 如果 antd 某天不用 moment 了呢?
return <Button>Click me</Button>;
};
2.2 硬连接 & 符号连接
pnpm通过内容寻址的方式解决了幽灵依赖的问题,并高效提升了磁盘利用率。其中有两个很关键的概念:
- 硬链接 Hard link:硬链接可以使得用户可以通过路径引用查找到全局 store 中的源文件。不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间。
- 符号链接 Symbolic link :也叫软连接,可以理解为快捷方式,pnpm 可以通过它找到当前项目下的同版本依赖项。
kotlin
node_modules
├── .pnpm
│ ├── A@1.0
│ │ └── node_modules
│ │ ├── A => <store>/A@1.0
│ │ └── B => ../../B@1.0
│ ├── B@1.0
│ │ └── node_modules
│ │ └── B => <store>/B@1.0
│ ├── B@2.0
│ │ └── node_modules
│ │ └── B => <store>/B@2.0
│ └── C@1.0
│ └── node_modules
│ ├── C => <store>/C@1.0
│ └── B => ../../B@2.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C
如果遇到不同版本的同一依赖,则在store中缓存多版本的依赖项,并且通过硬连接映射
kotlin
.pnpm-store
├── C@1.1
└── C@2.3
kotlin
node_modules/
.pnpm/
A@x.x_xxx/
node_modules/
C/ -> 硬链接到 .pnpm-store/C@1.1
B@x.x_xxx/
node_modules/
C/ -> 硬链接到 .pnpm-store/C@2.3