在前端开发中,我们每天都会与各种npm包打交道,选择一个合适的包管理工具能显著提升开发体验和构建效率。近年来,pnpm作为一款新兴的包管理工具逐渐受到越来越多开发者青睐,它究竟有何魅力能让众多开源项目纷纷转投其怀抱?本文将带你深入了解pnpm的原理与优势。
# 一、为什么需要更好的包管理工具
# 1.1 npm与yarn的发展历程
在早期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有一些内部变量,在两个不同包引入的React不是同一个模块实例,因此无法共享内部变量,导致一些不可预知的bug。
后来yarn横空出世解决了这些问题,npm也在3.0版本中沿用了yarn的解决方案,这个解决方案就是扁平化依赖。
# 1.2 扁平化依赖带来的新问题
所谓的扁平化依赖就是将所有依赖铺平,放到同一级目录下:
node_modules
├─ foo
│ ├─ index.js
│ └─ package.json
└─ bar
├─ index.js
└─ package.json
虽然之前的问题得到了解决,但这种扁平化的处理方式自身也存在许多问题:
问题一:幽灵依赖
所谓的幽灵依赖是指我们明明没有在package.json的dependencies里声明某个依赖,但在代码里却可以import进来。因为项目依赖被铺平了,那么依赖的依赖自然也是可以被引入到项目中。
幽灵依赖带来的弊端很明显:我们显式依赖了A,A又依赖了B,这时候我们在项目中直接使用B是可以的,但如果某一天A不再依赖于B,那么我们项目中使用B的地方就会报错。
问题二:依赖结构不确定性
举例来说,如果项目同时依赖foo和bar,而它们都依赖不同版本的lodash,那么最终安装哪个版本是不确定的,完全取决于package.json中的声明顺序。
这就是为什么会产生依赖结构的不确定问题,也是lock文件诞生的原因,无论是package-lock.json还是yarn.lock,都是为了保证install之后都产生确定的node_modules结构。
问题三:非法访问依赖
由于扁平化结构,如果A依赖B,B依赖C,那么A当中是可以直接使用C的,但A并没有声明C这个依赖,这就是非法访问。
在monorepo项目中,如果A依赖X,B依赖X,还有一个C,它不依赖X,但它代码里面用到了X。由于依赖提升的存在,npm/yarn会把X放到根目录的node_modules中,这样C在本地是能够跑起来的。但试想一下,一旦C单独发包出去,用户单独安装C,那么就找不到X了,执行到引用X的代码时就直接报错了。
正是这些问题促使了pnpm的诞生。
# 二、pnpm是什么
pnpm(Performant NPM)是一个快速的、节省磁盘空间的包管理工具,同时它还对monorepo有良好的支持。
# 安装pnpm
npm i pnpm -g
它的用处与npm和yarn并没有什么本质区别,甚至连用法都十分相似,但却在性能和安全性方面有显著优势。
# 三、pnpm核心优势
# 3.1 安装速度快
根据官方文档的benchmark测试数据,pnpm在大多数场景下的安装速度都要优于npm和yarn。
可以看到,pnpm在绝多大数场景下,包安装的速度都是明显优于npm/yarn,速度会比npm/yarn快2-3倍。
对yarn比较熟悉的同学可能会说,yarn不是有PnP安装模式吗?直接去掉node_modules,将依赖包内容写在磁盘,节省了node文件I/O的开销,这样也能提升安装速度。
但总体而言,pnpm的包安装速度还是明显优于yarn npm。
这是因为pnpm采用了并行的网络请求和本地缓存策略,减少了等待时间。
# 3.2 节省磁盘空间
pnpm内部使用**基于内容寻址存储(CAS - Content-addressable store)**的方式来存储依赖。
硬链接与软链接机制
- 硬链接(hard link):多个文件平等地共享同一个文件存储单元
- 符号链接/软链接(Symbolic link):一种特殊的文件,包含有一条指向其它文件或者目录的引用
软链接其实很好理解,它就相当于Windows系统中的快捷方式。一个符号链接文件仅包含有一个文本字符串,其被操作系统解释为一条指向另一个文件或者目录的路径。它是一个独立文件,其存在并不依赖于目标文件。
硬链接可以理解成给同一个文件创建了多个入口,它们都指向磁盘上的同一个存储位置。删除任何一个硬链接不影响其他链接,只有当所有硬链接都被删除时,文件才会被真正删除。
pnpm的磁盘空间优化策略包括:
-
不会重复安装同一个包:用
npm/yarn的时候,如果100个项目都依赖lodash,那么lodash很可能就被安装了100次,磁盘中就有100个地方写入了这部分代码。但使用pnpm则只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用硬链接。 -
不同版本复用:即使一个包的不同版本,
pnpm也会极大程度地复用之前版本的代码。比如lodash有100个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入101个文件,而是保留原来100个文件的硬链接,仅仅写入那一个新增的文件。
# 3.3 安全性高
pnpm自创了一套依赖管理方式——虚拟存储目录,很好地解决了幽灵依赖和非法访问的问题。
在pnpm中,只有在package.json中显式声明的依赖才能被访问,未声明的依赖是无法引入的,这保证了项目的安全性。
# 3.4 支持Monorepo
pnpm对monorepo的支持体现在各个子命令的功能上:
# 在根目录下执行,所有package都会被添加依赖
pnpm add A -r
# 使用filter指定package
pnpm add lodash --filter package-a
# 四、pnpm依赖管理原理
# 4.1 npm/yarn的安装原理
当执行npm/yarn install命令之后,首先会构建依赖树,然后针对每个节点下的包会经历以下四个步骤:
- 将依赖包的版本区间解析为某个具体的版本号
- 下载对应版本依赖的tar包到本地离线镜像
- 将依赖从离线镜像解压到本地缓存
- 将依赖从缓存拷贝到当前目录的node_modules目录
# 4.2 node_modules目录结构
使用pnpm安装express后,目录结构如下:
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
可以明显感觉到pnpm的目录结构更加合理:项目只依赖了express,那么node_modules中就只存在express的文件。
展开.pnpm文件夹,我们可以找到其真正的位置:express的真正位置位于.pnpm/express@4.17.1/node_modules/express。
这也代表了pnpm中的依赖规律,也是<package-name>@version/node_modules/<package-name>这种目录结构。
# 4.3 软链接与硬链接的协作
pnpm的依赖规律是<package-name>@version/node_modules/<package-name>这种目录结构:
- 所有的依赖都是从全局
store硬链接到了node_modules/.pnpm下 - 项目中的直接依赖通过软链接指向
.pnpm中的实际位置 - 依赖包中的每个文件再硬链接到
.pnpm store
.pnpm
├─ accepts@1.3.7
│ └─ node_modules
│ └─ accepts → 硬链接到store
├─ express@4.17.1
│ └─ node_modules
│ ├─ accepts → ../accepts@1.3.7/node_modules/accepts (软链接)
│ └─ express
│ └─ index.js → 硬链接到store
这种设计将包本身和依赖放在同一个node_module下面,与原生Node完全兼容,又能将package与相关的依赖很好地组织到一起,设计十分精妙。
# 五、pnpm工作空间(Workspace)完全指南
# 5.1 什么是Monorepo
Monorepo(单体仓库)是一种将多个项目存放在同一个代码仓库中的开发策略。相比传统的多仓库方案,Monorepo可以让代码共享更加方便版本管理更加统一。
pnpm从设计上就原生支持Monorepo,通过workspace功能可以轻松管理多个包。
# 5.2 创建Workspace项目
首先在项目根目录创建pnpm-workspace.yaml文件:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
然后创建项目结构:
my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── packages/
│ ├── pkg1/
│ │ ├── package.json
│ │ └── src/index.js
│ └── pkg2/
│ ├── package.json
│ └── src/index.js
└── apps/
└── web/
├── package.json
└── src/index.js
# 5.3 Workspace常用命令
安装所有依赖
# 安装workspace下所有packages的依赖
pnpm install
# 或使用-r递归安装
pnpm install -r
在所有package中安装依赖
# 在根目录执行,所有package都会被添加lodash依赖
pnpm add lodash -r
# 指定添加到某个package
pnpm add lodash --filter pkg1
使用filter过滤
# 只在pkg1中安装
pnpm add lodash --filter pkg1
# 只在以@apps开头的package中安装
pnpm add lodash --filter '@apps/*'
在特定package中执行命令
# 在pkg1中执行构建命令
pnpm --filter pkg1 run build
# 简写形式
pnpm -F pkg1 run build
# 5.4 Workspace内部依赖
在workspace中,不同package之间可以相互引用:
# 将pkg2添加到pkg1的依赖中
pnpm add pkg2 --filter pkg1