Skip to content

包管理器

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 会维护一张 静态映射表,该表中包含了以下信息:

  1. 当前依赖树中包含了哪些依赖包的哪些版本
  2. 这些依赖包是如何互相关联的
  3. 这些依赖包在文件系统中的具体位置

这个映射表在 YarnPnP 实现中对应项目目录中的 .pnp.js 文件。

这个 .pnp.js 文件是如何生成,Yarn 又是如何利用它的呢?

在安装依赖时,在第 3 步完成之后,Yarn 并不会拷贝依赖到 node_modules 目录,而是会在 .pnp.js 中记录下该依赖在缓存中的具体位置。这样就避免了大量的 I/O 操作的同时项目目录也不会有 node_modules 目录生成。

同时 .pnp.js 还包含了一个特殊的 resolverYarn 会利用这个特殊的 resolver 来处理 require() 请求(在 Module 层面做了一层拦截,改变了原先 node 的行为),该 resolver 会根据 .pnp.js 文件中包含的静态映射表直接确定依赖在文件系统中的具体位置,从而避免了现有实现在处理依赖引用时的 I/O 操作。

优点

PnP 的实现方案可以看出,同一个系统上不同项目引用的相同依赖的相同版本实际都是指向的 全局缓存 中的同一个目录。这带来了几个最直观的好处:

  1. 安装依赖的速度得到了空前的提升 CI 环境中多个 CI 实例可以共享同一份缓存
  2. 同一个系统中的多个项目不再需要占用多份磁盘空间

缺陷

  1. 执行脚本受限制所有的依赖引用都必须由 .pnp.js 中的 resolver 处理。因此不论是执行 script 还是用 node 直接执行一个 JS 文件,都必须经由 Yarn 处理。必须通过 yarn run 或是 yarn node 执行。

  2. 调试不方便 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 中的对应依赖。

    问题:

    1. 需要开发者在依赖包入口处(.pnp/unplugged/npm-[module name]-[version]-[hash]-integrity/node_modules/[module name]/[entry path]) 打断点才可以进行调试。
    2. 例如 A 依赖 BB 依赖 CB 为依赖模块,CB 的外部依赖模块。调试的时候需要先 yarn unplug B,在 B 模块中若还需要调试 C 模块则还需要 yarn unplug C,对于在 A 模块中的依赖项也是一样,极大增加了调试成本。

pnpm

拥有以下优秀的特性:

  1. 安装包速度快借助这一篇 文章 可以很清晰的得出在绝多大数场景下,pnpm 包安装的速度都是明显优于 npm/yarn,速度会比 npm/yarn2-3 倍,包括 yarn 使用 PnP 安装模式
  2. 高效利用磁盘空间

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)