本文不是教程,是一次"地下重构"的完整复盘。
事情是这样的
公司有个三年前的 React 项目,Webpack 4 + Babel 7 + 一堆自定义 loader,构建时间稳定在 4分30秒 左右。
每次改个文案,等热更新要喝杯水。提个 PR,CI 跑完能去趟厕所再回来。
上周我实在是忍不了了。周五下午需求评审完,我 Jira 上只建了一张票:"优化构建配置",估点 2sp。
领导看了一眼:嗯,小优化,去吧。
他不知道的是,我周末在家干了什么。
周六:先搞清楚这 4 分半都在干嘛
bash
ini
# 先上正经 profiling
DEBUG=webpack* npm run build 2> webpack.log
然后写了个脚本分析:
JavaScript
javascript
// analyze-build.js
const fs = require('fs');
const log = fs.readFileSync('webpack.log', 'utf8');
// 提取每个 loader 的耗时
const loaderTimes = log.match(/(\d+)ms.*?loader/g) || [];
console.table(loaderTimes.map(s => {
const [time, name] = s.match(/(\d+)ms.*?(\w+)-loader/).slice(1);
return { loader: name, time: +time };
}).sort((a, b) => b.time - a.time));
结果让我沉默了:
表格
| Loader | 耗时 | 备注 |
|---|---|---|
babel-loader |
127s | 全量转译,包括 node_modules |
ts-loader |
89s | 类型检查跟编译捆在一起 |
sass-loader |
45s | 没开 fiber,同步解析 |
eslint-loader |
38s | 构建时全量 lint,包括第三方库 |
babel-loader 在转译 node_modules 里的 lodash。
我盯着这个结果看了三分钟。
周六晚上:第一刀,先砍最明显的
1. 把 babel-loader 的 include 收紧
JavaScript
javascript
// 之前:没有 include,全量过 babel
{
test: /.(js|jsx)$/,
use: ['babel-loader'] // 127s
}
// 之后:只转译 src + 必要的 esm 包
{
test: /.(js|jsx)$/,
include: [
path.resolve(__dirname, 'src'),
// 只转译那些发布的是 esm 且需要兼容的包
path.resolve(__dirname, 'node_modules/@company'),
path.resolve(__dirname, 'node_modules/xxx-esm-pkg')
],
use: ['babel-loader']
}
这一刀下去:127s → 34s。
但还不够。ts-loader 的 89s 也很离谱。
2. 把类型检查从构建流程里拆出来
JavaScript
javascript
// 之前:ts-loader 自己做类型检查,阻塞编译
{
test: /.tsx?$/,
use: [
'babel-loader', // 转义
{
loader: 'ts-loader',
options: { transpileOnly: false } // 默认就是 false!
}
]
}
// 之后:ts-loader 只负责转译,类型检查交给 fork-ts-checker
{
test: /.tsx?$/,
use: ['babel-loader', 'ts-loader'] // ts-loader 默认 transpileOnly: false?
// 不对,ts-loader 默认确实是 false,但我们可以显式优化
}
实际上我换了思路:
JavaScript
yaml
// webpack.config.js
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
module: {
rules: [{
test: /.tsx?$/,
use: [
'babel-loader',
{ loader: 'ts-loader', options: { transpileOnly: true } }
]
}]
},
plugins: [
new ForkTsCheckerWebpackPlugin({
typescript: { diagnosticOptions: { semantic: true, syntactic: true } }
})
]
};
类型检查移到子进程,不阻塞主构建流程。
这一刀:89s → 21s(ts-loader 部分),且热更新不再等类型检查。
3. sass-loader 开 fiber + 持久化缓存
JavaScript
javascript
{
test: /.scss$/,
use: [
'style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: { fiber: false } // sass 1.33+ 废弃了 fiber,但...
// 实际上我升级了 sass-loader 并启用了 webpack5 的持久化缓存
}
}
]
}
这里我直接上了 Webpack 5 的持久化缓存:
JavaScript
lua
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}
};
第二次构建:4分30秒 → 38秒。
冷启动还有优化空间,但热缓存已经起飞了。
周日:我动了邪念
构建快了,但我看着 webpack.config.js 里那坨三年前的配置,手痒了。
这项目当年是从 CRA eject 出来的,配置里还有 eslint-loader(CRA 3 时代的产物)、url-loader、file-loader 混用、optimize-css-assets-webpack-plugin 配 cssnano...
我打开文档看了一眼:Vite 5 已经稳定了。
但迁 Vite 风险太大,我不敢。而且 Jira 票上写的是"优化构建配置",不是"重构构建工具"。
所以我折中了一下:用 Rspack 做一次"无痛迁移"。
Rspack 是字节出的,Webpack API 兼容,但用 Rust 重写,号称构建速度 5-10 倍。
我心想:反正都是 Webpack 配置,试试又不亏。
bash
sql
npm install @rspack/core @rspack/cli --save-dev
然后把 webpack.config.js 改成 rspack.config.js,API 基本不用动:
JavaScript
css
// rspack.config.js
const rspack = require('@rspack/core');
module.exports = {
// 90% 的配置直接复制过来就能跑
module: {
rules: [
// babel-loader 换成 @rspack/plugin-react
{
test: /.(js|jsx|ts|tsx)$/,
use: {
loader: 'builtin:swc-loader', // Rspack 内置 SWC,比 babel 快得多
options: {
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } }
}
}
}
}
]
},
plugins: [
new rspack.HtmlRspackPlugin({ template: './public/index.html' }),
// 其他插件大部分都能直接用
]
};
跑了一下:
bash
npx rspack build
冷构建:28秒。
有缓存:8秒。
我反复确认了三遍,没报错,产物正常,Source Map 也在。
周一:我撒了个小谎
晨会上,领导问:"构建优化那件事做得怎么样了?"
我说:"嗯,改了一些 loader 配置,构建快了一点。"
领导:"好,继续推进需求吧。"
我点点头,把 rspack.config.js commit 上去,PR 标题写的是:
chore: optimize build config and enable persistent cache
代码里确实也有 Webpack 5 缓存的配置(我保留了双份配置,Rspack 是主配置,Webpack 5 的作为 fallback),不算完全撒谎。
周三:事情开始失控
PR merge 的第二天,我收到一条飞书消息:
隔壁组后端 @我:"你们组 CI 怎么跑这么快?我们前端项目构建要 6 分钟,能参考下你们的配置吗?"
我还没回,又一条:
B 业务线前端负责人 :"听说你们构建优化了?能把
rspack.config.js发我看看吗?"
然后架构群开始有人 @我:
"xxx 你们那个 Rspack 迁移有文档吗?我们组也想试试。"
我慌了。
因为我 根本没有写迁移文档 。那个 rspack.config.js 是我周末边试边改的,里面还有几行我自己都忘了干嘛的注释:
JavaScript
arduino
// TODO: 这个 plugin 好像不加也行?先留着
// FIXME: 生产环境这里可能有问题,周末再测
而且最尴尬的是:我们的 CI 配置还是用的 webpack 命令,Rspack 是我本地开发用的。
也就是说,CI 上跑的还是优化后的 Webpack 5(28秒左右),本地开发用的是 Rspack(8秒)。
但隔壁组以为我全链路都切了。
周四:被迫写"文档"
下午被拉进了一个临时会议,标题是"《前端构建优化经验分享》"。
参会人:3 个业务线的前端负责人 + 架构组 + 我的领导。
领导看着我:"你优化得不错啊,给大家讲讲?"
我:"..."
然后我把周末干的事全抖了:
- Webpack 5 持久化缓存 + loader 范围收紧
- ts-loader
transpileOnly+fork-ts-checker - 本地开发切 Rspack(CI 还是 Webpack 5)
架构组的人问:"CI 为什么不一起切 Rspack?"
我说:"Rspack 的 copy-webpack-plugin 替代品有个 bug,生产环境我还没测完..."
其实是我周末没时间测了。
领导听完,沉默了一下,说:"那你这周把 CI 也切了吧,写个迁移文档,给其他组参考。"
我:"...好的。"
现在的情况
- 我们组:本地 Rspack(8s),CI Rspack(15s),已稳定运行两周
- A 业务线:抄了我的配置,但遇到 SWC 和 babel-plugin-import 的兼容问题,我来修的
- B 业务线:直接上 Vite 了,说"反正都要迁,不如一步到位"
- C 业务线:还在 Webpack 5,但用我的优化方案,构建从 5 分钟降到 40 秒
我的 Jira 票还是 2sp,状态"已完成"。
但我的飞书签名改成了: "构建优化咨询请先发红包。"
一些正经的总结
表格
| 优化手段 | 收益 | 风险 |
|---|---|---|
| Webpack 5 持久化缓存 | 二次构建 10x 提升 | 缓存失效策略要配好 |
loader include 收紧 |
减少 60%+ 无效编译 | 要确认哪些包需要转译 |
ts-loader + fork-ts-checker |
编译和类型检查并行 | 类型错误不会阻塞构建,需配 CI 检查 |
| Rspack/SWC | 冷构建 5-10x 提升 | 部分 babel plugin 不兼容,需逐个验证 |
最后
重构这件事,有时候不需要"立项评审"、"技术方案评审"、"排期"。
你只需要:
- 一个忍不了的痛点
- 一个不用加班的周末
- 一张只写"优化一下"的 Jira 票
然后,等同事来找你要代码。
你有过类似的"地下重构"经历吗?评论区聊聊。
如果本文对你有帮助,欢迎点赞收藏。如果构建优化遇到问题,可以留言,我看到了会回(但不一定及时,因为可能在帮隔壁组修 CI)。