npm && yarn的问题
幽灵依赖 稳定性风险
假设如下情况 项目里引入了一个第三方包 这个三方包依赖了100个其他包。 因为 npm 和 Yarn 使用的 扁平化 node_modules 结构
。 所有的依赖都是平铺在node_modules中的。
所以此时node_modules目录已经有101个依赖包。如果我们使用了一个没有在package.json中声明但是存在于node_modules文件夹下的包 此时通过node的寻址规则 项目是可以正常解析到的。
这个不存在package.json中的包就是一个幽灵依赖。
这是非常危险的情况 因为我们的依赖是不稳定的 如果这个第三方依赖包 升级了版本 从原来的100个依赖 变成了 10个依赖 其中90个可能都是找不到的 这时我们的项目再运行就会有问题了。
版本分身 磁盘占用
假设如下情况 我们直接在项目中引入了lodash@4.x.x 然后又依赖的其他的第三方库 这些第三方引入了lodash@3.x.x和lodash@2.x.x等等 那么只有lodash@4.x.x会被平铺在最外层 其他的依然会嵌套在子包中
kotlin
node_modules/
├── lodash@4.x.x
├── A/
└── node_modules/
└── lodash@2.x.x
├── B/
└── node_modules/
└── lodash@3.x.x
├── C/
└── node_modules/
└── lodash@2.x.x
同一个包的不同版本会被重复下载对此,这只是其中一个项目 如果我们电脑上有几十个几百个项目呢?会对磁盘存储造成浪费。
软链接
- 软链接: 本质上是一个独立的文件,它包含指向目标文件或目录的路径。这意味着软链接存储的信息是指向目标文件或目录的路径名。
- 多个软链接:你可以创建多个软链接,这些软链接可以指向相同或者不同的目标文件/目录。
- 独立存在:软链接文件本身在文件系统中是独立存在的,如果目标文件被移动或删除,软链接会指向一个"断链"的路径,但它本身仍然存在。
symlink1 和 symlink2 都是独立的文件,它们内部包含指向 /path/to/original/file 的路径信息。这两个软链接可以独立存在,但它们都指向同一个目标文件。
硬链接
- 硬链接: 直接指向文件数据块,硬链接和目标文件共享相同的数据块。
- 等价关系 :硬链接和它指向的文件完全等价,
没有原始文件和链接文件之分
,所有硬链接共享同一数据块,对一个硬链接所做的任何修改都会反映到所有其他硬链接上。 - 删除影响:只有所有指向文件数据块的硬链接都被删除时,文件的数据块才会在文件系统上被释放。
hardlink1 和 hardlink2 共享相同的数据块,所以它们在文件系统中是完全等价的文件。
pnpm
解决幽灵依赖
我们有一个 monorepo 项目,其中包含两个子项目 project-a 和 project-b,它们的依赖关系如下:
- project-a 依赖 lodash
- project-b 依赖 moment
在传统的 npm 或 Yarn 环境下,由于 node_modules 目录是扁平化的,当 npm install 或 yarn install 之后,某个子项目(比如 project-a)即使没有显式声明对 moment 的依赖,它也能访问到 moment。
安装后的目录如下:
go
my-monorepo/
├── node_modules/
│ ├── lodash/
│ ├── moment/
├── packages/
│ ├── project-a/
│ │ ├── package.json
│ │ └── index.js
│ └── project-b/
│ ├── package.json
│ └── index.js
└── package.json
在 npm 或 Yarn 安装后,执行以下代码在 project-a 是可行的,但具有幽灵依赖的风险:
javascript
const lodash = require('lodash');
console.log(lodash.isEmpty({}));
const moment = require('moment');
console.log(moment().format()); // 没有在 package.json 中声明,但可以访问
在传统的 npm 和 Yarn 中,因为 node_modules 目录结构是扁平化处理的,导致 project-a 可以访问到 project-b 的 moment 依赖。
使用 pnpm 安装依赖后的项目结构:
bash
my-monorepo/
├── node_modules/
│ ├── .pnpm/
│ │ ├── lodash@4.17.21/
│ │ │ └── node_modules/
│ │ │ └── lodash/
│ │ ├── moment@2.29.1/
│ │ │ └── node_modules/
│ │ │ └── moment/
│ ├── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
│ ├── moment -> .pnpm/moment@2.29.1/node_modules/moment
├── packages/
│ ├── project-a/
│ │ ├── node_modules
│ │ │ └── lodash -> ../../../node_modules/.pnpm/lodash@4.17.21/node_modules/lodash
│ │ └── index.js
│ └── project-b/
│ ├── node_modules
│ │ └── moment -> ../../../node_modules/.pnpm/moment@2.29.1/node_modules/moment
│ └── index.js
└── package.json
在 pnpm 这种结构下,每个子项目只能访问其 node_modules 下的包,这些包是通过软链接指向 .pnpm 目录中的实际位置。
project-a 只能访问到其明确声明的依赖 lodash,无法访问 moment:
javascript
const lodash = require('lodash');
console.log(lodash.isEmpty({}));
try {
const moment = require('moment');
console.log(moment().format());
} catch (error) {
console.error('moment is not a declared dependency in project-a');
}
执行上述代码时将会抛出错误,因为 moment 存在于 project-b 中,而不是 project-a 的有效依赖范围之内。
- 独立的 node_modules 结构:每个项目的 node_modules 目录不仅仅是一个平铺的目录结构,而是一个通过软链接高效管理的嵌套目录。
- 明确的依赖管理:安装依赖时,pnpm 将所有的包存储在 .pnpm 目录,并严格根据项目的 package.json 中定义的依赖关系,通过软链接进行访问。这确保了每个包只能访问其显式声明的依赖。
节约磁盘空间
在使用 pnpm 时,pnpm 在用户主目录下维护一个全局缓存目录(例如:~/.local/share/pnpm/store),存储所有包的实际数据
bash
my-monorepo/
├── packages/
│ ├── project-a/
│ │ ├── package.json
│ │ └── node_modules/
│ │ └── lodash -> ../../.pnpm/lodash@4.17.21/node_modules/lodash (软链接)
│ ├── project-b/
│ │ ├── package.json
│ │ └── node_modules/
│ │ └── lodash -> ../../.pnpm/lodash@4.17.21/node_modules/lodash (软链接)
├── node_modules/
│ ├── .pnpm/
│ │ ├── lodash@4.17.21/
│ │ │ └── node_modules/
│ │ │ └── lodash/ 【这个目录中的内容是硬链接 指向全局中的lodash】
│ └── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash (软链接)
└── pnpm-workspace.yaml
根 node_modules & 子包的 node_modules 目录 :
里面的内容实际都是软链接,指向 .pnpm 目录中的实际包文件
.pnpm 目录
.pnpm 目录是 pnpm 用来存储实际包文件的地方。每一个包版本有一个独立的目录,包括它的所有文件。
.pnpm 目录中的包文件使用了硬链接机制,从全局存储中引用实际文件。这些包文件存储在全局缓存中,而 .pnpm 目录中的文件只是硬链接到全局缓存的位置。
其他的部分都是使用的软链接引用的项目地址。
通过这种方式可以 大大减少了磁盘占用 提高了资源利用率
。
在多项目环境中,pnpm 的机制确保依赖管理更严谨、资源利用更高效,从而为开发者提供了一个更稳定、高效的依赖管理工具。