前端 CI 构建太慢,每次 push 要等 15 分钟。本文记录通过依赖缓存、并行执行、构建产物分析和 Webpack 配置调优,将构建时间压缩到 2 分钟的真实过程。

问题:push 之后等一杯咖啡还不够
我们的前端项目是一个 React + TypeScript 的单体应用,有 15 个页面,200 多个组件,NPM 依赖超过 1000 个。CI 用的是 GitHub Actions,每次 push 到 feature 分支,流水线大概要跑 15 分钟。
这 15 分钟是这样分布的:
npm ci:3 分钟npm run lint:1 分钟npm run typecheck:2 分钟npm run test:4 分钟npm run build:5 分钟
团队里每个人一天至少要 push 3-5 次,加起来每天光等 CI 就要浪费一个多小时。更难受的是,有时候 CI 跑到第 14 分钟报了一个 lint 错误,修完再 push,又是 15 分钟。
我花了一个下午,把这套流水线重新理了一遍,构建时间从 15 分钟压到了 2 分钟左右。下面是完整的优化过程。
第一步:先看清时间都花在哪里
GitHub Actions 每个 job 都有一个「Run time」的统计,但默认只显示总时长,看不到每个 step 的具体耗时。我在 workflow 里加了一个计时的 step,把每一步的执行时间打印出来。
yaml
# 在每个 step 前后加时间戳
- name: ⏱️ Timing start
run: echo "STEP_START=$(date +%s)" >> $GITHUB_ENV
- name: Run tests
run: npm run test
- name: ⏱️ Timing end
run: |
STEP_END=$(date +%s)
ELAPSED=$((STEP_END - STEP_START))
echo "⏱️ Tests took ${ELAPSED} seconds"
跑了几次之后,我拿到了精确到秒的耗时分布:
| Step | 平均耗时 | 占比 |
|---|---|---|
| Checkout + Setup Node | 45s | 5% |
npm ci |
3m 20s | 22% |
npm run lint |
55s | 6% |
npm run typecheck |
2m 5s | 14% |
npm run test |
4m 10s | 28% |
npm run build |
4m 50s | 25% |
| 总计 | 约 15m | 100% |
大头是 npm ci、test 和 build,这三个占了总时间的 75%。优化的重心就放在这里。
第二步:用缓存干掉 npm ci 的 3 分钟
npm ci 每次都要从 registry 下载所有包,即使 package-lock.json 没变,也要重新走一遍网络请求。
GitHub Actions 提供了官方的 cache action,可以把 node_modules 缓存起来:
yaml
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
关键 :key 里用了 hashFiles('package-lock.json'),当 lock 文件不变时,缓存直接命中,跳过整个 npm ci。当 lock 文件变了,缓存失效,自动重装。
加上缓存后,日常 push(lock 文件不变)的 npm ci 耗时从 3 分钟降到了 10 秒(恢复缓存的时间)。
一个容易忽略的细节 :restore-keys 里的模糊匹配 npm-${{ runner.os }}- 在缓存未精确命中时,会恢复最近的一个缓存。这意味着即使 lock 文件变了,也可以先恢复旧的 node_modules,再跑 npm ci 只更新变化的部分,比从头下载快很多。但这里有一个坑:npm ci 的设计就是先删 node_modules 再装,所以如果 lock 变了,旧缓存恢复后必须跑一次完整的 npm ci。更好的做法是用 npm install 代替 npm ci 来利用增量更新,但这会改变依赖安装行为,需要团队评估风险。
第三步:把 Lint、TypeCheck、Test 并行跑
原来的 workflow 是串行的:先 lint,再 typecheck,再 test,再 build。但实际上,lint、typecheck、test 这三步之间没有依赖关系,完全可以并行。
GitHub Actions 的 jobs 可以定义多个 job 并行执行。我把这三个拆成了独立的 job:
yaml
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
typecheck:
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 typecheck
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, typecheck, 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 同时跑,npm ci 这一步虽然各自都要执行,但结合了缓存后,大部分时候命中缓存,每个 job 的 install 都只需要 10 秒左右。
改完之后,lint + typecheck + test 这串流程的总耗时不再是 55s + 2m + 4m,而是 max(三者时间) = 4 分钟,省掉了约 3 分钟的串行等待。
第四步:给测试做并行拆分
测试是整个流水线里最慢的环节(4 分钟)。我们的项目有 200 多个测试用例,跑在单进程里,一个文件一个文件地顺序执行。
Vitest 原生支持 --pool=threads 和 --pool=forks 多进程并行,配置很简单:
javascript
// vitest.config.ts
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
},
},
},
});
改成多线程后,测试时间从 4 分钟降到了 1 分 40 秒。还不够。
进一步,我用了 Vitest 的 shard 功能,把测试文件分片,跑在多个独立的 job 上:
yaml
jobs:
test:
strategy:
matrix:
shard: [1/3, 2/3, 3/3] # 拆成 3 片
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest --shard=${{ matrix.shard }}
3 个 job 各跑一部分测试,最慢的那片耗时约 40 秒。测试总耗时从 4 分钟变成了 40 秒(等待最慢的那片结束)。
但这里有一个代价:GitHub Actions 的免费额度是按分钟计费的,3 个 job 并行跑,虽然时间短了,但总消耗的 CI 分钟数是原来的 3 倍。对于私有仓库需要评估额度是否够用。我们的开源项目不限额,所以无压力。
第五步:构建产物的分析和优化
npm run build 原来要 5 分钟,CRA 的 Webpack 配置在大型项目里确实慢。我做了三件事把它压下来。
5.1 把 Webpack 升级到最新版
CRA 没 eject 不能改配置,但 Webpack 5 的后续小版本在持续优化构建性能。把 react-scripts 升到最新版后,构建时间从 5 分钟降到了 4 分 10 秒。这是零成本的优化。
5.2 用 webpack-bundle-analyzer 找出大模块
bash
npm run build -- --stats
npx webpack-bundle-analyzer build/bundle-stats.json
分析发现一个图表库占了 400KB,但实际上我们只用了其中 3 个组件。改成按需导入后,打包体积降了 200KB,构建时间又少了 20 秒。
5.3 开启持久化缓存
在 CRA 里,可以通过 GENERATE_SOURCEMAP=false 跳过 SourceMap 的生成,这在 CI 里没必要(SourceMap 上传到 Sentry 会单独处理),构建时间从 3 分 50 秒降到了 2 分 30 秒。
yaml
- name: Build
run: GENERATE_SOURCEMAP=false npm run build
到这一步,build 从 5 分钟降到了 2 分 30 秒。
第六步:用 needs 和 if 减少不必要的 Job
不是每次 push 都需要跑所有检查。比如一个只改了 .md 文件的 commit,就不需要跑 typecheck 和 build。
yaml
- name: Check changed files
id: changed
run: |
if git diff --name-only HEAD^ HEAD | grep -qvE '\.(md|txt)$'; then
echo "need_build=true" >> $GITHUB_OUTPUT
fi
- name: Build
if: steps.changed.outputs.need_build == 'true'
run: npm run build
这个改动让文档提交的 CI 直接从 15 分钟变成了 1 分钟(只跑 lint + typecheck)。
优化效果总览
| 优化项 | 优化前 | 优化后 | 节省 |
|---|---|---|---|
npm ci(缓存命中) |
3m 20s | 10s | -3m 10s |
| Lint + TypeCheck + Test(并行) | 7m 10s | 1m 40s | -5m 30s |
| Test(多进程 + 分片) | 4m 10s | 40s | -3m 30s |
| Build(升级 + 无 SourceMap) | 5m | 2m 30s | -2m 30s |
| 跳过非必要 Job(文档提交) | 15m | 1m | -14m |
| 总计(日常代码 push) | ~15m | ~2m | -87% |
现在 push 代码之后,起身倒杯水,还没走回工位就看到 GitHub 的 Slack 通知:✅ CI passed。
还能继续压吗?
可以,但性价比低了。比如:
- 用
turborepo做增量构建:对于 monorepo 效果显著,但我们的单体应用收益有限。 - 换成 Vite 打包:速度确实快很多,但迁移成本高,短时间内不会做。
- 用更大的 Runner:GitHub 提供的 Linux 是 2 核的,换成 4 核或 8 核能继续压时间,但需要付费。
2 分钟是目前零成本能达到的最优平衡点,再往下就需要时间和金钱的额外投入了。
可复用的优化检查清单
如果也想优化自己的 CI,可以从下面几个方向逐个试:
- 打印每个 step 的精确耗时,找到瓶颈
- 依赖安装加缓存(
actions/cache) - Lint / TypeCheck / Test 拆成并行 job
- 测试开启多进程 + shard 分片
- 分析打包产物,按需导入大库
- 跳过 SourceMap 生成(CI 里不需要)
- 标记文件变更类型,跳过不必要 job
- 对比多次运行的耗时,确认优化稳定
你项目的 CI 跑一次要多久?做过哪些优化?欢迎评论区交流。