包管理器
yarn
PnP
注意
以下内容在基于 文章 的基础上继续扩展
背景
Yarn
团队开发 PnP
特性最直接的原因就是现有的 依赖管理方式效率太低
。引用依赖时慢,安装依赖时也慢。
先说说 Node
在处理依赖引用时的逻辑,这个流程会有如下两种情况:
如果我们传给 require()
调用的参数是一个核心模块(例如 “fs”、”path”等
或者是一个本地相对路径(例如 ./module-a.js 或 /my-li/module-b.js)
,那么 Node
会直接使用对应的文件。如果不是前面描述的情况,那么 Node
会开始寻找一个名为 node_modules
的目录:
实际上就是在循环的过程中,首先 Node
会在 当前目录 寻找 node_modules
,如果 没有 则到 父目录 查找,以此类推直到 系统根目录,如果存在 node_modules
目录则判断目录中是否存在要加载的模块,若没有则继续往 父目录 检索,如果找到了要加载的模块,那么再判断这个模块对应的 packages.json
是否指定了 main
属性,如果有指定了 main
属性,那么就加载 main
属性指向的文件,否则默认指向 index.js
,如果没有 index.js
文件,那么就查找 index.json
,还没有就找 index.node
,如果都找不到,那么将会报错。
require
检索模块流程图如下:
具体 require
的执行流程可以参考 文章。其执行链路可以分为以下几个阶段:
require
=> Module._load
=> Module.prototype._load
=> Module._extensions
=> Module._compile
=> return module.exports
。
可见 Node
在 解析依赖 时需要进行大量的处理,效率并不高。
再来看看 安装依赖 时发生了什么,现阶段 yarn install
操作会执行以下 4 个步骤:
- 将依赖包的版本区间解析为某个 具体的版本号
- 下载对应版本依赖的
tar
包到 本地离线镜像 - 将依赖从 离线镜像 解压到 本地缓存
- 将依赖从 本地缓存
拷贝
到当前目录的node_modules
目录
其中第 4
步同样涉及大量的文件 I/O
,导致安装依赖时效率不高(尤其是在 CI
环境,每次都需要安装全部依赖)。
Facebook
的工程师受够了这些问题并决定寻找一个能彻底解决问题同时还可以与现有生态兼容的解决方案。这便是 Plug’n’Play
特性,简称 PnP
。它已在 Facebook
内部测试了一段时间,现在 Yarn
团队决定与社区分享并共同优化该方案。Yarn
团队开发 PnP
特性最直接的原因就是 现有的依赖管理方式效率太低。引用依赖时慢,安装依赖时也慢。
实现方式
针对原先把依赖从 本地缓存 拷贝到 node_modules
的方案,Yarn
会维护一张 静态映射表,该表中包含了以下信息:
- 当前依赖树中包含了哪些依赖包的哪些版本
- 这些依赖包是如何互相关联的
- 这些依赖包在文件系统中的具体位置
这个映射表在 Yarn
的 PnP
实现中对应项目目录中的 .pnp.js
文件。
这个 .pnp.js
文件是如何生成,Yarn
又是如何利用它的呢?
在安装依赖时,在第 3
步完成之后,Yarn
并不会拷贝依赖到 node_modules
目录,而是会在 .pnp.js
中记录下该依赖在缓存中的具体位置。这样就避免了大量的 I/O
操作的同时项目目录也不会有 node_modules
目录生成。
同时 .pnp.js
还包含了一个特殊的 resolver
,Yarn
会利用这个特殊的 resolver
来处理 require()
请求(在 Module
层面做了一层拦截,改变了原先 node
的行为),该 resolver
会根据 .pnp.js
文件中包含的静态映射表直接确定依赖在文件系统中的具体位置,从而避免了现有实现在处理依赖引用时的 I/O
操作。
优点
从 PnP
的实现方案可以看出,同一个系统上不同项目引用的相同依赖的相同版本实际都是指向的 全局缓存 中的同一个目录。这带来了几个最直观的好处:
- 安装依赖的速度得到了空前的提升
CI
环境中多个CI
实例可以共享同一份缓存 - 同一个系统中的多个项目不再需要占用多份磁盘空间
缺陷
执行脚本受限制所有的依赖引用都必须由
.pnp.js
中的resolver
处理。因此不论是执行script
还是用node 直接执行一个 JS 文件
,都必须经由Yarn
处理。必须通过yarn run
或是yarn node
执行。调试不方便
PnP
的项目中不再有node_modules
目录,相比于直接使用node
执行脚本,PnP
在其中重写了Module
的实现,做了一层映射操作。在调试源码时也必须经过PnP
这一层操作,但其实对于研发来说PnP
的内部实现并不多关注。再其次由于依赖都指向了全局缓存,我们不再可以直接修改这些依赖。研发无法获取到原先 node_module 中的源码位置,这对于调试来说极其的不方便。要想调试还需要使用yarn
提供的yarn unplug packageName
来将某个指定依赖拷贝到项目中的.pnp/unplugged
目录下。之后.pnp.js
中的resolver
就会自动加载这个unplug
的版本。调试完毕后,再执行yarn unplug --clear packageName
可移除本地.pnp/unplugged
中的对应依赖。问题:
- 需要开发者在依赖包入口处(
.pnp/unplugged/npm-[module name]-[version]-[hash]-integrity/node_modules/[module name]/[entry path]
) 打断点才可以进行调试。 - 例如
A
依赖B
,B
依赖C
,B
为依赖模块,C
为B
的外部依赖模块。调试的时候需要先yarn unplug B
,在B
模块中若还需要调试C
模块则还需要yarn unplug C
,对于在A
模块中的依赖项也是一样,极大增加了调试成本。
- 需要开发者在依赖包入口处(
pnpm
拥有以下优秀的特性:
- 安装包速度快借助这一篇 文章 可以很清晰的得出在绝多大数场景下,
pnpm
包安装的速度都是明显优于npm/yarn
,速度会比npm/yarn
快2-3
倍,包括yarn
使用PnP
安装模式。 - 高效利用磁盘空间