前言
pnpm对比npm/yarn的优点:
- 更快速的依赖下载
- 更高效的利用磁盘空间
- 更优秀的依赖管理
我们按照包管理工具的发展历史,从 npm2 开始讲起:
npm2
使用早期的npm1/2安装依赖,node_modules文件会以递归的形式呈现,严格按照package.json结构以及次级依赖的package.json将依赖安装到各自的node_modules中,直至次级依赖不再依赖其他模块
举例:
- foo -> bar,foo依赖于bar
bash
node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json
- foo1 -> bar,foo2 -> bar,bar会被安装两次
bash
node_modules
├─ foo1
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ bar
│ ├─ index.js
│ └─ package.json
└─ foo2
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json
- 一些其他问题
● 依赖层级太深,会导致文件路径过长(windows 的文件路径最长是 260 多个字符,这样嵌套是会超过 windows 路径的长度限制的。)
● 这样的嵌套,重复的包被安装,导致node_modules文件体积巨大,占用过多的磁盘空间
当时 npm 还没解决,社区就出来新的解决方案了,就是 yarn:
npm3/yarn
yarn 是怎么解决依赖重复很多次,嵌套路径过长的问题的呢?
铺平。所有的依赖不再一层层嵌套了,而是全部在同一层,采用"扁平化"的方式去管理依赖,这样也就没有依赖重复多次的问题了,也就没有路径过长的问题了。
举例:
- foo1 -> bar,foo2 -> bar
bash
node_modules
├─ bar
│ ├─ index.js
│ └─ package.json
├─ foo1
│ ├─ index.js
│ └─ package.json
└─ foo2
├─ index.js
└─ package.json
- foo1 -> bar@1.0.0,foo2 -> bar@2.0.0
bash
node_modules
├─ bar@1.0.0
│ ├─ index.js
│ └─ package.json
├─ foo1
│ ├─ index.js
│ └─ package.json
└─ foo2
├─ index.js
└─ package.json
└─ node_modules
└─ bar@2.0.0
├─ index.js
└─ package.json
为什么还有嵌套呢?
因为一个包是可能有多个版本的,提升只能提升一个,所以后面再遇到相同包的不同版本,依然还是用嵌套的方式。
使用扁平化的方案,解决了npm2中出现的问题,但是也带来一些问题:
- 幽灵依赖
就是明明没有在dependencies中声明的依赖,但是却可以require进来。很容易理解,就是依赖都铺平了,那依赖的依赖也是可以找到的。
出现幽灵依赖是有隐患的,比如:因为没有显式依赖,万一有一天别的包不依赖这个包了,那你的代码也就不能跑了,因为你依赖这个包,但是现在不会被安装了。
- 浪费磁盘空间的问题
上面提到的依赖包有多个版本的时候,只会提升一个,那其余版本的包不还是复制了很多次么,依然有浪费磁盘空间的问题。
那社区有没有解决这俩问题的思路呢?
当然有,这不是 pnpm 就出来了嘛。
那 pnpm 是怎么解决这俩问题的呢?
pnpm
回想下 npm3 和 yarn 为什么要做 node_modules 扁平化?不就是因为同样的依赖会复制多次,并且路径过长在 windows 下有问题么?
那如果不复制呢,比如通过 link。
首先介绍下 link,也就是软硬连接,这是操作系统提供的机制。
- 硬连接就是同一个文件的不同引用,
- 而软链接是新建一个文件,文件内容指向另一个路径。
- 当然,这俩链接使用起来是差不多的。
如果不复制文件,只在全局仓库保存一份 npm 包的内容,其余的地方都 link 过去呢?
这样不会有复制多次的磁盘空间浪费,而且也不会有路径过长的问题。因为路径过长的限制本质上是不能有太深的目录层级,现在都是各个位置的目录的 link,并不是同一个目录,所以也不会有长度限制。
没错,pnpm 就是通过这种思路来实现的。
再把 node_modules 删掉,然后用 pnpm 重新装一遍,执行 pnpm install。
你会发现它打印了这样一句话:
包是从全局 store 硬连接到虚拟 store 的,这里的虚拟 store 就是 node_modules/.pnpm。
只要是在同一台机器下,下次安装依赖的时候pnpm会先检查store目录,如果有你需要安装的依赖则会通过一个硬链接到你的项目中去,而不是重新安装依赖。这也就表明为什么pnpm性能这么突出了,最大程度的节省了时间消耗和磁盘空间。
我们打开 node_modules 看一下:
确实不是扁平化的了,依赖了 express,那 node_modules 下就只有 express,没有幽灵依赖。
展开 .pnpm 看一下:
所有的依赖都在这里铺平了,都是从全局 store 硬连接过来的,然后包和包之间的依赖关系是通过软链接组织的。
比如 .pnpm 下的 expresss,这些都是软链接,
也就是说,所有的依赖都是从全局 store 硬连接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。
举例说明:
- 项目依赖foo@1.0.0
bash
node_modules
├─ .pnpm
│ └─ foo@1.0.0
│ └─ node_modules
│ └─ foo -> <store>/foo
│ ├─ index.js
│ └─ package.json
│
└─ foo
- 项目依赖了foo@1.0.0、bar@1.0.0,bar也依赖了foo@1.0.0
bash
node_modules
├─foo -> ./.pnpm/foo@1.0.0/node_modules/foo
├─bar -> ./.pnpm/bar@1.0.0/node_modules/bar
└─.pnpm
├─ bar@1.0.0
│ └─ node_modules
│ ├─ foo -> ../../foo@1.0.0/node_modules/foo
│ └─ bar -> <store>/bar
└─ foo@1.0.0
└─ node_modules
└─ foo -> <store>/foo
为什么需要同软链接的方式去引用实际的依赖呢?其实这样设计的目的是解决"幽灵依赖"问题,只有声明过的依赖才会以软链接的形式出现在node_modules目录中,在实际项目中引用的是软链接,软链接指向的是 .pnpm 的真实依赖,所以在日常开发中不会引用到未在 package.json 声明的包。
官方给了一张原理图,配合着看一下就明白了:
这就是 pnpm 的实现原理。
那么回过头来看一下,pnpm 为什么优秀呢?
首先,最大的优点是节省磁盘空间呀,一个包全局只保存一份,剩下的都是软硬连接,这得节省多少磁盘空间呀。
其次就是快,因为通过链接的方式而不是复制,自然会快。
这也是它所标榜的优点:
相比 npm2 的优点就是不会进行同样依赖的多次复制。
相比 yarn 和 npm3+ 呢,那就是没有幽灵依赖,也不会有没有被提升的依赖依然复制多份的问题。
这就已经足够优秀了,对 yarn 和 npm 可以说是降维打击。
Workspace
现代前端工程中居多都是使用 Lerna 管理 monorepo 类型的项目,每个人都清楚它的作用,而 pnpm 也是对此进行了友好的支持。与 Lerna 不同的是 pnpm 使用特殊的包选择器语法限制命令,不像 Lerna 那样需要很长难记的命令去标识。
一个 monorepo 工程,目录中必须要拥有管理工作区的配置文件(pnpm.workspace.yaml),相比其它包管理工具的工作区文件其实都大同小异。
bash
packages:
# 所有在 packages/ 和 components/ 子目录下的 package
- 'packages/**'
- 'components/**'
# 不包括在 test 文件夹下的 package
- '!**/test/**'
一些常用于管理 monorepo 的命令:
● 精确选择一个 repo <@scope/package>,或选择一组 repo <@scope/*>,再或者相对路径选择。
bash
pnpm dev --filter @byted-ehi/basic-list
pnpm dev --filter apps/*
pnpm dev --filter ./apps/admin-order-manage
● 选择一个 repo 以及所属 repo 的依赖项,例如:会运行 basic-list 下的所有依赖的 dev。这个命令的意思是在 @byted-ehi/basic-list 包的所有子目录以及这些子目录中的所有文件中执行开发脚本。
bash
pnpm dev --filter @byted-ehi/basic-list...
● 只选择某个 repo 的依赖项,与上面的区别是不包含 repo。例如:会运行 repo 下所有依赖的 dev,不包含repo 本身。这个命令的意思是在 @byted-ehi/basic-list 包的所有子目录中执行开发脚本。
bash
pnpm dev --filter @byted-ehi/basic-list^...
● 选择指定目录下的所有 repo。
bash
pnpm dev --filter ./apps
总结
pnpm 最近经常会听到,可以说是爆火。本文我们梳理了下它爆火的原因:
npm2 是通过嵌套的方式管理 node_modules 的,会有同样的依赖复制多次的问题。
npm3+ 和 yarn 是通过铺平的扁平化的方式来管理 node_modules,解决了嵌套方式的部分问题,但是引入了幽灵依赖的问题,并且同名的包只会提升一个版本的,其余的版本依然会复制多次。
pnpm 则是用了另一种方式,不再是复制了,而是都从全局 store 硬连接到 node_modules/.pnpm,然后之间通过软链接来组织依赖关系。
这样不但节省磁盘空间,也没有幽灵依赖问题,安装速度还快,从机制上来说完胜 npm 和 yarn。
pnpm 就是凭借这个对 npm 和 yarn 降维打击的。