通过 pnpm 安装依赖包会发生什么
通过 pnpm 下载的包都是放在一个全局目录(.pnpm-store)下,默认是在 ${os.homedir}/v3/.pnpm-store
,如果我们不确定在哪里,可以输入下面的命令手动配置:
bash
pnpm set store-dir [dir] --global
比如:
bash
pnpm set store-dir E:\pnpm\store --global
如果我们此时随便安装一个包,比如 express 那么首先放在全局目录下,之后在项目中创建一个硬链接指向全局目录。
在一个项目中安装 express
在另一个项目安装 express
我们发现上面的打印的消息不一样,一个是 reused 0, downloaded 64,另一个是 reused 64, download 0。
当我们通过 pnpm 安装依赖包,会首先在全局目录下查看是否存在相同的版本的包,如果存在,就可以直接复用,创建一个硬链接指向全局目录中已经安装的包就行了(所以它叫 reused,重复使用嘛)。如果版本不同或者之前没有安装这个包,才会下载到全局目录中,然后在项目中创建一个硬链接指向全局目录。
如果我们查看项目中的 node_modules 目录,会发现存在以下比较奇怪的结构(前提是依赖包是通过 pnpm 安装的)
假设我们安装了一个 a@1.0.0 这个依赖包
node_modules
└── .pnpm
└── a@1.0.0
└── node_modules
└── a -> <.pnpm-store>/a
├── index.js
└── package.json
我们看看这种目录里各个文件夹代表什么意思。
最外层的 node_modules 就是我们项目中的 node_modules,而 .pnpm 就是使用 pnpm 安装依赖包时会自动生成的一个目录,a@1.0.0 就是我们通过 pnpm 安装的依赖包名+版本号。这些都比较容易理解。令人困惑就是 a@1.0.0 中的结构。
前面讲到了我们通过 pnpm 安装依赖的包的时候,是先下载到全局目录(.pnpm-store)下的,然后在项目中通过硬链接到全局目录中的文件(也就是 a 目录下的index.js、package.json 文件是全局目录中的文件,硬链接只能链接文件),实现依赖包的复用。
那为什么在 a@1.0.0 以及 a 中加一个 node_modules 目录呢?
- 允许包本身导入自己:比如 a 可以通过
require('a/package.json')
或者import * as package from "a/package.json"
导入自身的 package.json 文件。 - 避免循环符号链接:依赖以及需要依赖的包被放置在一个文件夹下。 对于 Node.js 来说,依赖是在包的内部 node_modules 中或在任何其它在父目录 node_modules 中是没有区别的。
在看一个复杂一点的例子:
node_modules
└── .pnpm
├── a@1.0.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| | ├── index.js
| | └── package.json
| └── b -> ../../b@1.0.0/node_modules/b
| ├── index.js
| └── package.json
└── b@1.0.0
└── node_modules
└── b -> <.pnpm-store>/b
├── index.js
└── package.json
假如依赖包 a 中使用了依赖包 b,那么同样是跟依赖包 a 一样的操作,下载到全局目录中,然后在 .pnpm 生成一个依赖包名+版本号的目录(b@1.0.0),同时会将 node_modules/b 硬链接到全局目录中。
不过有点区别的是在 a@1.0.0 中的 node_modules 中也会创建一个目录符号链接指向 b@1.0.0/node_modules/b。此时我们在依赖包 a 中导入依赖包 b,Node 不会使用在 a@1.0.0/node_modules/b 中的 b,而是在它的实际位置 b@1.0.0/node_modules/b 中解析,也就是说"真实"文件其实是在 b@1.0.0/node_modules/b 中的(这种布局乍一看可能很奇怪,但它与 Node 的模块解析算法完全兼容! 解析模块时,Node 会忽略符号链接,直接找到符号链接的文件)。
注意,这里的真实并不是真实文件,这个"真实"文件是从全局目录中硬链接过来的,虽然从文件夹中查看它是存在内存大小的,但是实际上并不存在。
虽然以上的示例非常简单。 但是,无论依赖项的数量和依赖关系图的深度如何,布局都会保持这种结构。
在通过 pnpm 安装依赖包时,除了会在 .pnpm 中生成目录外,还是会 node_modules 中生成。
node_modules
├── .pnpm
| └── a@1.0.0
└── a -> .pnpm/a@1.0.0/node_modules/a
├── index.js
└── package.json
此时这个 a 同样是个目录符号链接,链接到 .pnpm/a@1.0.0/node_modules/a 中。因为 Node 需要在 node_modules 查找已安装依赖,否则会报错,提示找不到这个依赖,因此 node_modules 中也是需要存在安装的依赖包,只不过它是一个目录符号链接而已。
这种布局的一大好处是只有真正在依赖项中的包才能访问。使用平铺的 node_modules 结构,所有被提升的包都可以访问。
至于为什么说这是 pnpm 的优势,我们来实际安装一个依赖包看看:
以 express 为例,这是通过 pnpm 安装时生成的目录结构:
node_modules
├─ .pnpm
| └── express@4.19.2
| └── node_modules
| ├── ... (还有很多依赖包,这里不展示)
| ├── express -> <.pnpm-store>/express
| | ├── index.js
| | └── package.json
| └── debug -> ../../express@4.19.2/node_modules/express
| ├── node.js
| └── package.json
|
└── express -> .pnpm/express@4.19.2/node_modules/express
├── index.js
└── package.json
这是通过 npm 安装生成的目录结构:
node_modules
├── ... (还有很多依赖包,这里不展示)
├── debug
| ├── node.js
| └── package.json
└── express
├── index.js
└── package.json
乍一看好像 pnpm 更复杂,又有 .pnpm 目录,又有一堆目录符号链接,npm 看起来好像更简洁、干净。在我刚使用 pnpm,我也有这种感觉,但是 npm 这种的结构会导致一个非常愚蠢的问题!
那就是我们明明只安装了一个 express,为什么会在 node_modules 中可以获取到 express 中的依赖呢?由于在 node_modules 存在这些依赖,意味着我们是可以直接在项目中导入的!
js
import debug from 'debug';
因为 Node 不关注我们项目中的 package.json 定义的安装依赖,只要是在 node_modules 中就可以显示调用。
如果说我们确实在项目中使用 debug 依赖,那么这样直接使用确实可以工作,而且它甚至也能在生成环境中使用,但是我们可能没有考虑到一些情况:
- debug 更新了,移除了一些我们目前正在使用的特性,当 express 发布了新版本,我们通过 npm install 更新后会发现我们的项目即便没有任何更改也出现了问题。
- 还有一种可能是 express 突然不想使用 debug 了,将其从 dependencies 字段中移除后发布新版本,此时我们 npm install 更新后同样会出现问题。
而 pnpm 这种设计就确保了只有通过 pnpm 安装的依赖才会在 node_modules 生成对应的文件夹,不会像 npm 一样将某个依赖包中的依赖全部都放在 node_modules 中。
当然,npm 是修复了这个问题的,通过配置 npm c set install-strategy shallow
可以将直接安装的依赖才放在 node_modules 中,而依赖包中的依赖则是放在依赖包中的 node_modules 中。但是,我们有多少人知道并使用过这个配置?
比如:
node_modules
└── express
├── node_modules
| ├── ...
| └── debug
| ├── node.js
| └── package.json
├── index.js
└── package.json
通过 npm 安装的依赖并不存在一个全局目录,只要安装的依赖都是放在 node_modules 中,如果我们有非常多项目都依赖了同一个依赖,那就意味着我们要对同一个依赖安装多次,非常占用内存。而 pnpm 则不同,它会放在一个全局目录中进行复用,在项目中的依赖都是一个硬链接而已,虽然在文件夹中查看 node_modules 目录它显示了占用内存,但实际上它并不占用,如果我们是 window 电脑,可以通过 fsutil hardlink list [filename]
查看该文件的硬链接数:
\nvm\store\v3\files\76\6d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
\leo\pnpm\node_modules\.pnpm\express@4.19.2\node_modules\express\index.js
\$RECYCLE.BIN\S-1-5-21-2040100086-518969392-3969120953-1001\$RPTAE6E\.pnpm\express@4.19.2\node_modules\express\index.js
peerDependencies 的处理
上面的讲解都是基于依赖包内没有 peerDependencies 的情况,如果存在 peerDependencies ,会有不同处理:
如果一个依赖包中没有 peerDependencies,它先创建一个硬链接(b@1.0.0/node_modules/b),然后这个硬链接目录符号链接到其他依赖包中的 node_modules 中,比如前面介绍的例子:
node_modules
└── .pnpm
├── a@1.0.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| | ├── index.js
| | └── package.json
| └── b -> ../../b@1.0.0/node_modules/b
| ├── index.js
| └── package.json
└── b@1.0.0
└── node_modules
└── b -> <.pnpm-store>/b
├── index.js
└── package.json
如果一个依赖包存在 peerDependencies,比如依赖包 a 中存在 b、c 两个 peerDependencies:
json
{
"peerDependencies": {
"b": "^1.0.0",
"c": "^1.0.0"
}
}
在项目中我们导入了 foo、bar 两个依赖包,都需要 a 这个依赖包,而且这两个依赖包也同时导入了 b、c 两个依赖,但是版本不一样。
foo 需要 a@1.0.0、b@1.0.0、c@1.0.0,而 bar 需要 a@1.0.0、b@1.0.0、c@1.1.0。这时候 a 就会有多组依赖项:
node_modules
└── .pnpm
├── a@1.0.0_b@1.0.0+c@1.0.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| ├── b -> ../../b@1.0.0
| └── c -> ../../c@1.0.0
├── a@1.0.0_b@1.0.0+c@1.1.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| ├── b -> ../../b@1.0.0
| └── c -> ../../c@1.1.0
├── b@1.0.0
├── c@1.0.0
└── c@1.1.0
可以看到本来只需要一个 a@1.0.0 就能搞定,但是因为 peerDependencies 得存在需要根据版本号生成两个依赖项组(a@1.0.0_b@1.0.0+c@1.0.0、a@1.0.0_b@1.0.0+c@1.1.0)。
如果依赖包 a@1.0.0 没有 peer 依赖,但是它依赖的 b@1.0.0 存在 peer 依赖 c@^1,在我们项目中存在 c@1.0.0 及 c@1.1.0,那么会形成如下的结构:
node_modules
└── .pnpm
├── a@1.0.0_c@1.0.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| └── b -> ../../b@1.0.0_c@1.0.0
├── a@1.0.0_c@1.1.0
| └── node_modules
| ├── a -> <.pnpm-store>/a
| └── b -> ../../b@1.0.0_c@1.1.0
├── b@1.0.0_c@1.0.0
| └── node_modules
| ├── b -> <.pnpm-store>/b
| └── c -> ../../c@1.0.0
├── b@1.0.0_c@1.1.0
| └── node_modules
| ├── b -> <.pnpm-store>/b
| └── c -> ../../c@1.1.0
├── c@1.0.0
└── c@1.1.0
url 链接,如果我们通过
npm config set registry <registry-url>
改变了 npm 源,那么我们在 .pnpm 目录中可能看到类似fast-glob@https+++registry.npmmirror.com+fast-glob+-+fast-glob-3.3.2.tgz
这样的 @ 字符后边不是具体版本号的目录名,不用奇怪,就把他当作是版本号即可。因为这个依赖包不是通过从公共注册表中获取的,而是直接从自定义的 NPM 源或镜像获取的。