Hello,大家好,我是松宝写代码,写宝写的不止是代码。接下来给大家带来的是关于Webpack4的性能优化的系列,今天带来的是编译阶段的性能优化。
由于优化都是在 Webpack 4 上做的,当时 Webpack 5 还未稳定,现在使用 Webpack 5 时可能有些优化方案不再需要或方案不一致,这里主要介绍优化思路,仅作为参考。
背景
在接触一些大型项目构建速度慢的很离谱,有些项目在 编译构建上30分钟超时,有些构建到一半内存溢出。但当时一些通用的 Webpack 构建优化方案要么已经接入,要么场景不适用:
- 已接入的方案效果有限。比如 cache-loader、thread-loader,能优化编译阶段的速度,但对于依赖解析、代码压缩、SourceMap 生成等环节无能为力
- 作为前端基建方案,业务依赖差异极大,难以针对特定依赖优化,如 DllPlugin 方案
- 作为移动端打包方案,追求极致的首屏加载速度,难以接受频繁的异步资源请求,如 Module Federation、Common Chunk 方案
- 存在一码多产物场景,需要单仓库多模式构建(1.0/2.0 * 主包/分包)下缓存复用,难以接受耦合度高的缓存方案,如 Persistent Caching
在这种情况下,只好另辟蹊径去寻找更多优化方案,这篇文章主要就是介绍这些“非主流”的优化方案,以及引发的思考。
分析
简化Webpack 的构建流程后,Webpack 的构建流程大体上分为如下几个阶段:
- 模块编译:需要运行如 babel、postcss 等 loader 对模块进行代码编译
- 依赖解析:需要使用 acorn 把代码生成 AST 并遍历查找下游依赖
- 代码压缩:需要生成 AST 并大量修改替换
- SourceMap:需要将构建流程代码操作产生的位置映射计算、合并
而在尽可能不改变处理逻辑的情况下,常见的优化思路就是“并行”和“缓存”:
- 并行:如 thread-loader
- 缓存:如 cache-loader/Persistent Caching
但目前“并行”和“缓存”仅覆盖模块编译阶段,能否把“并行”和“缓存”的方案扩展到整个构建流程呢?
准备
为了让“并行”+“缓存”能够覆盖整个构建流程,需要做如下准备工作:
- 引用透明改造:保证各个耗时较高的构建阶段无副作用
- 缓存池:统一管理各阶段生成的缓存
- 并行调度池:统一管理子进程/子线程的调度
引用透明改造
引用透明改造包括如下几个部分:
- 以 module 的 request 作为整个生命周期中的唯一标识,模块级粒度的构建控制参数都放到 request 的 query 中。
- 需要并行任务的配置、参数、结果都能够序列化/反序列化。
- 函数执行不依赖全局变量,相同的参数一定能得到相同的结果。
缓存池
缓存池的核心功能:
- 读写时机控制:Webpack 按照 module 维度拆分缓存,而由于 node_modules 黑洞导致 module 数量巨大,因此读写本地文件系统开销也较大,避免在主进程繁忙时读写缓存。
- 按需读写:通常模块并不一定会全量重新构建,因此按需的读取/写入能大幅度减少文件的操作次数。
- 整体/分体缓存:不同的场景可能导致缓存的切分粒度不同,比如分体缓存能够更好的处理按需读写,而整体缓存能在 faas 读取 nas 场景下获得较好的性能。
并行调度池
并行调度池类似于数据库连接池,主要功能:
- 任务队列:将处理任务放在队列中,同时向并行调度器发送处理请求。
- 并行调度器:收到处理请求时,若有空闲并行实例优先调度,若没有则按照最大并行数量新建。
- 子进程:使用 child_process 创建子进程,通过 IPC message 传输数据。
- 子线程:使用 worker_threads 创建子线程,通过 ArrayBuffer 传输数据(注意 nodejs 版本)。
- 并行实例:不处理实际逻辑,负责跨进程/线程通信,处理数据序列化反序列化,按需加载构建任务。
- 构建任务:执行具体的处理逻辑:
编译任务:使用 loader-runner 编译模块代码。
压缩任务:使用 terser/esbuild 压缩模块代码。
SourceMap 任务:生成序列化 SourceNode。
做好了这些准备工作后,就可以开始进行各个阶段的“并行”+“缓存”改造。
编译阶段优化
编译阶段流程
Webpack 内部的单个模块构建流程大致如下所示:
- 从 entry 开始,创建模块。
- 模块经过 loader 处理后,得到编译后代码。
- 编译后代码经过 AST 解析后,得到模块的下游依赖。
- 将下游依赖创建新的模块,回到步骤 2 递归处理。
- 直到所有模块都处理完成,模块编译流程结束。
Cache-loader
loader 运行类似于 Express/Koa 的中间件机制,每一个 Loader 分为 pitch 和 normal 两个阶段,cache-loader 利用这一点,在 pitch 阶段进行缓存检测,如果检测到缓存可用则直接返回。无缓存或缓存不可用则继续运行后续流程,直到 normal 阶段生成缓存写入文件系统。
thread-loader也是同理,只不过把后续的 loader 以及相关参数交给了子进程,并在子进程中模拟了 Webpack 的 loader 运行机制。
Persistent Caching
但 cache-loader 无法解决 AST Parser + 遍历生成依赖带来的消耗,开源界有 hard-source-webpack-plugin 尝试解决这个问题(但问题很多)。Webpack 团队自己也意识到了这个问题, 因此在 Webpack 5 中增加的 Persistent caching 来优化,但它的实现思路是将 Webpack 整个上下文都缓存下来,因此 Webpack 5 给几乎每个对象都增加了序列化/反序列化的方法:
// webpack@5.9.0/lib/NormalModule.js L1068 ~ L1105
serialize(context) {
const { write } = context;
// deserialize
write(this._source);
write(this._sourceSizes);
write(this.error);
write(this._lastSuccessfulBuildMeta);
write(this._forceBuild);
super.serialize(context);
}
deserialize(context) {
const { read } = context;
this._source = read();
this._sourceSizes = read();
this.error = read();
this._lastSuccessfulBuildMeta = read();
this._forceBuild = read();
super.deserialize(context);
}
但由于当时无法升级 Webpack 5,且 Persistent caching 脱离了统一的缓存控制,最终选择自己实现缓存来保证可移植、可拼接、预生成,如果在 Webpack 5 上实现,理论上可以复用一部分模块、依赖的序列化/反序列化能力,并桥接到缓存池上。
依赖解析缓存方案
方案设计
方案如下图所示:
- 缓存管理:将缓存池桥接到 Webpack 构建的生命周期 hooks 上。
- 模块处理器:模块的序列化与反序列化工具。
- 缓存匹配器:判断模块是否可以使用缓存中的数据。
- Hash 生成器:全局统一的 Hash 生成器。
处理流程
- 通过 NormalModuleFactory 干预模块生成,并代理掉模块自身的 build 方法。
- 当模块触发构建时,先进行缓存匹配:
- 首先需要通过模块 Request 生成 Hash 并从上面说的缓存池中找到对应的项目。
- 读取缓存中的 metaHash,并将 Request 里的文件通过 fs.stat 读取文件的元信息,将其中的文件名、文件大小、修改时间等信息生成 hash,与 metaHash 进行比对,相等则认为缓存可用。
- 读取缓存中的 contentHash,并读取文件文本内容生成 Hash 比对,相等则认为缓存可用。
- 缓存匹配时,使用模块反序列化器将缓存恢复成模块实例属性,并写入到当前模块中,跳过构建流程直接回调。
- 未匹配时,使用 Webpack 内置的模块 build 方法(上面被代理的方法)进行构建,但拦截其回调函数,在外面套娃进行模块的序列化。
模块处理器
模块的序列化分为两部分:模块本体序列化、模块依赖序列化。
模块本体的序列化较为简单:
- 模块的 Request,也就是模块的唯一 ID。
- 模块的 source 对象,一个 Webpack Source 实例,通过 sourceAndMap 方法获取其结果代码和 SourceMap 并序列化。
- 模块的构建信息对象,包括 buildInfo、buildMeta 对象。
模块的依赖序列化较为复杂,因为依赖由 Webpack 解析 AST 后遍历生成,依赖内部会直接保留相关联的 AST 节点,这些 AST 节点在后续的 chunk 产物生成的 dependency template 阶段会用来生成模块引用依赖的相关代码。
但实际上,依赖内部并不会真正使用多少 AST 的节点,仅仅是从其中读取少量信息用来做代码替换的位置判断和字符串拼接,因此序列化的过程就变成了提取 AST 上依赖使用的关键信息,而反序列化则是将这些关键信息伪造成 AST 节点即可。
不过,Webpack 内部这样的依赖有数十个(webpack/lib/dependencies目录下),需要一个个处理。同时,对于一些特殊的场景,比如 Block 类型的依赖(通常是异步加载的代码)无法支持。(Webpack 5 中可以直接用这些 Dependency 上面的序列化/反序列化方法)。
'use strict';
const NullDependency = require('./NullDependency');
class HarmonyExportHeaderDependency extends NullDependency {
constructor(range, rangeStatement) {
super();
this.range = range;
this.rangeStatement = rangeStatement;
}
get type() {
return 'harmony export header';
}
}
HarmonyExportHeaderDependency.Template = class HarmonyExportDependencyTemplate {
apply(dep, source) {
const content = '';
const replaceUntil = dep.range ? dep.range[0] - 1 : dep.rangeStatement[1] - 1;
source.replace(dep.rangeStatement[0], replaceUntil, content);
}
};
module.exports = HarmonyExportHeaderDependency;
如此这般,当缓存命中时,模块的依赖解析流程会被完全跳过。但这个流程并行化难度较高,主要原因是 Webpack 内 Parser Hooks 的桥接较为复杂,可以说 Hooks 的存在本身就是副作用的一种体现。
其他优化
Resolver
对 Webpack 的 enhance-resolver 进行缓存,降低 Webpack 在文件系统中查找的成本。由于 Resolver 较为复杂,且不同的 node_modules 组织方式、不同的依赖版本、不同的起始路径,都可能使得相同的 request 被解析到完全不同的文件,因此针对不同类型的 request,缓存的处理逻辑不同:
- Loader resolver:Loader 均由构建器统一管理,可以设置持久化缓存。
- 动态注入路径:在构建过程中添加的依赖,而非源码本身的依赖,受构建器统一管理,可以设置持久化缓存。
- node_modules:在一次构建中,相同 context 下的相同 request 可以使用内存缓存,但不宜使用持久化缓存。
- 项目源码:不宜使用缓存。
Hash
构建器和 Webpack 的处理流程中存在大量的 Hash 计算。而使用 md5 作为 Hash 的成本较高,可以采用如 imurmurhash 等碰撞率高一些但性能更好的 Hash 方案进行替换。同时代理的 Hash 也可用来做后续的可移植缓存。