前端构建效率优化之路
1455 字 · 4 分钟#Webpack#工程化
手上这个项目是个有些年头的单页应用:webpack 3 时代的配置,Angular 加上一部分 CoffeeScript 的历史包袱,样式走 Sass。全量构建一次要两分四十多秒,改一行代码等半天。这篇是给它提速的探索记录,每条路后面都写了当时的结论——有效、无效,还是待验证。
瓶颈分析
工欲善其事必先利其器,优化之前先得知道时间花在哪。speed-measure-webpack-plugin 可以统计 Webpack 的打包耗时,不仅有总时间,还能拆到每个插件和 loader 头上。
ni -D speed-measure-webpack-plugin
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = smp.wrap(config)
图里信息量不小:总耗时 2 分 46 秒;插件里 ExtractTextPlugin 和 UglifyJSPlugin 各吃掉近两分钟(各阶段并行统计,时间有重叠);loader 这边,1576 个模块光是遍历解析就花了 46 秒,sass-loader 又是 24 秒。对这样一个单页应用,构建的大部分时间都消耗在编译 JavaScript 和 CSS 文件的各类 loader 上。
背后是 Webpack 的构建流程:从入口文件出发递归遍历,不断寻找依赖、逐个编译,每个模块都要经历 String → AST → String 的来回,loader 串在中间处理字符串或执行脚本。加上 Node.js 单线程的特性和语言本身的执行效率,Webpack 构建慢一直是它饱受诟病的地方。
于是优化分成两个方向——开发阶段和生产阶段,探索了四类办法:Webpack 的常见传统优化、分模块构建、切换到 Vite、基于 esbuild 的插件。
开发阶段
缓存
结论:有效,已落地。
HardSourceWebpackPlugin 为模块提供中间缓存。首次构建没有太大变化,从第二次开始,构建时间会大大加快。
顺带一提,这个插件后来停止了维护——webpack 5 内置的持久化缓存(cache.type: 'filesystem')就是它的官方替代,升得动 webpack 5 的项目直接用内置方案就好。
多进程
结论:没跑出收益,暂时搁置。
thread-loader 会把排在它之后的 loader 放进单独的 worker 池里并行执行。听上去很美,但 worker 的启动和进程间通信本身有开销,loader 任务不够重时反而可能变慢。简单接入没看到改善,文档还得细读再试。
寻址优化
结论:零成本的通用技巧,能省一点是一点。
核心是合理设置 loader 的 exclude 和 include,让 loader 少碰文件:
exclude告诉 loader 可以忽略某个目录include直接圈定 loader 只处理指定目录——处理的文件越少,执行速度越快
分模块构建
结论:这次优化的主菜,已落地。
项目体量不断增大之后,耗时大头在于递归遍历 AST、解析 require,如此反复直到走完整个项目。而有意思的是,对单次开发而言,一个人极大概率只动这个大项目里的某一小块。
所以,如果收集依赖的时候可以跳过本次不需要的模块,或者干脆自行选择只构建必要的模块,整体构建时间就能大大减少。
原理
Webpack 是静态编译打包的:收集依赖时分析代码中的 require(import 会被 Babel 编译成 require)语句,然后递归地收集、构建。要做的就是通过增加一些配置、小幅改造现有代码,让 Webpack 在初始化遍历路由模块收集依赖时,跳过我们不需要的部分。
候选方案有三个:IgnorePlugin、webpack-virtual-modules、NormalModuleReplacementPlugin。最终选择 NormalModuleReplacementPlugin 做文本替换——它对项目的侵入性非常小,只需要添加一个前置脚本和一处 Webpack 配置,路由文件的代码一行都不用改。
实现
用 inquirer 做一个交互式的命令行问答,选出本次要开发的模块,再用 EJS 模板生成一份只包含这些模块的新路由配置;构建时由 NormalModuleReplacementPlugin 把原路由文件替换成生成的这份:
new webpack.NormalModuleReplacementPlugin(
/src[\\/]routes\.js$/,
path.resolve(__dirname, 'routes.generated.js'),
)
这套问答写起来不算优雅,用起来也多一步,但收益实打实:只选两三个模块开发时,依赖树小了一个量级,冷启动的等待肉眼可见地变短。
使用 Vite 优化开发时构建
结论:想得很美,放弃。
Vite 为什么快,官方文档的两张图就说明白了:传统 dev server 要把整个应用打包完才能就绪,Vite 直接起服务,浏览器请求到哪个模块,就按需编译哪个模块。
↑ 图取自 Vite 官方文档 Why Vite。
但套到这个项目上是另一回事:Angular 老工程,脚手架和构建深度绑定在 webpack builder 上,还拖着 CoffeeScript 这样的历史包袱,而 Vite 的插件生态主要围绕 Vue 和 React。评估下来迁移成本远大于收益,放弃。
热更新
结论:存疑。
Angular 真的有热更新吗?--hmr 挂上去看不出效果,行为更像整页 live reload,待深挖。
生产阶段
「去除不必要的检查步骤」这条路走不通——检查基本都是必要的。
引入 esbuild
结论:待验证。
思路是让 esbuild 接管最贵的两件事:转译和压缩(esbuild-loader 替换 babel-loader,压缩器替换 UglifyJS)。难点全在这个项目自己身上:得先把 @angular/builder 内置的 Babel 剔出来,还要对齐输出的目标语法版本,而项目的 Webpack 配置本身已经比较乱,动起来要小心。
其它
- 对项目做微前端拆分,把相对独立的模块拆解出来独立部署——那就不只是构建优化,是架构话题了。