npm、Yarn 和pnpm 是Node.js 的三种主流包管理器,主要区别在于依赖包的管理方式和性能优化:npm 是标准包管理器,Yarn 引入了缓存和锁文件提高速度和确定性,而pnpm 通过共享存储区和硬链接/符号链接技术,实现更快的安装速度、更小的磁盘占用和更严格的依赖管理,有效解决了幽灵依赖问题。
npm (Node Package Manager)
特性
Node.js 官方的标准包管理器,通过 package.json 文件管理项目依赖。
安装方式
早期版本使用串行下载,导致安装速度较慢,但后续版本有所优化。
依赖管理
在 npm v3
之前,node_modules 的目录结构是嵌套的。每个包的依赖会放在自己的 node_modules 子目录中,这样会导致同一个依赖在不同层级重复出现,路径可能会非常深(Windows 下甚至会出现 "路径过长" 错误)。下面这里 C@1.0.0 被安装了两次,浪费磁盘空间。
依赖包被平铺地存储在每个项目的 node_modules 目录中,导致重复存储占用大量磁盘空间。npm v3 开始引入 依赖扁平化(deduplication/flattening) 机制。当安装依赖时,npm 会尽可能把依赖提升到项目根目录的 node_modules 中,这样可以让多个包共享同一个依赖副本。减少了重复安装,缩短了文件路径,提高了安装速度。虽然比嵌套结构好,但平铺存储仍然有缺点。(1)磁盘空间浪费依然存在。每个项目仍然会在自己的 node_modules 中保存一份依赖。如果你有 10 个项目都用到了 lodash,硬盘上就有 10 份 lodash 副本。(2)依赖树不直观。node_modules 结构与 package.json 声明的依赖关系不一致。调试依赖问题时比较困难。(3)幽灵依赖(Phantom dependencies)子依赖被提升到顶层后,项目可以直接引用它,即使它没在 package.json 中声明。这会导致依赖关系不清晰,可能引发生产环境问题
javascript
project-嵌套结构
├── node_modules
│ ├── A
│ │ ├── node_modules
│ │ │ ├── C@1.0.0
│ ├── B
│ │ ├── node_modules
│ │ │ ├── C@1.0.0
javascript
project-扁平化结构
├── node_modules
│ ├── A
│ ├── B
│ ├── C@1.0.0 ← 被 A 和 B 共享
锁文件
引入 package-lock.json 来记录依赖版本,保证每次安装的确定性,但存在"幽灵依赖"问题。
Yarn
采用与npm 类似的方式进行依赖的扁平化管理,但也存在一定的重复存储和幽灵依赖问题。
特性
Facebook 开发的包管理器,旨在解决npm 的性能问题,提供更快的安装速度。
安装速度
通过并行下载和本地缓存,速度显著优于传统npm。
依赖管理
采用与npm 类似的方式进行依赖的扁平化管理,但也存在一定的重复存储和幽灵依赖问题。
锁文件
使用 yarn.lock 文件来保证依赖的确定性安装。
pnpm
● 特性:一种更高效的包管理器,通过独特的依赖管理机制提供诸多优势。
● 安装速度:利用硬链接和符号链接,在全局存储区共享依赖,避免了重复下载,速度更快。
● 磁盘空间:使用中心化的存储区管理所有依赖包,项目内的依赖通过链接指向存储区,大大节省了磁盘空间。
● 依赖管理:创建非扁平化的 node_modules 目录结构,通过符号链接组织依赖关系,有效解决了npm 和Yarn 存在的幽灵依赖问题。
● Monorepo 支持:由于其高效的依赖管理,pnpm 在Monorepo(单仓多项目)场景下表现尤为出色。
主要区别总结
● 依赖存储方式:npm 和Yarn 采用各自项目的 node_modules 目录进行依赖复制,而pnpm 则使用共享的全局存储区,并通过硬链接和符号链接来管理依赖。
● 磁盘占用:pnpm 极大地减少了磁盘空间占用,因为相同的依赖不会在每个项目中重复存储。
● 安装速度:pnpm 的共享存储和硬链接机制使其安装速度通常最快。
● 依赖管理:pnpm 的非扁平化 node_modules 结构解决了幽灵依赖问题,提供了更严格的依赖管理。
● 兼容性:pnpm 兼容npm 的 package-lock.json 和Yarn 的 yarn.lock 文件,方便迁移和集成。
🌟 pnpm 包依赖关系处理
- pnpm 存储依赖的底层逻辑
- 平时我们装依赖,文件会直接存在项目的 node_modules 里,但 pnpm 不这么干 ------ 它先把所有依赖(比如 foo 库、bar 库)统一存到一个 "公共仓库"(叫 "内容可寻址存储",你可以理解成 "全电脑共用的依赖仓库"),然后在项目的 node_modules 里,用 "硬链接" 把仓库里的文件 "借" 过来用。
- 硬链接的好处:不是复制文件,只是建了个 "快捷方式" 指向仓库里的真实文件,所以不管多少项目用同一个依赖,都只占一份空间,超省内存。
- 给每个依赖建立专属文件夹
- pnpm 会在 node_modules 里先建一个 .pnpm 文件夹,然后给每个依赖(比如 foo@1.0.0、bar@1.0.0)单独建个子文件夹,每个子文件夹里再套一层 node_modules,最后用硬链接把 "公共仓库" 里的依赖文件拉进来。比如 bar@1.0.0 的文件夹里,bar 相关的 index.js、package.json,都是指向公共仓库的硬链接,不是复制的新文件。这么绕一层的原因很简单:
- 让依赖自己能 "找到自己":比如 foo 库想读自己的 package.json,直接写 require('foo/package.json') 就能找到,不用乱找路径;
- 避免 "循环报错":比如 A 依赖 B,B 又依赖 A,这么建文件夹能防止链接绕圈圈。
- pnpm 会在 node_modules 里先建一个 .pnpm 文件夹,然后给每个依赖(比如 foo@1.0.0、bar@1.0.0)单独建个子文件夹,每个子文件夹里再套一层 node_modules,最后用硬链接把 "公共仓库" 里的依赖文件拉进来。比如 bar@1.0.0 的文件夹里,bar 相关的 index.js、package.json,都是指向公共仓库的硬链接,不是复制的新文件。这么绕一层的原因很简单:
- 用 "符号链接" 搭出 "依赖关系网"
- 依赖的文件就位后,pnpm 再用 "符号链接"(就是咱们平时说的 "软链接",一个指向其他文件夹的快捷方式)把它们串起来,满足 "谁依赖谁" 的需求:
- 因为 foo 依赖 bar,就给 foo@1.0.0 文件夹里的 node_modules 建个 bar 的软链接,指向 .pnpm 里的 bar@1.0.0 文件夹 ------ 这样 foo 要调用 bar 时,就能通过这个链接找到;
- 因为 foo 是咱们项目直接用的依赖,再给根目录的 node_modules 建个 foo 的软链接,指向 .pnpm 里的 foo@1.0.0 文件夹 ------ 这样咱们写代码时,直接 import foo 就能用。
- 依赖的文件就位后,pnpm 再用 "符号链接"(就是咱们平时说的 "软链接",一个指向其他文件夹的快捷方式)把它们串起来,满足 "谁依赖谁" 的需求:
- 这个结构的两大好处
- 避免套娃:传统的依赖管理会把很多依赖 "提到" 根目录的 node_modules 里,导致有些没在 package.json 里声明的依赖,项目也能用上(相当于 "偷偷用别人的东西"),后续很容易出问题。
- 幽灵依赖:而 pnpm 默认只让项目用 "明确声明过的依赖",只有你在 package.json 里写了的,才能通过链接找到 ------ 这样能避免很多 "明明本地能跑,线上跑不了" 的蠢错误。
有些第三方库本身写得不规范(比如它用了某个依赖,但没在自己的 package.json 里声明),如果按 strict 模式来,这些库会报错。所以 pnpm 默认加了个 "妥协方案":把所有依赖偷偷 "提" 到 .pnpm/node_modules 里,让这些不规矩的库也能找到依赖。如果想关掉这个妥协,把配置里的 hoist 设为 false 就行。
总结一下:pnpm 就是用 "硬链接省空间、软链接搭关系" 的方式,把 node_modules 管理得又省内存又规整,还能减少依赖混乱导致的错误,只是文件夹结构看起来绕了点,但跟 Node.js 的规则是完全兼容的。