在现代前端开发过程中,包管理器是必不可少的一环,它能帮助我们管理node_modules,安装对应依赖,提高了前端开发的效率,同时也让大型的前端工程成为可能。说到包管理工具,我相信大家应该都不陌生,除了与 node.js 自带的 npm,还有 yarn也是大家平时开发时经常用到的工具,相对来说,本文今天要介绍的主角pnpm就显得较为小众。那么,在npm和yarn已经如此成熟的情况下,pnpm 这种较新并且用户量较小的包管理器的意义何在呢?顺着本文让我们来一起来寻找问题的答案吧
Why Pnpm ?
我相信这是大家看到 pnpm 后的第一反应,在 npm 和 yarn如此成熟的今天,切换工具链势必会带来迁移成本,pnpm 作为一个后起之秀,必然需要有相较于其它同类工具非常显著的优势,才能让大家有更换它的动力,而当我们打开pnpm的官网,我们会发现作者非常直接的把pnpm的优势在官网上列了出来
可以看到 pnpm 相对于其它包管理工具有以下几个显著的优势
- 超快的安装速度
- 更高效的利用磁盘空间
- 内置的 monorepo
- 避免 phantom dependencies (在代码中可以引用间接安装的包,eg. 项目里安装了A依赖,A依赖了B,项目里可以直接引用B)
如果说更高效的磁盘空间利用率和内置的monorepo不是大多数人在意的点,那么超快的安装速度以及对phantom dependencies的天然隔离则是我们开发过程中每天都会碰到的问题,接下来让我们探索一下pnpm的这些特性是怎么实现的以及他们对前端开发来说存在的意义
超快的安装速度
我相信几乎每个人都碰到过在大型前端项目中(尤其是monorepo)yarn 或者 npm 安装依赖太过耗时的问题,而根据 pnpm 官方给出的数据图表,在大多数场景下,pnpm的安装速度都领先于 yarn 和 npm
-
怎么做到的 ?
- 要回答这个问题,让我们先看看官方doc中提到的pnpm的开发动机
When using npm or Yarn, if you have 100 projects using a dependency, you will have 100 copies of that dependency saved on disk. With pnpm, the dependency will be stored in a content-addressable store, so:
If you depend on different versions of the dependency, only the files that differ are added to the store. For instance, if it has 100 files, and a new version has a change in only one of those files, pnpm update
will only add 1 new file to the store, instead of cloning the entire dependency just for the singular change.
All the files are saved in a single place on the disk. When packages are installed, their files are hard-linked from that single place, consuming no additional disk space. This allows you to share dependencies of the same version across projects.
As a result, you save a lot of space on your disk proportional to the number of projects and dependencies, and you have a lot faster installations!
可以看到,pnpm 采用了和 npm 还有 yarn完全不同的思路,对于 npm 还有 yarn 来说,不同项目的node_modules是没法复用的,也就是说如果我有两个项目,哪怕这两个项目安装的很多包都是同一个版本,也还是需要安装多份,这无疑占用了更多的磁盘空间,同时也会带来更长的下载时间,而pnpm对于这个问题采取的解决方案是在磁盘的一块连续地址上存储所有下载的npm包,并且通过硬链接的方式将npm包链接到各个项目的node_modules中,这样一来,所有的项目都可以共享同一npm包,不再需要额外的下载包的时间,同时,pnpm 还对npm包进行了diff,只会将包中变更的文件加到本地存储中,举个例子,两个项目同时引用了一个包的A和B版本,A和B版本有99个文件是相同的,只有一个文件是不同的,那么pnpm会复用这99个文件,通过硬链接的方式把新版包中不变的文件指向原来已经存储过的文件,以此避免存储重复的文件。通过这种方式,pnpm能在不同项目中共享npm包,在节省磁盘空间的同时也提高了包的安装速度
硬链接(Hard Link):硬链接是文件系统中对文件物理物理索引的引用,当移动或删除原始文件时,硬链接不会被破坏,因为它所引用的是文件的物理数据而不是文件在文件结构中的位置,如果删除的文件有相应的硬链接,那么这个文件依然会保留,直到所有对它的引用都被删除,对硬链接的操作其实本质上就是对已有文件进行操作
避免 phantom dependencies
- 什么是 phantom dependcies?
首先我们先来了解下什么是phantom dependcies,举个例子,我们想在我们的项目里引用 A 包,同时 B 又依赖了 A 包,当我们使用npm或者yarn安装完A之后,node_modules的结构会是啥呢?
node_modules
- A
-index.js
-package.json
- B
-index.js
-package.json
相信大家对这个结构也不陌生,这也就是我们经常遇到的我只是在package.json里添加了几个包,装完之后node_modules多了一堆包,因为npm / yarn的安装会把包里面依赖的包也给安装到node_modules,也就是安装之后node_modules其实是一个拍平的结构,避免出现依赖嵌套的结构,这样带来了不少好处,比如node_modules层级不会太深,同时因为node_modules的包查找机制,在某个包内的node_modules里没有找到依赖包,会从上一级的node_modules中查找,如果有相同版本的包则不会重复安装,这样就解决了大量重复的包的安装问题,但同时flat的结构带来了一个非常明显的安全性问题,那就是可以在项目内非法访问依赖的包。可以看到,在这个例子里,其实我们只添加了A的依赖,但是引用B这个包也能正常使用,不会报错,一旦某天A的新版本升级了依赖B,有了break change,或者不再使用B这个包,那我们的项目就会直接报错了
- Pnpm 怎么解决phantom dependcies的问题
与 npm / yarn 不同,pnpm重新设计了一套node_modules的结构,以此从根本上解决了phantom dependcies的问题,让我们来看下pnpm安装后node_modules的结构
首先,我们运行一下pnpm add react
,然后看下node_modules下的结构
node_modules
│ ├── .pnpm
│ └── react -> .pnpm/react@17.0.2/node_modules/react
可以看到node_modules下非常的整洁,只有一个react的包,我们都知道react引用了非常多的包,而这里node_modules下的包和我们在package.json下声明的依赖一模一样,不会有任何依赖被安装到node_modules下,这样就没法在项目里非法访问依赖,保证了安全性,这里你肯定会好奇,那pnpm把这些依赖装到哪去了呢?别急,我们接着往下看
仔细观察一下,会发现这里react其实是一个软链接,链接到了.pnpm文件夹下的react,让我们打开.pnpm文件夹看看
软链接(符号链接):一个指向文件的快捷方式,在访问软链接时,系统会把路径替换问软链接指向的文件
硬链接和软链接的区别:
- 硬链接是同一个文件的不同名称,可以理解为从不同的角度看同一个事物,所有对硬链接的操作都是对文件本身的操作,删除一个硬链接就像限制你的视角,文件本身并不会消失,当所有的硬链接都被删除,则文件本身也被删除了
- 软链接只是一个快捷方式,删除所有的软链接也不会对文件有任何影响,对软链接的所有操作会被文件系统替换为对软链接指向的文件的操作,如果原始文件的位置被移动了,那么软链接就找不到对应的文件了,改动自然也无法同步到文件上,与硬链接不同,因为软链接只是个快捷方式,所以软链接甚至可以指向不存在的文件
总结来说
硬链接就是文件本身,一个不同的名字(引用)
软链接本质上是另外一个文件,只不过在访问时文件系统会替换软链接为指向文件
.pnpm
│ │ ├── js-tokens@4.0.0
│ │ │ └── node_modules
│ │ │ └── js-tokens
│ │ │ ├── CHANGELOG.md
│ │ │ ├── LICENSE
│ │ │ ├── README.md
│ │ │ ├── index.js
│ │ │ └── package.json
│ │ ├── lock.yaml
│ │ ├── loose-envify@1.4.0
│ │ │ └── node_modules
│ │ │ ├── js-tokens -> ../../js-tokens@4.0.0/node_modules/js-tokens
│ │ │ └── loose-envify
│ │ │ ├── LICENSE
│ │ │ ├── README.md
│ │ │ ├── cli.js
│ │ │ ├── custom.js
│ │ │ ├── index.js
│ │ │ ├── loose-envify.js
│ │ │ ├── node_modules
│ │ │ │ └── .bin
│ │ │ │ └── loose-envify
│ │ │ ├── package.json
│ │ │ └── replace.js
│ │ ├── node_modules
│ │ │ ├── .bin
│ │ │ │ └── loose-envify
│ │ │ ├── js-tokens -> ../js-tokens@4.0.0/node_modules/js-tokens
│ │ │ ├── loose-envify -> ../loose-envify@1.4.0/node_modules/loose-envify
│ │ │ └── object-assign -> ../object-assign@4.1.1/node_modules/object-assign
│ │ ├── object-assign@4.1.1
│ │ │ └── node_modules
│ │ │ └── object-assign
│ │ │ ├── index.js
│ │ │ ├── license
│ │ │ ├── package.json
│ │ │ └── readme.md
│ │ └── react@17.0.2
│ │ └── node_modules
│ │ ├── loose-envify -> ../../loose-envify@1.4.0/node_modules/loose-envify
│ │ ├── object-assign -> ../../object-assign@4.1.1/node_modules/object-assign
│ │ └── react
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── build-info.json
│ │ ├── cjs
│ │ │ ├── react-jsx-dev-runtime.development.js
│ │ │ ├── react-jsx-dev-runtime.production.min.js
│ │ │ ├── react-jsx-dev-runtime.profiling.min.js
│ │ │ ├── react-jsx-runtime.development.js
│ │ │ ├── react-jsx-runtime.production.min.js
│ │ │ ├── react-jsx-runtime.profiling.min.js
│ │ │ ├── react.development.js
│ │ │ └── react.production.min.js
│ │ ├── index.js
│ │ ├── jsx-dev-runtime.js
│ │ ├── jsx-runtime.js
│ │ ├── node_modules
│ │ │ └── .bin
│ │ │ └── loose-envify
│ │ ├── package.json
│ │ └── umd
│ │ ├── react.development.js
│ │ ├── react.production.min.js
│ │ └── react.profiling.min.js
│ └── react -> .pnpm/react@17.0.2/node_modules/react
点开根目录下react软链接到.pnpm文件夹下的react@17.0.2,会发现react本身以及react所有的依赖项都被安装到了react的node_modules下,而react的依赖项又是软链接到最外层安装的包下,这时候我们再点开pnpm-lock文件就会发现,.pnpm目录下文件的结构和pnpm-lock下的依赖顺序和结构完全一致,这样一来包之间的依赖就十分清晰,同时还避免了非法引用依赖的问题
自带 monorepo 支持
随着前端项目规模越来越大,相互之间的依赖原来越多,越来越多的大型前端项目采用monorepo的方式组织项目(即把多个项目放在一个git仓库下管理,共享的代码可以弄成包在不同项目中引用),目前比较常用的是方式是使用lerna + yarn的组合,但lerna + yarn的组合带来的一个问题就是上手成本较高,相比之下,pnpm自带workspace,使用者很快就能享受到monorepo带来的便利之处,要使用pnpm的workspace功能,只需要一步
在根目录下新建一个pnpm-workspace.yaml文件,标识这是一个workspace,并填入workspace路径
packages:
# 所有在 packages/子目录下的 package
- 'packages/**'
# 不包括在 test 文件夹下的 package
- '!**/test/**'
是的,你没有看错,这样我们就配置好了,接下来让我们在根目录下建几个文件夹,感受一个monorepo带来的便利
├── packages
│ ├── a
│ ├── b
在a和b的package.json中,我们分别把name设置成@test/a和@test/b
让我们给每个packages都装上react试试
pnpm install react react-dom -w
可以看到,react和react-dom被安装在了workspace根目录下,所有packages都可以共享这个依赖,除此之外,pnpm也支持针对某一特定的package安装依赖,可以使用以下命令只在a这个package下安装react
pnpm i react -r --filter @test/a
接下来让我们来看看pnpm是怎么处理本地包之间的依赖的,我们在packages/a这个文件夹下建立一个index.js文件,并且导出一个函数,之后我们运行一下以下命令
pnpm i @test/a -r --filter @test/b
运行完成之后,可以看到packages/b下多了一个node_modules,而这里已经有了本地的@test/a包,这里pnpm就已经帮我们把包链接起来了,我们可以在不同的包之间共享本地代码,就像从npm上安装的包一样,同时,当我们访问b的package.json的时候就可以看到@test/a包指向workspace。当然,如果这里你想直接把packages下的包安装到顶层让所有packages都能访问到,使用 pnpm intsall
@test/a -w
即可,怎么样,是不是很简单呢?
总结
本文粗略的对pnpm的核心思想还有优势做了简单的介绍,当然在软件开发中,是没有银弹存在的,尽管有着以上的各种优势,pnpm相比于npm/yarn也有着一个最大的劣势,那就是迁移成本,对于存量大型项目来说,迁移pnpm无法保证和npm/yarn的行为完全一致,这其中可能还要踩不少坑,对于新项目而言,pnpm无疑是值得一试的选择,能大幅提升开发体验.