前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

拯救磁盘空间 为什么 pnpm 是更好的包管理工具

首页2023-05-26 14:40:12Front-End
pnpmnpmyarn包管理工具前端工程化Workspacemonorepo

有天我清理 Mac 硬盘,跑了一句 du -sh ~/projects/*/node_modules,结果加起来三十多个 G。十几个项目,每个都装了自己那一份 react、lodash、webpack,同一个版本的同一份代码在磁盘上被写了十几遍。删了一轮,过两周装回来又是三十个 G。

这就是我当初换 pnpm 的直接动机。后来用下来发现省空间只是它顺手做到的事,真正让我不想再回去的是它把 node_modules 那套混乱的扁平结构给理清楚了。这篇把 pnpm 为什么这么设计、它的软硬链接到底怎么工作、Workspace 怎么用,从头捋一遍。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • npm 早期的嵌套 node_modules 踩过哪三个坑,为什么后来全行业改成扁平化
  • 扁平化又带来了幽灵依赖、结构不确定、非法访问这三个新问题
  • 硬链接和软链接的区别,以及 pnpm 的内容寻址存储是怎么省下磁盘的
  • node_modules/.pnpm 这个虚拟存储目录的结构长什么样,为什么这么设计
  • Workspace 从建目录到 filter 过滤、内部依赖、发布配置的完整用法
  • pnpm / yarn / npm 的功能对比,以及什么情况下别用 pnpm

# 一、npm 早期的嵌套 node_modules 是怎么把自己坑死的

在早期 npm 1、npm 2 的年代,项目的 node_modules 是严格的嵌套结构,你依赖谁,谁的依赖就装在谁自己的目录里:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json
@前端进阶之旅: 代码已经复制到剪贴板

这个结构其实很符合直觉,依赖关系一目了然。但它有三个致命问题。

问题一是依赖层级太深。依赖套依赖套依赖,路径长度会指数级增长。这在 Windows 上直接要命,因为文件路径默认最多支持 256 个字符,层级一深就会出现「路径过长导致文件删不掉」这种诡异情况,很多老前端应该都被这个折磨过。

问题二是包重复安装。比如 foo 和 zoo 都依赖于 bar,那么 bar 就会在两者的 node_modules 中被安装两次。项目一大,同一个包被装几十份是常态,磁盘和安装时间都撑不住。

问题三是模块实例不能共享。这个最阴。React 有一些内部变量(比如那个著名的 hooks dispatcher),如果两个包各自引入了自己那份 React,它们拿到的不是同一个模块实例,内部变量自然对不上,跑出来就是各种玄学 bug。Invalid hook call 那个经典报错里专门列了「你可能有多份 React」这一条原因,说的就是这个。

后来 yarn 横空出世解决了这些问题,npm 也在 3.0 版本中沿用了 yarn 的解决方案,这个方案就是扁平化依赖。

# 二、扁平化解决了老问题,又造出三个新问题

所谓扁平化,就是把所有依赖(包括依赖的依赖)都铺平,尽量放到 node_modules 的同一级目录下:

node_modules
├─ foo
│  ├─ index.js
│  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json
@前端进阶之旅: 代码已经复制到剪贴板

Node 的模块解析规则是逐层向上找 node_modules,所以铺平之后 foo 依然能找到 bar。层级深、重复装、实例不共享这三个问题一次性解决了。

但这套处理方式自己也带来了新麻烦。

幽灵依赖。你明明没有在 package.json 的 dependencies 里声明某个包,代码里却能直接 require 或 import 进来。因为它是被依赖的依赖提升上来的,物理上就躺在 node_modules 根目录,Node 当然找得到。

这东西的危害不在当下,在未来。你显式依赖了 A,A 依赖了 B,你在项目里直接用 B 一点问题没有。但某天 A 升级不再依赖 B 了,或者 A 把 B 换成了别的版本,你项目里所有用 B 的地方就集体报错,而你的 package.json 上看不出任何线索。这个我踩过,排查了一下午才想明白是自己一直在偷用一个从没声明过的包。

依赖结构不确定。假设项目同时依赖 foo 和 bar,两者又依赖不同版本的 lodash。谁的版本被提升到根目录、谁被留在自己的 node_modules 里,取决于 package.json 里的声明顺序和解析先后。同样一份 package.json,两台机器装出来的 node_modules 结构可能不一样。

这正是 lock 文件诞生的原因。无论是 package-lock.json 还是 yarn.lock,都是为了把这个不确定的结构钉死,保证每次 install 之后产生一致的结果。

非法访问依赖。跟幽灵依赖是一体两面,但在 monorepo 里危害更大。假设包 A 依赖 X、包 B 依赖 X,还有一个包 C 不依赖 X 但代码里用了 X。由于依赖提升,X 被放在了根目录的 node_modules,C 在本地跑得好好的。可一旦 C 被单独发布出去,用户单独安装 C,运行到引用 X 的代码就直接崩了。本地跑通不代表发出去能跑,这类问题往往要到用户手上才暴露。

正是这些问题促使了 pnpm 的诞生。

# 三、pnpm 是什么

pnpm(Performant NPM)是一个快速的、节省磁盘空间的包管理工具,同时它对 monorepo 有原生的良好支持。

# 安装pnpm
npm i pnpm -g
@前端进阶之旅: 代码已经复制到剪贴板

现在更推荐的装法是用 Node 自带的 Corepack,在项目 package.json 里写一行 "packageManager": "pnpm@9.x.x",然后 corepack enable,团队所有人用的版本就自动对齐了,不用挨个叮嘱「你的 pnpm 版本是多少」。原文那种全局 npm i -g 的装法当然也能用,只是版本容易在团队里跑偏。

它的用处和 npm、yarn 没有本质区别,命令都长得差不多,pnpm install、pnpm add、pnpm run 上手几乎零成本。差别全在底层怎么组织 node_modules。

# 四、pnpm 快在哪,省在哪

# 4.1 安装速度

按官方文档的 benchmark 数据,pnpm 在大多数场景下的安装速度都优于 npm 和 yarn,原文记录的量级是快 2 到 3 倍。这个数字受缓存状态、lock 文件是否存在、网络环境影响很大,你自己项目上跑出来的比例可能完全不同,建议实测再下结论。

快的原因主要是两条:并行的网络请求,以及本地那个全局 store 的复用。装过一次的包再装就是本地建链接,几乎不耗时。

对 yarn 熟悉的同学可能会说,yarn 不是有 PnP 安装模式吗,直接干掉 node_modules,把依赖内容写进 .yarn/cache,省掉大量文件 I/O,也能提速。这话没错,PnP 在纯安装速度上确实很猛。不是说 PnP 不行,而是它改变了 Node 的模块解析方式,很多工具链(尤其是那些自己实现了模块查找的老工具)需要专门适配,迁移成本不低。pnpm 走的是「保留 node_modules、但把结构做对」这条更保守的路,兼容性上省心得多。

# 4.2 节省磁盘空间

这才是 pnpm 最直观的杀手锏。它内部使用基于内容寻址的存储(Content-addressable store,简称 CAS)来存放依赖,所有包的文件按内容哈希存在一个全局 store 里,项目里的 node_modules 只是指向它的链接。

要理解这套机制,得先分清两种链接。

  • 硬链接(hard link):多个文件名平等地指向磁盘上的同一份数据。删掉其中任何一个都不影响其他,只有当所有硬链接都被删除时,这份数据才真正被回收。
  • 软链接 / 符号链接(symbolic link):一个独立的小文件,里面存的是一条指向另一个文件或目录的路径。它相当于 Windows 里的快捷方式,本身的存在不依赖目标文件,目标没了它就变成一个断掉的链接。

差别记住一点就够了:硬链接指向的是数据本身,软链接指向的是路径。

有了硬链接,pnpm 的省空间策略就好理解了。

同一个包只在磁盘上存一份。用 npm 或 yarn 时,如果 100 个项目都依赖 lodash,lodash 很可能就被完整写入磁盘 100 次。用 pnpm 则只写一次,后面 99 次都是硬链接过去,几乎不占额外空间。

不同版本之间复用未变的文件。因为 store 是按文件内容寻址的,假设 lodash 有 100 个文件,升级之后只新增了 1 个、改了 2 个,那磁盘上不会重新写 101 个文件,而是复用没变的那 97 个,只写入新增和改动的那几个。包体越大、版本迭代越频繁,这个收益越明显。

这里有个前提条件很多人不知道:硬链接只能在同一个文件系统内建立。如果你的全局 store 和项目目录不在同一个磁盘分区(比如项目放在外接移动硬盘上),pnpm 建不了硬链接,会退化成直接拷贝,省空间的效果就没了。用 pnpm store path 可以看到 store 的实际位置,遇到「怎么装完还是这么大」的时候先查这个。

# 4.3 安全性

pnpm 自创了一套虚拟存储目录的依赖管理方式,把幽灵依赖和非法访问这两个问题从结构上堵死了。

在 pnpm 装出来的项目里,node_modules 根目录只会出现你在 package.json 里显式声明过的包。没声明的,物理上就不在那个位置,Node 的解析规则找不到,代码里 import 直接报错。

这个设计刚上手会有点不适应,因为它会把你项目里潜藏的幽灵依赖全部暴露出来,从 npm 迁过来第一次跑经常一堆红。但这些错误本来就该报,只是之前被扁平化结构掩盖了而已。

# 4.4 对 monorepo 的支持

pnpm 的 monorepo 支持体现在几乎每个子命令上,都能配合 -r(递归)和 --filter(过滤):

# 在根目录下执行,所有package都会被添加依赖
pnpm add A -r

# 使用filter指定package
pnpm add lodash --filter package-a
@前端进阶之旅: 代码已经复制到剪贴板

这块第五、六节会展开讲。

# 五、pnpm 的 node_modules 结构拆解

# 5.1 先看 npm/yarn 是怎么装的

执行 npm install 或 yarn install 之后,工具会先构建依赖树,然后对树上的每个节点做四件事:

  1. 把依赖包的版本区间解析成一个具体的版本号
  2. 下载对应版本的 tar 包到本地离线镜像
  3. 把依赖从离线镜像解压到本地缓存
  4. 把依赖从缓存拷贝到当前目录的 node_modules

注意最后一步是拷贝。这就是为什么 100 个项目会在磁盘上留下 100 份 lodash。pnpm 改的就是这一步,它不拷贝,改成建链接。

# 5.2 装一个 express 看看长什么样

用 pnpm 安装 express 之后,node_modules 是这样的:

node_modules
├─ .pnpm
│  ├─ express@4.17.1
│  │  └─ node_modules
│  │     ├─ accept
│  │     └─ express
│  └─ accepts@1.3.7
│     └─ node_modules
│        └─ accepts
└─ express  → 软链接,指向.pnpm/express@4.17.1/node_modules/express
@前端进阶之旅: 代码已经复制到剪贴板

第一眼就能看出区别:项目只依赖了 express,那么 node_modules 根目录下就只有 express 这一个入口,express 自己那一堆依赖全被收进了 .pnpm 这个虚拟存储目录里,不会污染根目录。幽灵依赖就是这么被堵掉的。

fe
  • 一、npm 早期的嵌套 node_modules 是怎么把自己坑死的
  • 二、扁平化解决了老问题,又造出三个新问题
  • 三、pnpm 是什么
  • 四、pnpm 快在哪,省在哪
    • 4.1 安装速度
    • 4.2 节省磁盘空间
    • 4.3 安全性
    • 4.4 对 monorepo 的支持
  • 五、pnpm 的 node_modules 结构拆解
    • 5.1 先看 npm/yarn 是怎么装的
    • 5.2 装一个 express 看看长什么样
    • 5.3 软链接和硬链接是怎么配合的
  • 六、pnpm 工作空间 Workspace 完全指南
    • 6.1 什么是 Monorepo
    • 6.2 创建 Workspace 项目
    • 6.3 常用命令
    • 6.4 Workspace 内部依赖
    • 6.5 发布配置
  • 七、日常命令速查
  • 八、三者功能对比一览
  • 九、什么情况下别急着换 pnpm
  • 总结
  • 参考

← 前端文件下载完全指南 12 种方案对比与实战代码Formily 核心概念解析与构建复杂表单的最佳实践总结 →