在考虑更换新的打包工具之前,先分析 Webpack 单体仓库构建缓慢的问题
通过分析20分钟长的Webpack微前端构建过程,发现了Terser、Babel、安装流程以及Docker缓存等问题,而哪些修复措施使其时间缩短至约两分钟。
当前端构建速度变慢时,人们的本能反应往往是归咎于打包工具并开始规划迁移。但这种反应往往是不正确的。本案例研究针对的是一个微前端平台,其 Webpack 构建时间已超过20分钟;通过性能分析发现问题并不出在打包工具本身,通过一系列有针对性的改进,在无需更换 Webpack 的情况下将构建时间缩短到了大约2分钟。您将了解到如何找到真正的瓶颈、哪些基于 Rust 的工具替代了哪些构建阶段,以及为什么依赖安装、注册表令牌和 Docker 层叠与任何加载器一样重要。
当构建时间成为平台问题时
该平台每月会收到来自十余名工程师的600多份拉取请求,由此产生了5,000多次CI构建。以每次构建20分钟计算,每月的CI运行时间超过1,600小时。在如此高的频率下,缓慢的构建速度不再只是个小麻烦,而会成为整个组织的瓶颈:反馈延迟送达,CI队列不断堆积,紧急的生产问题修复也需排队等待。
单体仓库的结构让情况更加糟糕:
20 production apps: React and Next.js applications
7 shared libraries
1 centralized E2E test suite
~3,950 TSX/JSX files and 6,600+ TypeScript source files
~350 reusable component modules
144 npm dependencies (74 production, 70 development)
Workspace: single hoisted monorepo
Team: 10+ engineers, 600+ PRs/month
CI: 5,000+ build runs/month
20个应用程序在同一个工作空间中共享7个库,这意味着任何工具的变更都会同时影响到所有项目。
为何先进行性能分析再考虑迁移
在20个生产级应用、7个共享库以及144个依赖项之间更换打包工具是一项高风险项目。某个共享库出现问题可能会波及所有应用,而重新验证所有的构建输出可能需要数周时间,且无法保证一定能解决问题。因此,团队首先对整个流程进行了分析。分析结果显示,在打包工具之外,类型检查、测试编排和依赖解析等方面也存在相当大的开销,而这些方面并不会因使用新的打包工具而得到改善。
这一经验在任何性能优化工作中都很常见:在测量之前进行优化会让每一次改动都变成一种猜测。Webpack在生成最终产物之前要经历多个不同的阶段:
- 模块编译
- 加载器执行
- 代码块生成
- 资源优化
- 压缩处理
- 资源输出
如果没有各阶段的耗时数据,就无法判断哪些阶段需要重点关注,因此在获得这些数值之前,加载器及插件配置都未被修改。
使用 ProgressPlugin 找出性能瓶颈
Webpack 自带了 ProgressPlugin,无需额外安装。只需在配置文件中指定 Webpack 即可:
const webpack = require('webpack');
然后将该插件添加到 plugins 数组中:
plugins: [
new webpack.ProgressPlugin()
]
开启性能分析输出后,有一行数据立刻引起了注意:
[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s
有一个插件耗时超过了13分钟。与优化阶段的其他插件相比,这种差异极为明显:
copy-webpack-plugin 30ms
WriteIndexHtmlPlugin 22ms
RealContentHashPlugin 58ms
CompressionPlugin 70ms
LicenseWebpackPlugin 1.15s
TerserPlugin 825.31s
其他插件均在几毫秒内完成,而许可证插件则大约需要一秒钟。问题并不出在打包工具本身,而是使用 Terser 进行 JavaScript 压缩时效率过低。这一发现促使团队彻底改变了工作方向。
使用 Speed Measure Plugin 进行加载器级计时
ProgressPlugin 能显示哪个阶段耗时较长,但无法指出编译过程中是哪条加载器链导致了延迟。为此,团队引入了 speed-measure-webpack-plugin,该插件会对导出的配置进行封装:
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);
SMP能够显示每个加载器链处理模块所需的时间,这使得我们可以在引入SWC前后对比处理流程的效率。它还提供了真实的性能数据。mini-css-extract-plugin、css-loader和postcss-loader组成的CSS处理链并未变快,处理时间仅从8.66秒略微上升至9.36秒。在总共20.78秒的总体处理时间中,未被任何加载器处理的CSS代码和模块占据了近16秒的时间。团队无需再争论该使用哪种工具,因为有具体的数据表明下一步应该优化哪个环节。
需要注意的一点是:SMP通过封装插件和加载器来计时,但它可能与某些较新的插件产生冲突,因此应将其用于诊断目的,而非直接放入生产环境配置中。
制约所有决策的目标
在做出任何更改之前,团队将目标分成了三组。
构建性能
- 降低冷启动构建的延迟。
- 加快增量构建的速度。
- 减少代码转译和压缩所花费的时间。
- 更高效地利用可用的CPU核心。
CI效率
- 更多复用Docker缓存层。
- 缩短依赖项安装时间。
- 让流水线更具确定性。
平台稳定性
- 在企业规模下保持工具的稳定性。
- 避免有风险的迁移操作。
- 保持与现有生态系统的兼容性。
- 在原始速度与长期可维护性之间取得平衡。
为何选择放弃Vite和Rspack
Vite已经经过了认真评估。它是一款出色的软件,通常非常适合新项目或较为简单的项目。关键的区别在于,它是基于实际的构建流程进行测试的,而非许多迁移指南中所提供的简单演示。评估结论是,最大的成本因素——代码压缩——与打包工具无关:即便改用Vite,也需要大约四周时间,而且团队仍会面临类似的瓶颈,还需解决新的配置格式以及插件兼容性问题。决策记录中表明,如果编译问题最终成为主要瓶颈,可以重新考虑使用Vite。
需要客观说明的是:Vite 默认使用 esbuild 而非 Terser 来压缩 JavaScript,因此实际进行迁移时也会同时更换压缩工具。这反而更凸显了核心观点,而非削弱它。关键在于压缩工具的选择,而 Webpack 无需迁移即可使用同样高效的压缩工具。
另一种被考虑过的方案是 Rspack,它是一种基于 Rust 的打包工具,具备与 Webpack 兼容的配置,前景似乎也不错。但由于当时存在近期安全事件以及生态系统仍在发展中,团队决定谨慎对待,将其列入后续重新评估的清单。在自行下结论之前,请先了解其当前状况。
重构最慢的环节
在策略确定后,工作重点转为逐一替换那些运行速度最慢的组件。
使用 SWC 而非 Babel 进行代码转换
JavaScript 和 TypeScript 的转译曾是最大的成本之一。Babel 被基于 Rust 开发的 SWC 取代,后者专为高吞吐量的 JS 和 TS 转换而设计,通过 swc-loader 引入,并在之前使用 thread-loader:
{
test: /\.(jsx?|tsx?)$/,
use: [
'thread-loader',
{
loader: 'swc-loader'
}
]
}
公开测试表明 SWC 的转译速度远快于 Babel,在该流程中,这一替换带来了更快的转译速度、通过 thread-loader 实现的并行处理、对主线程的干扰更少以及更短的 CI 运行时间。此处的 swc-loader 需要依赖 .swcrc 文件或内联的解析器和 JSX 设置选项;缺少这些选项的话,TypeScript 和 JSX 语法将无法被解析。
使用 esbuild 替代 Terser 进行压缩
性能分析已经指出了主要问题,因此 JavaScript 的压缩工作通过 esbuild-loader 的插件转而由 esbuild 承担:
new EsbuildPlugin({
target: 'es2015',
minify: true
})
这一选择是基于minification-benchmarks项目的结果做出的,该项目会从压缩后大小、gzip压缩后大小以及处理时间等方面对比esbuild、terser、swc、uglify-js等工具的表现。根据相关数据:
- esbuild的压缩后及gzip压缩后的输出表现相当不错,虽然略大于最佳结果,但差距仅在5%到8%左右;
- 在处理相同输入时,esbuild的耗时约为295毫秒,而terser则需要约6.7秒,这种差距在大型项目组中会更为显著;
@swc/core和oxc-minify虽然能生成更小的输出文件,但它们的速度劣势以及集成难度使得决策倾向于选择esbuild。
target: 'es2015' 这一设置用于告知 esbuild 可以使用哪种语法;请确保该设置与实际浏览器的支持情况相匹配,因为更高的目标版本能让代码更简洁。
用于 CSS 压缩的 LightningCSS
CSS 压缩功能已从 Parcel 团队开发的 LightningCSS 替代,该工具被作为 css-minimizer-webpack-plugin 的压缩函数使用:
new CssMinimizerPlugin({
minify: CssMinimizerPlugin.lightningCssMinify,
})
LightningCSS 基准测试将其与 cssnano 和 esbuild 的 CSS 压缩工具进行了对比。测试结果显示,在所有测试场景中它都是最快的,生成的代码体积更小,尤其是在处理像 Tailwind 之类的大型样式表时优势更为明显。在那些 CSS 优化会直接影响整体延迟的 CI 流水线中,更出色的压缩效果加上更短的处理时间使其成为显而易见的首选。在两次压缩工具更新后,JavaScript 的压缩速度提升了约 10 倍,而 CSS 优化速度则提升了约 6 倍。
充分利用每个 CPU 核心
CI 代理通常拥有多个核心,但许多流水线却让其中大部分核心处于闲置状态。在那些计算成本较高的加载器之前加入 thread-loader,可以将这些任务分配到多个工作进程上:
use: ['thread-loader', 'swc-loader']
这减少了冷构建的瓶颈,提升了资源利用率,并提高了持续集成流程的处理速度。需要注意的是,每个工作进程都会产生启动和消息传递的开销;对于小型项目而言,像SWC这样本来就速度很快的加载器,使用工作进程带来的成本可能超过其节省的成本,因此需要对比有与没有工作进程时的表现。
使用 pnpm 实现更快、更可预测的安装过程
编译并非唯一的成本因素,安装依赖项也会占用持续集成的时间。该团队通过涵盖安装速度、磁盘使用情况以及依赖解析方式的公开基准测试,对比了 npm、yarn、pnpm 和 bun 的表现。在模拟测试中 bun 表现优异,但在生态系统成熟度、Node.js 兼容性以及大型生产环境中的应用记录方面,pnpm 更具优势。
npm会将包复制到每个node_modules目录中,而ppnm则维护一个基于内容地址的全球存储:每个包版本仅下载一次并硬链接到各个项目中。这带来了几大优势:
- 由于包只需获取一次即可重复使用,安装速度更快;
- 工作区不再复制相同的包,从而减少了磁盘占用;
- 严格的依赖解析机制可以阻止幻影依赖的出现,即那些代码中引用了但未明确声明的包,从而使构建过程更加可预测;
- 由于该存储可在多次构建之间重复使用,Docker层缓存效果也会得到提升。
在Docker环境中,BuildKit缓存挂载功能能让ppnm的存储在多次构建之间保持不变,而--frozen-lockfile选项会在锁文件过期时阻止安装:
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
导致缓存失效的认证问题
最有效的解决方案之一与前端工具无关。这些软件包来自 AWS CodeArtifact,其授权令牌12小时后会过期。在流水线中反复获取令牌会导致缓存失效、重复进行身份验证,还会破坏 Docker 层结构,因为输入到某层的值发生变化就会改变该层的缓存键。
解决办法是在每个 Jenkins 任务中仅请求一次令牌,然后在所有步骤中重复使用它:
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
--domain <domain> \
--domain-owner <owner> \
--query authorizationToken \
--output text)
这样不仅能更好地复用层结构,减少不必要的安装操作,还能让流水线运行更稳定。如果要将此类令牌传递给 Docker 构建过程,建议使用机密文件挂载而非构建参数,这样它既不会出现在镜像历史记录中,也不会导致缓存失效。
为提升缓存命中率而优化 Dockerfile 的层结构
最后,这些 Dockerfile 被重新组织为按变化频率从低到高排序的层结构:
- 基础运行时;
- 依赖项安装;
- 源代码复制;
- Webpack构建。
结合用于包存储的--mount=type=cache选项以及用于处理凭据的--mount=type=secret选项,这种执行顺序意味着典型的代码更改仅需要重新构建最后两层,从而大幅提升了缓存的重用率。
关键要点
- 应将构建速度视为一个系统问题。在此案例中,最大的优化效果来自代码压缩工具、依赖安装、凭据管理以及Docker分层技术,而非打包工具本身。
- 先进行性能分析。
ProgressPlugin几分钟内就发现了Terser压缩步骤耗时13分钟的问题;而盲目尝试迁移则可能需要数周时间。 - 基于Rust的工具如SWC、esbuild和LightningCSS具有协同效应:每一种工具都能进一步减少延迟。