Hello,大家好,我是松宝写代码,写宝写的不止是代码。接下来给大家带来的是关于Webpack4的性能优化的系列,今天带来的是编译阶段的性能优化。
由于优化都是在 Webpack 4 上做的,当时 Webpack 5 还未稳定,现在使用 Webpack 5 时可能有些优化方案不再需要或方案不一致,这里主要介绍优化思路,仅作为参考。
在接触一些大型项目构建速度慢的很离谱,有些项目在 编译构建上30分钟超时,有些构建到一半内存溢出。但当时一些通用的 Webpack 构建优化方案要么已经接入,要么场景不适用:
在这种情况下,只好另辟蹊径去寻找更多优化方案,这篇文章主要就是介绍这些“非主流”的优化方案,以及引发的思考。
简化Webpack 的构建流程后,Webpack 的构建流程大体上分为如下几个阶段:
而在尽可能不改变处理逻辑的情况下,常见的优化思路就是“并行”和“缓存”:
但目前“并行”和“缓存”仅覆盖模块编译阶段,能否把“并行”和“缓存”的方案扩展到整个构建流程呢?
为了让“并行”+“缓存”能够覆盖整个构建流程,需要做如下准备工作:
引用透明改造包括如下几个部分:
缓存池的核心功能:
并行调度池类似于数据库连接池,主要功能:
编译任务:使用 loader-runner 编译模块代码。
压缩任务:使用 terser/esbuild 压缩模块代码。
SourceMap 任务:生成序列化 SourceNode。
做好了这些准备工作后,就可以开始进行各个阶段的“并行”+“缓存”改造。
Webpack 内部的单个模块构建流程大致如下所示:
loader 运行类似于 Express/Koa 的中间件机制,每一个 Loader 分为 pitch 和 normal 两个阶段,cache-loader 利用这一点,在 pitch 阶段进行缓存检测,如果检测到缓存可用则直接返回。无缓存或缓存不可用则继续运行后续流程,直到 normal 阶段生成缓存写入文件系统。
thread-loader也是同理,只不过把后续的 loader 以及相关参数交给了子进程,并在子进程中模拟了 Webpack 的 loader 运行机制。
但 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 解析 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 的存在本身就是副作用的一种体现。
对 Webpack 的 enhance-resolver 进行缓存,降低 Webpack 在文件系统中查找的成本。由于 Resolver 较为复杂,且不同的 node_modules 组织方式、不同的依赖版本、不同的起始路径,都可能使得相同的 request 被解析到完全不同的文件,因此针对不同类型的 request,缓存的处理逻辑不同:
构建器和 Webpack 的处理流程中存在大量的 Hash 计算。而使用 md5 作为 Hash 的成本较高,可以采用如 imurmurhash 等碰撞率高一些但性能更好的 Hash 方案进行替换。同时代理的 Hash 也可用来做后续的可移植缓存。