通过解析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个步骤,如下图所示:
这5个步骤对应着 yarn install
执行的五个阶段
- Validating package.json(检查 package.json):检查运行环境
- Resolving packages(解析包):整合依赖信息
- Fetching packages(获取包):获取依赖包到缓存中
- Linking dependencies(连接依赖):复制依赖到 node_modules
- Building fresh packages(构建安装):执行 install 阶段的 scripts
检查 package.json:检查运行环境
package.json 中的 os、cpu、engines 字段用于规定模块所需的运行条件。在这一阶段检查当前环境是否满足这些运行条件。
解析包:整合依赖信息
这一阶段通过递归处理,整合所有依赖的具体信息。过程总结为两部分:
收集首层依赖:将 package.json 中的 dependencies、devDependencies、optionalDependencies 依赖列表和 workspaces 中的顶级 packages 列表以 「包名@版本范围」 的格式整合为首层依赖集合,可以具象为一个字符串数组;
遍历所有依赖,收集依赖具体信息:概括的说,从首层依赖集合出发,结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息。处理单个依赖依照 yarn.lock 优先原则:
- 在 yarn.lock 中寻找能完全匹配的包记录,定位到包记录,即可确定版本号,根据包记录组装成特定数据结构并加入到依赖信息Map;
- 如果无法定位到包记录,向配置的 Registry 请求包信息,通过响应数据中的
versions
字段获取符合版本范围的最高版本信息,以最高版本作为确定版本,根据包信息组装成特定数据结构并加入到依赖信息Map; - 如果发现依赖所需的确定版本和已存在于集合中依赖重合,引用同一数据结构加入依赖信息Map,不进行下一步子依赖处理。这一步是为重叠版本区间做准备
- 完成当前依赖的数据处理后,对其子依赖按层递归处理。
简单总结,这一阶段的策略是: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 cache dir
查询,缓存条目以{slug}/node_modules/{packageName}
命名的目录形式保存。值得一提的是,slug 由版本、哈希值、uid构成,因此 yarn 以同级平铺的形式存放缓存条目
以 typescript 包为例简单分析缓存条目的内容,包括下载到的依赖压缩包(.yarn-tarball.tgz)、Metadata文件(.yarn-metadata.json)、压缩包解压文件。如果 package.json 中设置了bin
字段,在 .bin 目录下拷贝相关文件。
本地文件系统
对于相对路径和file://
协议路径,yarn 只尝试从本地读取。
网络请求
对 URL 的处理主要在解析包阶段完成,来自 yarn.lock 的数据从resolved
字段中提取 URL,来自 Registry 的数据将dist.tarball
作为 URL。在网络请求阶段仅是规范化非 https 协议。以 yarn.lock 中指定的下载路径(resolved
字段)优先,确实有助于尽可能安装一致的依赖环境,避免不同 Registry 可能存在未知情况对依赖包的影响。
另外,Yarn 对网络请求也做了一些优化:
- 首先是以 urlToPromise 的形式做了 cache,避免向同一 URL 发送请求造成不必要的开销。
- 支持最大并行发送 8 个请求(可通过
--network-concurrency
自定义上限),实现思路是维护一个请求队列给待发请求排队,使用running
变量记录正在处理中的请求数作为锁。新请求进入队列或一个请求处理完毕时,出队处理下一个待发请求,从而实现自动循环。 - 自动重发策略(请求失败尝试重发,单请求重发上限为5次),实现思路为维护一个离线队列存放失败未重发请求,每 3s 出队一个进行重发。当超过重发上限,输出“info There appears to be trouble with your network connection. Retrying...”提示,表示放弃该请求。
指路👉:这一部分的核心代码入口
连接依赖:复制依赖到 node_modules
经过上一步骤,项目所需的依赖都在 yarn 缓存目录中准备就绪。这一阶段进行的主要工作是将依赖从缓存复制到项目的 node_modules 下。这一阶段实际上做了三件事:
- 处理 peerDependencies
- 扁平化依赖树
- 拷贝依赖到 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 build
和 yarn 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 构建。
这样的构建安装过程产生不少痛点:
- 二进制文件下载过慢,这和访问外网服务器有关,也就产生了修改
sass_binary_site
配置变量的常规操作 -
node-gyp
同样存在通过 install scripts 自行下载 Node 源码的行为,又产生一个需配置变量disturl
自定义脚本,增加了不在包管理器控制范围内的变因,最终造成开发者在使用过程中的额外处理。
随着 Dart、WebAssembly 的成熟,跨语言代码有了新的可能性。Yarn 官方也建议开发者使用 WebAssembly 代替 install scripts。Sass 也推出 Dart Sass 支持在纯 JavaScript 使用 Sass, node-sass 不再跟随 Node 版本更新,sass 将成为未来的解决方案。
结束 5 个步骤之后,更新 yarn.lock。
总结
有趣的 emoji 图标、简单清晰的提示,背后是 yarn 的表达。现在你明白了吗?