把前端项目的 CI 构建时间从 15 分钟压到了 2 分钟

0 阅读5分钟

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

下载 (2)121212121.jpg

问题: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,把每一步的执行时间打印出来。

# 在每个 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 Node45s5%
npm ci3m 20s22%
npm run lint55s6%
npm run typecheck2m 5s14%
npm run test4m 10s28%
npm run build4m 50s25%
总计约 15m100%

大头是 npm citestbuild,这三个占了总时间的 75%。优化的重心就放在这里。

第二步:用缓存干掉 npm ci 的 3 分钟

npm ci 每次都要从 registry 下载所有包,即使 package-lock.json 没变,也要重新走一遍网络请求。

GitHub Actions 提供了官方的 cache action,可以把 node_modules 缓存起来:

- 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:

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 多进程并行,配置很简单:

// vitest.config.ts
export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
      },
    },
  },
});

改成多线程后,测试时间从 4 分钟降到了 1 分 40 秒。还不够。

进一步,我用了 Vitest 的 shard 功能,把测试文件分片,跑在多个独立的 job 上:

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 找出大模块

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 秒

- name: Build
  run: GENERATE_SOURCEMAP=false npm run build

到这一步,build 从 5 分钟降到了 2 分 30 秒。

第六步:用 needsif 减少不必要的 Job

不是每次 push 都需要跑所有检查。比如一个只改了 .md 文件的 commit,就不需要跑 typecheck 和 build。

- 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 20s10s-3m 10s
Lint + TypeCheck + Test(并行)7m 10s1m 40s-5m 30s
Test(多进程 + 分片)4m 10s40s-3m 30s
Build(升级 + 无 SourceMap)5m2m 30s-2m 30s
跳过非必要 Job(文档提交)15m1m-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 跑一次要多久?做过哪些优化?欢迎评论区交流。