通过解析yarn install工作流程,分析 lockfile、缓存、请求Registry、扁平化依赖树、install scripts 各个部分之间的协作关系。或许能帮助大家理解一些 yarn 的常见操作,在实际解决问题的过程中产生一些启发。

如果对包管理、lockfile 的概念有些疑问,可以先浏览《前端包管理介绍》这篇短文。

概览

在进入主流程之前,yarn 会先检查当前项目目录下是否存在 npm-shrinkwrap.json 和 package-lock.json 文件。如果存在 npm-shrinkwrap.json,yarn 将会忽视它的存在并输出提示信息。如果存在 package-lock.json,yarn 将会建议用户不要混合使用包管理器,避免版本解析不一致。

在进行 yarn install 的过程中,我们可以在终端看到由 emoji 图标装饰的5个步骤,如下图所示:

yarn 命令安装 yarn add install_yarn 命令安装

这5个步骤对应着 yarn install 执行的五个阶段

  1. Validating package.json(检查 package.json):检查运行环境
  2. Resolving packages(解析包):整合依赖信息
  3. Fetching packages(获取包):获取依赖包到缓存中
  4. Linking dependencies(连接依赖):复制依赖到 node_modules
  5. Building fresh packages(构建安装):执行 install 阶段的 scripts

检查 package.json:检查运行环境

package.json 中的 os、cpu、engines 字段用于规定模块所需的运行条件。在这一阶段检查当前环境是否满足这些运行条件。

解析包:整合依赖信息

这一阶段通过递归处理,整合所有依赖的具体信息。过程总结为两部分:

收集首层依赖:将 package.json 中的 dependenciesdevDependenciesoptionalDependencies 依赖列表和 workspaces 中的顶级 packages 列表以 「包名@版本范围」 的格式整合为首层依赖集合,可以具象为一个字符串数组;

遍历所有依赖,收集依赖具体信息:概括的说,从首层依赖集合出发,结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息。处理单个依赖依照 yarn.lock 优先原则:

  1. 在 yarn.lock 中寻找能完全匹配的包记录,定位到包记录,即可确定版本号,根据包记录组装成特定数据结构并加入到依赖信息Map;
  2. 如果无法定位到包记录,向配置的 Registry 请求包信息,通过响应数据中的 versions 字段获取符合版本范围的最高版本信息,以最高版本作为确定版本,根据包信息组装成特定数据结构并加入到依赖信息Map;
  3. 如果发现依赖所需的确定版本和已存在于集合中依赖重合,引用同一数据结构加入依赖信息Map,不进行下一步子依赖处理。这一步是为重叠版本区间做准备
  4. 完成当前依赖的数据处理后,对其子依赖按层递归处理。

yarn 命令安装 yarn add install_缓存_02

简单总结,这一阶段的策略是:yarn.lock 能命中版本时,以 yarn.lock 为准;否则,向 Registry 请求更新信息。

指路👉:这一部分的主要代码位置

获取包:获取依赖包到缓存中

在进入这一阶段之前,yarn 会比较当前项目 node_modules 下的模块是否已满足依赖信息集合的要求。如果已满足将跳出,并输出 “success Already up-to-date.” 表示yarn install 已经完成。

简单模拟上一步得到的依赖信息集合,这里使用 patternX 指代一个数据对象:

{
  'trim-newlines@^1.0.0': pattern1,
  'trim-newlines@^2.0.0': pattern2,
  'trim-newlines@^3.0.0': pattern3,
  'typescript@~4.0.3': pattern4
  'typescript@~4.0.5': pattern4
}
复制代码

可以发现一个特征,确定版本号相同的依赖引用同一个对象。利用该特征配合 Set 筛去重复依赖,实现归并重叠版本。

在这一阶段的目的是将需要的所有依赖包收集到 yarn 的缓存目录中。

yarn 采取的策略是先确定缓存中是否存在,不存在的情况下再尝试以读取文件系统,从 Registry 下载的方式获取。

yarn 命令安装 yarn add install_缓存_03

缓存

yarn 的缓存目录可通过yarn cache dir查询,缓存条目以{slug}/node_modules/{packageName}命名的目录形式保存。值得一提的是,slug 由版本、哈希值、uid构成,因此 yarn 以同级平铺的形式存放缓存条目

以 typescript 包为例简单分析缓存条目的内容,包括下载到的依赖压缩包(.yarn-tarball.tgz)、Metadata文件(.yarn-metadata.json)、压缩包解压文件。如果 package.json 中设置了bin字段,在 .bin 目录下拷贝相关文件。

yarn 命令安装 yarn add install_yarn 命令安装_04

本地文件系统

对于相对路径和file://协议路径,yarn 只尝试从本地读取。

网络请求

对 URL 的处理主要在解析包阶段完成,来自 yarn.lock 的数据从resolved 字段中提取 URL,来自 Registry 的数据将dist.tarball作为 URL。在网络请求阶段仅是规范化非 https 协议。以 yarn.lock 中指定的下载路径(resolved字段)优先,确实有助于尽可能安装一致的依赖环境,避免不同 Registry 可能存在未知情况对依赖包的影响。

另外,Yarn 对网络请求也做了一些优化:

  1. 首先是以 urlToPromise 的形式做了 cache,避免向同一 URL 发送请求造成不必要的开销。
  2. 支持最大并行发送 8 个请求(可通过--network-concurrency 自定义上限),实现思路是维护一个请求队列给待发请求排队,使用 running 变量记录正在处理中的请求数作为锁。新请求进入队列或一个请求处理完毕时,出队处理下一个待发请求,从而实现自动循环。
  3. 自动重发策略(请求失败尝试重发,单请求重发上限为5次),实现思路为维护一个离线队列存放失败未重发请求,每 3s 出队一个进行重发。当超过重发上限,输出“info There appears to be trouble with your network connection. Retrying...”提示,表示放弃该请求。

指路👉:这一部分的核心代码入口

连接依赖:复制依赖到 node_modules

经过上一步骤,项目所需的依赖都在 yarn 缓存目录中准备就绪。这一阶段进行的主要工作是将依赖从缓存复制到项目的 node_modules 下。这一阶段实际上做了三件事:

  1. 处理 peerDependencies
  2. 扁平化依赖树
  3. 拷贝依赖到 node_modules

peerDependencies

peerDependencies一般用在要分发的包中,当一个包必须以另一个包作为地基,适合使用 peerDependencies 指定两者的关系。比如 react 需要依托 react-dom、koa 插件需要依托 koa。

yarn 的处理策略是当发现 peerDependencies 不在已处理的依赖包列表中,中断操作提示用户手动安装。

扁平化依赖树

依赖树的构建决定 node_modules 下的文件结构,构建策略也有着逐步优化的历程

  • npm v2 版本使用深嵌套树,按照实际依赖关系维护依赖树。这样做容易造成嵌套地狱,共同依赖会在多个模块下存在副本。
  • npm v3 版本借鉴 yarn 的扁平化策略,即按照包的安装顺序进行处理,首次出现的包 A v1.0 存放于顶层,之后再次出现对 A v1.0 的依赖不再处理(得益于模块查询系统沿依赖树向上检索的特性)。之后出现对 A 包其他版本的依赖则存放于依赖模块的子依赖树中。这样做的问题是,包的安装顺序决定最终结构,并且未根据依赖包的版本使用频率科学决定依赖树结构。
  • yarn 则是在扁平化依赖树阶段分析同一依赖包不同版本的使用频率,选择利用率最大的版本放置在顶层。这一过程称为 dedupe。在 npm 中需要通过 npm dedupe手动进行这一操作。

拷贝依赖到 node_modules

在这一步对 软连接 和 缓存目录 区分处理。最终的效果是按依赖树结构将可执行模块放置到 node_modules 中。

构建安装:执行 install 阶段的脚本

在这一阶段执行 install 相关的生命周期钩子,包括 preinstall、 install、postinstall。yarn buildyarn rebuild都是作用在这个阶段。

从缓存复制依赖的策略,几乎可以实现零构建零安装构建完整的依赖环境。但是还是存在一些包需要根据目前的宿主环境动态生成模块,为了满足这一需求,包管理器便提供了 install 系列钩子来支持。

举一个耳熟能详的实例 —— node-sass。

Sass 引擎最初使用 Ruby 实现需要 Ruby 作为运行环境支持,后来为了满足跨语言使用 Sass 团队提供 LibSass —— 给引擎加上一个 C/C++ 接口。

在 WebAssembly 还没有兴起的时代,Node 上执行原生代码是通过 node-gyp 进行本地构建实现的,通过 node-gyp 将 binding.node 格式的二进制文件构建为可被 NodeJS 执行的代码。

node-sass 要能正常使用,需要依赖 node-gyp,并且需要从 sass 二进制资源站点额外下载二进制文件。

node-sass 的 package.json 中有两条 install 相关的脚本

{
  "scripts": {
    "install": "node scripts/install.js",
    "postinstall": "node scripts/build.js",
  }
}
复制代码

install 阶段下载二进制文件,postinstall 阶段通过 node-gyp 构建。

这样的构建安装过程产生不少痛点:

  1. 二进制文件下载过慢,这和访问外网服务器有关,也就产生了修改sass_binary_site配置变量的常规操作
  2. node-gyp 同样存在通过 install scripts 自行下载 Node 源码的行为,又产生一个需配置变量disturl

自定义脚本,增加了不在包管理器控制范围内的变因,最终造成开发者在使用过程中的额外处理。

随着 Dart、WebAssembly 的成熟,跨语言代码有了新的可能性。Yarn 官方也建议开发者使用 WebAssembly 代替 install scripts。Sass 也推出 Dart Sass 支持在纯 JavaScript 使用 Sass, node-sass 不再跟随 Node 版本更新,sass 将成为未来的解决方案。

结束 5 个步骤之后,更新 yarn.lock。

总结

yarn 命令安装 yarn add install_json_05

yarn 命令安装 yarn add install_yarn 命令安装

有趣的 emoji 图标、简单清晰的提示,背后是 yarn 的表达。现在你明白了吗?