包管理器
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
拥有以下优秀的特性: