摘要:CRA 项目构建越来越慢,但又不想 eject 暴露所有配置。本文记录了在 CRA 框架限制下,通过依赖优化、esbuild-loader、分包策略、缓存和并行处理,将生产构建从 8 分钟压缩到 40 秒的真实过程。所有方法均无需 eject。

起因:构建一次,够下楼吃顿饭再上来
我们团队的前端项目基于 Create React App(CRA),没有 eject。项目跑了三年,从最初的 3 个页面膨胀到 15 个页面、200 多个组件,依赖从几十个涨到上千个。生产构建时间从最初的一分钟出头,逐步恶化到 8 分钟。
8 分钟意味着:提交 PR 后等 CI 构建,失败了改完再提交,又是 8 分钟。一天下来,光等构建就能浪费一个多小时。
团队讨论过是否 eject 或迁移到 Vite。但 eject 之后要自己维护 Webpack 配置,迁移 Vite 的改动量太大。最后我们决定在不 eject 的前提下,把能做的小优化全做了。结果远超预期------生产构建从 8 分钟降到了 40 秒。
诊断:先搞清楚时间都去哪了
优化之前,得先知道瓶颈在哪。我用 react-scripts build --profile 生成构建耗时报告:
bash
npm run build -- --profile --json > stats.json
然后用 webpack-bundle-analyzer 和 speed-measure-webpack-plugin 分析耗时。
speed-measure-webpack-plugin 在 CRA 里需要一点技巧才能用。我们通过 CRACO(CRA Configuration Override)注入:
javascript
// craco.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = {
webpack: {
configure: (webpackConfig) => {
return smp.wrap(webpackConfig);
}
}
};
跑完后,耗时分布一目了然:
| 阶段 | 耗时 | 占比 |
|---|---|---|
| Babel 转译 | 3m 20s | 42% |
| CSS/SCSS 处理 | 1m 50s | 23% |
| Terser 压缩 | 1m 30s | 19% |
| SourceMap 生成 | 40s | 8% |
| 其他 | 40s | 8% |
| 总计 | ~8m | 100% |
Babel 转译和 Terser 压缩是两大头,占了总时间的 61%。优先砍这两块。
优化一:用 esbuild-loader 替换 Babel + Terser
Babel 是单线程的,Terser 也是。esbuild 用 Go 写成,多线程并行,速度有数量级的差异。
通过 CRACO 把 Babel 替换成 esbuild-loader,同时把 Terser 替换成 ESBuildMinifyPlugin:
bash
npm install -D esbuild-loader @craco/craco
javascript
// craco.config.js
const path = require('path');
const EsbuildPlugin = require('esbuild-loader').EsbuildPlugin;
module.exports = {
webpack: {
configure: (webpackConfig) => {
// 1. 替换 Babel 为 esbuild-loader
const oneOfRule = webpackConfig.module.rules.find(
rule => rule.oneOf
);
if (oneOfRule) {
const babelLoader = oneOfRule.oneOf.find(
rule => rule.loader && rule.loader.includes('babel-loader')
);
if (babelLoader) {
babelLoader.loader = 'esbuild-loader';
babelLoader.options = {
loader: 'tsx', // 处理 TypeScript + JSX
target: 'es2015', // 目标环境
tsconfigRaw: require('./tsconfig.json'),
};
}
}
// 2. 替换 Terser 为 esbuild
if (webpackConfig.optimization) {
webpackConfig.optimization.minimizer = [
new EsbuildPlugin({
target: 'es2015',
css: true, // 同时压缩 CSS
}),
];
}
return webpackConfig;
}
}
};
改完之后,Babel 转译时间从 3 分 20 秒降到 12 秒,Terser 压缩从 1 分 30 秒降到 5 秒。两项合计从接近 5 分钟变成不到 20 秒。效果立竿见影。
优化二:分包策略------别把所有东西打成一个包
之前所有依赖都打进了一个 bundle,不仅构建慢,首屏加载也慢。把不常变的 vendor 拆出来,每次只构建变化的业务代码。
javascript
// craco.config.js(追加到 webpack.configure 中)
const webpackConfig = ...;
webpackConfig.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
// React 核心
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'vendor-react',
priority: 40,
},
// Ant Design
antd: {
test: /[\\/]node_modules[\\/]antd[\\/]/,
name: 'vendor-antd',
priority: 30,
},
// 图表库
charts: {
test: /[\\/]node_modules[\\/](echarts|recharts)[\\/]/,
name: 'vendor-charts',
priority: 20,
},
// 其他依赖
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-common',
priority: 10,
},
},
};
return webpackConfig;
配合路由懒加载(React.lazy),构建时只有变更的路由页面会重新打包,vendor 包缓存命中后直接跳过。
优化三:CSS 处理加速
项目里用了 SCSS,每次构建都要编译。改成 esbuild 处理 CSS 后,SCSS 编译时间从 1 分 50 秒降到 8 秒。
另一个容易被忽略的点是 Ant Design 的样式。Antd 5 用 CSS-in-JS,运行时注入样式,不需要在构建时编译 Less。如果还在用 Antd 4,建议升级,构建速度提升明显。
升级 Antd 的注意事项和具体迁移步骤,可以参考 gpt108.com 上整理的前端技术专题,里面有 Antd 4 到 5 的完整迁移指南和常见坑点汇总,对我们这次升级帮助很大。
优化四:构建缓存持久化
Webpack 5 支持持久化缓存到文件系统,配置后增量构建速度极快。CRA 默认没开,通过 CRACO 开启:
javascript
// craco.config.js(追加)
webpackConfig.cache = {
type: 'filesystem',
buildDependencies: {
config: [__filename], // 配置文件变化时缓存失效
},
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
};
首次构建后,后续构建速度大幅提升。配合 CI 里的 actions/cache:
yaml
- name: Cache webpack
uses: actions/cache@v4
with:
path: node_modules/.cache
key: webpack-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
webpack-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
CI 里的增量构建时间从 8 分钟降到了第一次 40 秒,后续 15 秒(缓存命中)。
优化五:并行处理------能同时跑的都同时跑
之前的 CI 是串行的:lint → test → build。lint 和 test 之间没有依赖关系,完全可以并行。
yaml
# .github/workflows/ci.yml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run build
三个 job 并行跑,总时间从 lint + test + build(串行)变成了 max(lint, test) + build。虽然每个 job 单看没变快,但端到端的时间少了。
额外收益:开发体验也提升了
生产构建优化完,顺带把开发服务器的启动速度也优化了。npm start 从 45 秒降到 8 秒,热更新从 2 秒变成几乎实时。团队的开发体验好了很多。
优化效果总览
| 优化项 | 优化前 | 优化后 | 节省 |
|---|---|---|---|
| Babel 转译 | 3m 20s | 12s | -3m 08s |
| Terser 压缩 | 1m 30s | 5s | -1m 25s |
| CSS 处理 | 1m 50s | 8s | -1m 42s |
| 构建缓存(二次构建) | 无 | 15s | - |
| 并行 CI | 12m 端到端 | 3m 端到端 | -9m |
| 生产构建总耗时 | ~8m | ~40s | -85% |
8 分钟到 40 秒。提交 PR 后喝口水,构建已经跑完了。以前怕 CI 挂了反复等的焦虑感彻底消失。
踩过的坑
-
esbuild-loader 不支持装饰器 :项目里用了
@observable装饰器(mobx),esbuild 不支持。解决:装饰器的文件仍然走 Babel,其他文件走 esbuild。 -
Webpack 缓存和 CI 缓存冲突 :CI 里 Webpack 缓存偶尔会因 node 版本不一致而报错。解决:CI 里指定固定的 node 版本号,并且
restore-keys精确到package-lock.json哈希。 -
拆包后首屏变慢:vendor 包太多导致 HTTP 请求数增加。解决:控制 vendor 包数量不超过 5 个,配合 HTTP/2 的多路复用,体验没影响。
总结
CRA 项目的构建优化,核心思路就四个:
- 换工具:Babel → esbuild,Terser → esbuild。数量级提速。
- 拆包:vendor 和业务代码分开,减少重复构建。
- 开缓存:Webpack 持久化缓存 + CI 缓存,增量构建秒级。
- 并行跑:CI 里 lint、test、build 能并行的全并行。
不需要 eject,不需要迁 Vite,靠 CRACO 和几个 loader 就能做到。如果你的 CRA 项目构建超过 3 分钟,这周末试试,你会在周一的团队群里被同事感谢。
你的项目构建一次要多久?试过哪些优化方法?欢迎评论区交流。