陌上人如玉
公子世无双

10.14 构建性能与 CI 加速

10.14 构建性能与 CI 加速

一句话:构建与 CI 的加速只有三条路——「少做(缓存命中就不干活)、做快(换更快的工具、把类型检查拿出来)、并行(拆 job、分片、并行任务图)」;而这三条的前提都是「先量出来慢在哪」——不测量的优化基本等于乱猜。

一、先量:把「慢」变成数字

⭐⭐⭐ 构建慢的五个常见成因(按出现频率):

① 【重复劳动】(最常见)
   每次构建都「重新编译所有依赖」(没有持久化缓存)
   ⭐ 症状:CI 上「pnpm install + build」占了 80% 时间,但代码只改了一个文件

② 【串行的重型任务】
   · 类型检查(`tsc`)与打包串行跑(其实可以并行)
   · 1500 个模块全部走 `babel-loader`(而它只该处理 src)
   · source map 用了最贵的配置

③ 【模块图过大】
   · 从 barrel 文件(`index.ts` 里 re-export 一切)导入 → 拉进整棵依赖树
   · 依赖没有「按需引入」(见 5.14)
   · `transpilePackages` 把整个 node_modules 也编译了

④ 【工具本身慢】
   · webpack + babel + ts-loader(纯 JS 工具链)
   · ⭐ 对比:esbuild/swc/Rspack(Rust/Go 实现)通常快 5~20 倍

⑤ 【环境问题】
   · Docker 没有层缓存 → 每次都重装依赖
   · CI 机器配置低(2 核跑 4 个并行任务 → 互相拖慢)
   · 缓存「命中率低」(key 设计不当,每次都 miss)
# ✅ 三个「先测量」的手段
# ① Vite:看「预构建」与「转换」各花了多久
vite build --debug                       # 输出 debug 日志(含各阶段耗时)
# 或:vite --profile  → 会生成一个 vite-profile-*.json,可在 speedscope.app 打开火焰图

# ② webpack:生成 stats(含每个模块/loader 的耗时)
webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json   # 或直接看 stats 的 timing

# ③ 通用:给「命令」计时(最土但最有效)
time pnpm install
time pnpm typecheck
time pnpm build
time pnpm test
# ✅ 一个非常实用的检查:构建到底处理了多少模块?
# Vite
vite build --debug 2>&1 | grep -i "modules transformed"
# 输出示例:✓ 1832 modules transformed.  ← ⭐ 如果这个数字「远超你写的文件数」,
#                                          说明「依赖被打包器处理了」(见下文「减少工作」)

二、工具选型:先看清「快在哪」

工具 实现 开发(冷启动/HMR) 生产构建 说明
webpack 5 JS 慢(需打包全部) 慢 生态最全、可定制性最高
⭐ Vite Go(esbuild)+ Rollup ⭐⭐ 极快(原生 ESM 按需转换) 中(Rollup) 开发体验最好
Rspack Rust ⭐⭐ 快 ⭐⭐ 快 webpack 兼容 API(迁移成本低)
Turbopack Rust ⭐⭐ 快 ⭐(逐步成熟) Next.js 的新引擎
esbuild Go ⭐⭐⭐ 最快 ⭐⭐ 快 但 CSS/代码分割/插件能力有限
⭐⭐⭐ 「Vite 开发为什么快」——这是最值得理解的机制(面试常问):

   【webpack dev server 的做法】
     启动时「把整个应用打包一遍」(构建完整模块图)→ 才启动服务
     ⭐ 所以「项目越大,冷启动越慢」(可能几十秒到几分钟)
     改动时「增量重建受影响的 chunk」→ 项目大了也慢

   【Vite 的做法(两段式)】
     ① 【依赖预构建】用 esbuild 把 `node_modules` 的依赖「预打包成 ESM」
        (⭐ 只做一次,结果缓存在 `node_modules/.vite`)
        为什么需要:① 依赖常常是 CJS(浏览器不认);② 一个包可能有很多小文件
        (如 lodash-es 有 600+ 文件 → 浏览器发 600 个请求)
     ② 【源码按需转换】源码**不打包**,服务端直接以「原生 ESM」提供;
        浏览器请求哪个模块,Vite 才转换哪个(esbuild 单文件转换,毫秒级)
     ⭐ 所以「冷启动 ≈ 依赖预构建时间」(与「源码量」几乎无关)

   ⚠️ 代价与注意:
     · 生产构建仍用 **Rollup**(esbuild 的 CSS/代码分割/插件不够成熟)
     · 「源码按需转换」意味着「页面首次进入时」才转换那部分模块
       → ⭐ 所以「大依赖(如 monaco)」要放 `optimizeDeps.exclude` 或改成懒加载
     · 依赖预构建的「触发条件」要理解:
       新依赖 / lock 变化 / `optimizeDeps.include` 变化 / 手动 `--force`
// ✅ Vite 的「预构建」调优(解决「首次进入某页面很慢」)
export default {
  optimizeDeps: {
    // ⭐ 提前预构建「动态导入的大依赖」(否则它会在「用户打开那个页面时」才被处理)
    include: [
      'react', 'react-dom', 'react-dom/client',
      'monaco-editor/esm/vs/editor/editor.api',      // 提前处理
      'echarts/core',
    ],
    // ⭐ 排除「本来就很大、且不想预构建」的(避免「一次预构建几百 MB」)
    exclude: ['@my/large-lib-that-ships-esm'],
  },
};

三、加速手段:五个杠杆

⭐⭐⭐ 杠杆 ①【缓存(收益最大)】

   【webpack 5 的文件系统缓存】
     cache: { type: 'filesystem', buildDependencies: { config: [__filename] } }
     ⭐ 二次构建能快 5~10 倍(因为「模块的转换结果」被持久化了)

   【Vite】
     · `node_modules/.vite`(依赖预构建缓存)→ ⭐ **CI 里要缓存这个目录**
     · `cacheDir` 可自定义位置(便于缓存)

   【构建产物缓存(Turbo / Nx)】
     ⭐ 见 10.9:「输入哈希不变 → 直接还原产物,不执行命令」
     + **远程缓存**(跨机器/跨 CI 复用)—— 这是 CI 加速的最大杠杆

   ⚠️ 缓存的「正确性」比「命中率」更重要:
     · `outputs` 漏声明 → 缓存命中时「产物缺失」(见 10.9)
     · 缓存 key 不含「环境变量/配置」→ 「改了配置却命中旧缓存」
     ⭐ 判据:「**任何会影响产物的输入,都必须进哈希**」

⭐⭐⭐ 杠杆 ②【减少工作】

   a. 【别编译 node_modules】
      · webpack: `babel-loader` 的 `exclude: /node_modules/`
      · ⭐ 但「需要编译的依赖」要显式包含(如 `@my/ui` 发的是 ESM+TS 源码时)
      · Vite/Next: `transpilePackages: ['@my/ui']`(只列需要的)
      ⚠️ 反例:`transpilePackages` 列了 20 个包 → 编译量翻倍

   b. 【提升 target(少转译)】
      · `build.target: 'es2022'`(而不是 `es2015`)→ ⭐ 少一堆语法降级
      · 用 `browserslist` 明确支持范围(别「为 IE11 转译」)
      · ⭐ 但要注意「运行时 API 的 polyfill」仍需要(语法 ≠ API)

   c. 【类型检查与打包分离】⭐
      · 打包时**不做类型检查**(esbuild/swc 只「剥掉类型」,不检查)
      · 类型检查**单独跑**(`tsc --noEmit`,可以并行 + 只在「类型相关文件」变化时跑)
      · webpack 生态里用 `fork-ts-checker-webpack-plugin`(放到独立进程并行)
      · ⭐ 收益:Vite/SWC 项目「构建」能快 30%~50%,且类型检查能被缓存

   d. 【减少 source map 的开销】
      | devtool / sourcemap | 生成速度 | 质量 |
      | --- | --- | --- |
      | `false` | ⭐⭐⭐ | 无 |
      | `eval` | ⭐⭐ 快 | 「每模块 eval」,行号不准 |
      | ⭐ `eval-cheap-module-source-map` | ⭐ 较快 | 开发环境推荐 |
      | `source-map` | 慢 | 最准(生产用,但**只在需要时**) |
      ⭐ 建议:开发用 `eval-cheap-module-source-map`;生产 **`hidden-source-map`**
        (生成但不上传公开目录,只传给监控平台)

   e. 【依赖预构建的取舍】
      · `optimizeDeps.include`(提前处理)vs `exclude`(不处理)
      · ⚠️ 「预构建太多」→ 冷启动变慢;「不预构建」→ 首次进入页面卡

⭐⭐⭐ 杠杆 ③【并行/并行化】

   · 【多进程】:`thread-loader` / `esbuild-loader` / `swc-loader`(把转换放 worker)
   · 【并行任务】:Turbo/Nx 的「任务图」会并行跑「互不依赖」的任务
   · 【CI 里拆 job】:lint / typecheck / build / test 拆成多个 job **并行**
     ⭐ 但要注意「每个 job 都要重新 install」(用缓存缓解)——
       所以「拆太多」也可能「总时间更长」(权衡)
   · 【测试分片】:`--shard=1/4`(vitest / Playwright 都支持)
     ⭐ 4 个 shard 并行 → 测试时间约降到 1/4(如果测试可独立运行)

⭐⭐⭐ 杠杆 ④【换工具】
   · babel-loader → **esbuild-loader / swc-loader**(快 10~20 倍)
   · ts-loader → **esbuild / swc**(剥离类型)
   · terser → **esbuild**(压缩快 10~100 倍,体积大 2~5%,见 5.14)
   · webpack → **Rspack**(快 5~10 倍,API 兼容)
   ⚠️ 代价:生态/插件兼容性、边缘行为差异(要跑完整测试验证)

⭐⭐⭐ 杠杆 ⑤【调整工程结构】
   · ⭐ **避免 barrel 文件**(`index.ts` re-export 一切)
     → 「从 barrel 导入一个函数」会「拉进整棵依赖树」(模块图爆炸)
     ✅ 直接指向具体文件,或用「按目录分出口」
   · 拆分「巨型入口」(一个 entry 引用了全站)
   · 把「很少变的东西」与「经常变的东西」分开(⭐ 缓存友好)
// ✅ 一份「Vite 加速」的配置示例
export default defineConfig({
  build: {
    target: 'es2022',                       // ⭐ 少转译
    sourcemap: 'hidden',                    // ⭐ 生产:生成但不公开引用
    // ⭐ 压缩用 esbuild(快);要极致体积再换 terser(见 5.14)
    minify: 'esbuild',
    // ⭐ 关掉「报告 gzip 大小」的重复计算(CI 里可以只跑一次)
    reportCompressedSize: false,
  },
  optimizeDeps: {
    include: ['react', 'react-dom', 'react-dom/client'],
  },
  esbuild: {
    // ⭐ 生产环境「剔除」纯函数调用(替代「babel-plugin-lodash」之类)
    pure: ['console.log', 'console.debug'],
    legalComments: 'none',
  },
  server: {
    // ⭐ 预热「高频访问」的文件(减少首次请求的转换等待)
    warmup: {
      clientFiles: ['./src/main.tsx', './src/App.tsx', './src/routes/**/*.tsx'],
    },
  },
});
// ✅ webpack 的「文件系统缓存 + 类型检查分离」
{
  "cache": {
    "type": "filesystem",
    "//": "⭐ 缓存位置(CI 里缓存这个目录)",
    "cacheDirectory": "node_modules/.cache/webpack",
    "buildDependencies": { "config": ["webpack.config.js", "tsconfig.json"] }
  },
  "module": {
    "rules": [
      {
        "test": /\.[jt]sx?$/,
        "//": "⭐ 只处理 src,别编译 node_modules(除非明确需要)",
        "exclude": /node_modules/,
        "use": [{ "loader": "esbuild-loader", "options": { "target": "es2022" } }]
      }
    ]
  },
  "plugins": [
    "// ⭐ 类型检查:放到独立进程并行(不阻塞打包)",
    "ForkTsCheckerWebpackPlugin"
  ]
}

四、CI 加速:完整清单(按收益排序)

⭐⭐⭐ CI 加速的九条(按「真实收益」排序):

① 【构建产物缓存 / 远程缓存】⭐⭐⭐ 收益最大
   Turbo 的远程缓存(或 Nx Cloud)→ 「同事/上次 CI 构建过的,直接命中」
   ⭐ 典型效果:10 分钟 → 1~2 分钟

② 【只跑受影响的包】⭐⭐⭐(monorepo 必备)
   `turbo run build test --filter='[origin/main]'`(见 10.9)

③ 【依赖安装缓存】⭐⭐
   · `actions/setup-node` 的 `cache: pnpm`(缓存 pnpm store)
   · ⭐ `pnpm install --frozen-lockfile`(保证可复现 + 不做多余解析)
   · pnpm 的内容寻址存储 + 硬链接 → 安装本身就快

④ 【构建器自身的缓存】⭐⭐
   缓存 `node_modules/.vite`、`node_modules/.cache`、`.next/cache`

⑤ 【拆 job 并行】⭐⭐
   lint / typecheck / build / test 拆成独立 job(但要权衡「各自 install」的成本)
   ⭐ 更优:只拆「耗时最长的那一个」(如 test)

⑥ 【测试分片】⭐⭐
   `vitest --shard=1/4` / `playwright --shard=1/4`
   ⭐ 4 个 runner 并行 → 时间降到 1/4(前提:测试之间独立)

⑦ 【缓存「测试环境的二进制」】⭐
   Playwright 的浏览器:缓存 `~/.cache/ms-playwright`(否则每次下 300MB)
   Docker:BuildKit 的 `cache-from: type=gha`

⑧ 【机器规格】⭐
   提升 runner 的 CPU/内存(⭐ 「并行任务多」时,2 核机器反而更慢)

⑨ 【减少「每次都跑」的检查】⭐
   · 只在「相关文件变化」时才跑(如「只有 .md 变化 → 跳过构建」)
   · `paths` 过滤(GitHub Actions 的 `on.push.paths`)
# ✅ 一份「加速到位」的 CI 配置(GitHub Actions)
name: CI
on:
  push: { branches: [main] }
  pull_request:

# ⭐ 同一个分支的新 push「取消旧的运行」(省机器 + 快速反馈)
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  # ⭐ 关闭「不必要的输出/遥测」,减少 IO 与噪声
  TURBO_TELEMETRY_DISABLED: 1
  NEXT_TELEMETRY_DISABLED: 1

jobs:
  # ⭐ 任务 1:安装依赖(其它 job 通过 artifact 或直接各自缓存复用它)
  setup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }                     # ⭐ 需要完整历史(算变更范围)
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm                                 # ⭐ 缓存 pnpm store
      - run: pnpm install --frozen-lockfile
      # ⭐ 缓存「构建器自身的缓存」(Vite 依赖预构建)
      - uses: actions/cache@v4
        with:
          path: |
            node_modules/.vite
            node_modules/.cache
            .turbo
          key: build-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          restore-keys: |
            build-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            build-cache-${{ runner.os }}-

  # ⭐ 任务 2~5:并行跑(⭐ 只跑受影响的包)
  quality:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: actions/cache@v4
        with:
          path: .turbo
          key: turbo-${{ runner.os }}-${{ github.sha }}
          restore-keys: turbo-${{ runner.os }}-
      # ⭐⭐ 关键:只跑「相对 base 分支发生变化」的包
      - run: pnpm turbo run lint typecheck --filter='[origin/${{ github.base_ref || 'main' }}]'
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}      # ⭐ 远程缓存
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

  test:
    needs: setup
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]                            # ⭐⭐ 测试分片(4 路并行)
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      # ⭐ 缓存 Playwright 浏览器(否则每次下载几百 MB)
      - uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
      # ⭐ 只装需要的浏览器
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec vitest run --shard=${{ matrix.shard }}/4
      - run: pnpm exec playwright test --shard=${{ matrix.shard }}/4

  # ⭐ 构建(单独 job,便于「只在需要时跑」)
  build:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: actions/cache@v4
        with:
          path: |
            .turbo
            node_modules/.vite
          key: build-${{ runner.os }}-${{ github.sha }}
          restore-keys: build-${{ runner.os }}-
      - run: pnpm turbo run build --filter='[origin/${{ github.base_ref || 'main' }}]'
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: apps/*/dist }

五、完整实现:一个「构建耗时画像 + 基线对比」脚本(220 行)

// scripts/build-profile.mjs —— 测量各阶段耗时,并与基线对比(防止「悄悄变慢」)
// 用法:node scripts/build-profile.mjs [--update-baseline]
//
// ⭐ 设计目标:
//   ① 「一次跑完所有阶段」并输出表格(不用手动 time 四次)
//   ② 与「上次的基线」对比 → 报告「哪个阶段变慢了」
//   ③ 支持写入 CI 的 step summary(GitHub Actions 的 $GITHUB_STEP_SUMMARY)

import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';

const BASELINE_PATH = '.build-baseline.json';
const updateBaseline = process.argv.includes('--update-baseline');

/** ============ 1. 阶段定义(可按项目调整) ============ */
const STAGES = [
  { name: 'install',    cmd: 'pnpm', args: ['install', '--frozen-lockfile'], skip: true },
  { name: 'lint',       cmd: 'pnpm', args: ['turbo', 'run', 'lint'] },
  { name: 'typecheck',  cmd: 'pnpm', args: ['turbo', 'run', 'typecheck'] },
  { name: 'build',      cmd: 'pnpm', args: ['turbo', 'run', 'build'] },
  { name: 'test',       cmd: 'pnpm', args: ['turbo', 'run', 'test'] },
];

/** ============ 2. 跑一个命令并计时 ============ */
function run(cmd, args, { capture = false } = {}) {
  return new Promise((resolve) => {
    const start = performance.now();
    const child = spawn(cmd, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', shell: false });

    let output = '';
    if (capture) {
      child.stdout?.on('data', (d) => { output += d; });
      child.stderr?.on('data', (d) => { output += d; });
    }

    child.on('close', (code) => {
      resolve({ code: code ?? 1, durationMs: performance.now() - start, output });
    });
    child.on('error', (err) => {
      resolve({ code: 1, durationMs: performance.now() - start, output: String(err) });
    });
  });
}

/** ============ 3. 逐阶段执行 ============ */
async function profile() {
  const results = [];

  for (const stage of STAGES) {
    if (stage.skip && !process.env.CI) {
      // ⭐ 本地默认跳过 install(太慢且通常已装好);CI 里跑
      results.push({ name: stage.name, durationMs: 0, code: 0, skipped: true });
      continue;
    }

    process.stdout.write(`\n▶ ${stage.name} … `);
    const r = await run(stage.cmd, stage.args);
    const secs = (r.durationMs / 1000).toFixed(1);
    console.log(`${r.code === 0 ? '✅' : '❌'} ${secs}s`);

    results.push({ name: stage.name, durationMs: Math.round(r.durationMs), code: r.code, skipped: false });

    // ⭐ 一旦某个阶段失败就停止(避免「后面的阶段因为缺少产物而报奇怪错」)
    if (r.code !== 0) {
      console.log(`\n⚠️ 阶段「${stage.name}」失败,停止后续阶段`);
      break;
    }
  }

  return results;
}

/** ============ 4. 与基线对比 ============ */
function compareWithBaseline(results) {
  if (!existsSync(BASELINE_PATH)) return { rows: results.map((r) => ({ ...r, delta: null })), baselineMissing: true };

  let baseline;
  try { baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); }
  catch { return { rows: results.map((r) => ({ ...r, delta: null })), baselineMissing: true }; }

  const baseByName = new Map((baseline.stages ?? []).map((s) => [s.name, s.durationMs]));

  const rows = results.map((r) => {
    const base = baseByName.get(r.name);
    if (base === undefined || r.skipped) return { ...r, delta: null };
    return { ...r, delta: r.durationMs - base, base };
  });

  return { rows, baselineMissing: false, baselineTotal: baseline.totalMs };
}

/** ============ 5. 输出报告 ============ */
function formatReport(rows, baselineMissing, baselineTotal) {
  const total = rows.reduce((sum, r) => sum + r.durationMs, 0);
  const fmt = (ms) => (ms / 1000).toFixed(1) + 's';

  const width = Math.max(...rows.map((r) => r.name.length), 12);
  const pad = (s, n) => String(s).padEnd(n);

  const lines = [];
  lines.push('');
  lines.push('┌─ 构建阶段耗时 ────────────────────────────────');
  lines.push(`│ ${pad('阶段', width)}  ${pad('耗时', 10)}  ${pad('变化', 12)}`);
  lines.push('├───────────────────────────────────────────────');

  for (const r of rows) {
    if (r.skipped) {
      lines.push(`│ ${pad(r.name, width)}  ${pad('(跳过)', 10)}  ${pad('-', 12)}`);
      continue;
    }

    let deltaText = '-';
    if (r.delta !== null) {
      const sign = r.delta >= 0 ? '+' : '';
      const pct = r.base > 0 ? ((r.delta / r.base) * 100).toFixed(0) : '0';
      // ⭐ 用「符号」让「变慢」一眼可见(CI 日志里没有颜色)
      const flag = r.delta > 5000 ? ' ⚠️' : r.delta < -5000 ? ' 🎉' : '';
      deltaText = `${sign}${fmt(r.delta)} (${sign}${pct}%)${flag}`;
    }

    lines.push(`│ ${pad(r.name, width)}  ${pad(fmt(r.durationMs), 10)}  ${pad(deltaText, 12)}`);
  }

  lines.push('├───────────────────────────────────────────────');
  lines.push(`│ ${pad('合计', width)}  ${pad(fmt(total), 10)}  ${
    baselineTotal !== undefined ? pad(`${total >= baselineTotal ? '+' : ''}${fmt(total - baselineTotal)}`, 12) : pad('-', 12)
  }`);
  lines.push('└───────────────────────────────────────────────');

  if (baselineMissing) {
    lines.push('');
    lines.push('ℹ️ 还没有基线。跑 `node scripts/build-profile.mjs --update-baseline` 记录当前耗时。');
  }

  return { text: lines.join('\n'), total };
}

/** ============ 6. 主流程 ============ */
process.env.CI = process.env.CI ?? '';

const results = await profile();
const { rows, baselineMissing, baselineTotal } = compareWithBaseline(results);
const { text, total } = formatReport(rows, baselineMissing, baselineTotal);

console.log(text);

// ⭐ 写入 GitHub Actions 的 step summary(PR 里能看到)
if (process.env.GITHUB_STEP_SUMMARY) {
  appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n## 构建耗时\n\n\`\`\`\n${text}\n\`\`\`\n`);
}

// ⭐ 更新基线
if (updateBaseline) {
  writeFileSync(
    BASELINE_PATH,
    JSON.stringify(
      { totalMs: total, stages: results.filter((r) => !r.skipped), updatedAt: new Date().toISOString() },
      null,
      2
    ) + '\n',
    'utf8'
  );
  console.log(`\n✅ 已更新基线 ${BASELINE_PATH}`);
}

// ⭐ 退出码反映「是否有阶段失败」
process.exit(results.some((r) => r.code !== 0) ? 1 : 0);
// ✅ 用法(package.json 里加脚本)
{
  "scripts": {
    "profile": "node scripts/build-profile.mjs",
    "profile:baseline": "node scripts/build-profile.mjs --update-baseline"
  }
}
⭐⭐⭐ 这个脚本解决三个「工程化」问题:

① 【把「构建慢」变成「可比较的数字」】
   人脑对「构建好慢」没有量级概念;对「build +18.2s(+35%)⚠️」立刻有反应。

② 【防止「悄悄变慢」】
   ⭐ 真实场景:三个月里每个人加了一点东西,构建从 40s 变成 3 分钟,没人发现。
   有了基线对比 → 「谁把构建拖慢了」在 PR 里就能看到。

③ 【PR 里可见】
   写进 `$GITHUB_STEP_SUMMARY` → 每个 PR 都有「构建耗时表」→ 团队自然会注意。
⭐⭐ 「基线」怎么用才有效(三条纪律):

   ① 基线要「在稳定的机器上生成」(CI runner 规格一致;本地机器变化大)
   ② ⭐ 对比要看「同一阶段」,不要只看「总时间」
      (总时间可能因为「某个阶段被跳过」而看起来变快了)
   ③ ⭐ 不要「因为慢了几百毫秒就去优化」——
      关注「单个阶段 > 20% 的劣化」或「绝对值明显不合理的阶段」

六、本篇特有的坑

① 不测量就优化(改了配置发现「更慢了」)
   ✅ 先 `--profile` / `time` / `--debug`,找到「占比最大」的阶段

② 缓存 key 里不含「配置文件」
   → 改了构建配置却命中旧缓存(⭐ 症状:「改了配置没生效」)
   ✅ key 里带上 `hashFiles('tsconfig.json', 'vite.config.ts', 'pnpm-lock.yaml')`

③ 缓存了 `node_modules`(体积大、跨版本容易坏)
   ✅ 缓存「pnpm store」(更小更安全)或用 `setup-node` 的 `cache: pnpm`

④ 缓存「无限增长」→ 满了之后「每次都 miss」(还可能被平台清理)
   ⭐ 症状:缓存命中率忽高忽低
   ✅ 用「内容哈希 + restore-keys」分层;定期清理

⑤ `actions/cache` 的 key 用了 `github.sha`(每次都 miss)
   key: cache-${{ github.sha }}                     // ❌ 每次都新 key
   ✅ `hashFiles(...)` 作为主 key + `restore-keys` 兜底

⑥ Babel 编译了 `node_modules`(构建时间翻倍)
   ✅ `exclude: /node_modules/`(需要时再显式 include 某个包)

⑦ `transpilePackages` 列太多(把整个 monorepo 都编译了)
   ✅ 只列「真的发 TS/ESM 源码」的包;其它包「先在它自己里构建好」

⑧ 类型检查与打包串行(明明可以并行)
   ✅ Vite/SWC:类型检查「单独跑」(`tsc --noEmit`);
      webpack:用 `fork-ts-checker-webpack-plugin`

⑨ 类型检查「每次全量跑」(5 分钟)
   ✅ 用 `tsc --build`(增量)+ project references(见 10.9);
      或让 CI 缓存 `*.tsbuildinfo`

⑩ 开发环境用了「生产级 source map」
   devtool: 'source-map'                            // ⚠️ 开发环境很慢
   ✅ 开发用 `eval-cheap-module-source-map`;生产用 `hidden-source-map`

⑪ 生产构建「还生成 source map 并部署到公开目录」
   ⚠️ 体积 + **源码泄露**
   ✅ `hidden-source-map` + 只上传监控平台(见 5.14)

⑫ 从 barrel 文件(`index.ts`)导入(模块图爆炸)
   ⭐ 症状:构建处理了 5000 个模块,但你只写了 200 个文件
   ✅ 直接指向具体文件;或把 barrel 限制在「稳定的小集合」

⑬ `optimizeDeps.include` 里放太多(Vite 冷启动变慢)
   ✅ 只放「高频且需要预构建」的(React 全家桶 + 大型 ESM 库)

⑭ `optimizeDeps` 没排除「已经很大的 ESM 库」
   ⚠️ 会导致「首次进入页面」时「一次性处理几百个文件」(卡几秒)
   ✅ `exclude` 它,或改成「动态导入 + 懒加载」

⑮ CI 里「每个 job 都重新 install」(拆 job 拆过头)
   ⚠️ 4 个 job × 40s 安装 = 160s,可能比「串行做所有事」还慢
   ⭐ ✅ 权衡:拆「耗时最长」的那个;或把 install 结果「打包成 artifact」

⑯ 测试没有分片(一个 job 跑 20 分钟)
   ✅ `--shard=1/4`(⭐ 前提:测试之间「独立」,不共享状态)

⑰ Playwright 浏览器每次都下载(几百 MB)
   ✅ 缓存 `~/.cache/ms-playwright` + 只 `install chromium`(不装全部)

⑱ Docker 构建没有层缓存(每次都重装依赖)
   ✅ 先 `COPY package.json pnpm-lock.yaml` → `RUN install` → 再 `COPY .`(⭐ 顺序决定缓存命中)
   ✅ `cache-from: type=gha`(BuildKit 的 CI 缓存)

⑲ 用了 esbuild/swc 替换 babel,但没跑完整测试(行为差异)
   ⚠️ 典型差异:装饰器、`useDefineForClassFields`、某些 proposal 语义
   ✅ 换了之后「跑一遍完整测试 + 对比产物体积」

⑳ 只看「构建时间」不看「HMR 时间」(开发体验同样重要)
   ⭐ 开发者每天改 100 次代码,HMR 每慢 200ms = 每天多等 20 秒
   ✅ 关注:「HMR 的响应时间」与「页面刷新时间」
⭐ ⑫ 的「诊断命令」(快速定位「模块图为什么这么大」)
   # Vite:看「转译了多少模块」
   vite build --debug 2>&1 | grep -i "modules transformed"
   # webpack:生成 stats 后按「依赖数量」排序
   npx webpack --profile --json > stats.json
   npx webpack-bundle-analyzer stats.json
   ⭐ 如果「transformed 数量」远大于「你写的文件数」→ 几乎一定是 barrel 或
     「误编译了 node_modules」
# ⑱ 的完整处理(Docker 层缓存友好)
# ⭐ 关键:把「变化频率低」的步骤放前面
FROM node:20-slim AS base
RUN corepack enable

FROM base AS deps
WORKDIR /app
# ⭐ 只复制「依赖声明」(这两行不变 → 这一层就能命中缓存)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/*/package.json ./packages-tmp/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

FROM deps AS build
WORKDIR /app
# ⭐ 再复制源码(源码变化只影响「后面这些层」)
COPY . .
RUN pnpm turbo run build --filter=@acme/web...

FROM base AS runner
WORKDIR /app
COPY --from=build /app/apps/web/dist ./dist
CMD ["node", "server.js"]

七、面试延伸

  1. 「Vite 为什么比 webpack 快?」
    核心是「开发环境不打包」的两段式设计:① ⭐ 依赖预构建——用 esbuild(Go 实现)把 node_modules 的依赖预打包成 ESM(解决「依赖是 CJS」与「一个包几百个小文件」两个问题),只做一次并缓存在 node_modules/.vite;② ⭐ 源码按需转换——源码不打包,由 dev server 以「原生 ESM」提供,浏览器请求哪个模块,Vite 才用 esbuild 转换哪个(单文件转换是毫秒级)。所以 webpack 是「启动时构建完整模块图」(越大越慢),Vite 是「冷启动 ≈ 依赖预构建时间」(与源码量几乎无关)。⚠️ 但「生产构建」Vite 仍用 Rollup(因为 esbuild 的 CSS 处理/代码分割/插件生态不够成熟)。

  2. 「构建慢怎么排查?」
    三步:① ⭐ 先量——vite build --debug(看各阶段与 modules transformed 数量)、webpack --profile --json + webpack-bundle-analyzer(看每个模块/loader 的耗时)、最土但最有效的 time 分阶段计时;② 找「占比最大」的那一类——是「重复劳动」(没缓存)、「串行的重活」(类型检查与打包串行)、「模块图过大」(barrel 文件/误编译 node_modules)、还是「工具本身慢」(babel/ts-loader);③ ⭐ 对号入座:重复劳动 → 开缓存;串行 → 拆开并行;模块图 → 去掉 barrel、exclude: /node_modules/;工具 → 换 esbuild/swc/Rspack。判据:如果 modules transformed 远超你写的文件数,几乎一定是「barrel 文件」或「误编译了依赖」。

  3. 「构建加速有哪些手段?」
    五个杠杆:① ⭐ 缓存(收益最大)——webpack 的 filesystem 缓存、Vite 的 node_modules/.vite、Turbo/Nx 的产物缓存 + 远程缓存(⚠️ 但「命中率」不如「正确性」重要:outputs 漏声明会导致「缓存命中时产物缺失」);② ⭐ 减少工作——别编译 node_modules、提升 target(少转译)、类型检查与打包分离(esbuild/swc 只剥类型,tsc --noEmit 单独并行跑)、开发用 eval-cheap-module-source-map;③ ⭐ 并行——多进程 loader(thread-loader)、Turbo 的任务图并行、CI 拆 job、测试分片;④ 换工具——babel→esbuild/swc(快 10~20 倍)、webpack→Rspack(快 5~10 倍);⑤ 调整结构——去掉 barrel 文件、拆巨型入口。

  4. 「为什么推荐『类型检查与打包分离』?」
    因为两者的目标不同:打包器只需要「剥掉类型」(esbuild/swc 做得极快,且不做类型检查);类型检查需要「完整的类型信息 + 整个项目」(tsc 慢)。如果让 ts-loader 在打包时做类型检查,就会「把最慢的任务放在关键路径上」,而且「改一个文件就要重新检查」。分离之后:① 打包快 30%~50%;② 类型检查可以并行跑(独立进程/独立 job);③ 类型检查可以被缓存(tsc --build 的 *.tsbuildinfo、project references);④ ⭐ 「类型错误」与「构建产物」解耦——CI 里可以「先出产物」再「报类型错误」(各有取舍)。webpack 生态对应的方案是 fork-ts-checker-webpack-plugin。

  5. 「CI 怎么提速?」
    按收益排序:① ⭐⭐ 构建产物缓存 / 远程缓存(Turbo Remote Cache、Nx Cloud)——「构建过的直接命中」,典型效果 10 分钟 → 1~2 分钟;② ⭐⭐ 只跑受影响的包(turbo run ... --filter='[origin/main]',需要 fetch-depth: 0);③ ⭐ 依赖安装缓存(setup-node 的 cache: pnpm + --frozen-lockfile);④ 构建器自身缓存(node_modules/.vite、.next/cache);⑤ ⭐ 测试分片(--shard=1/4);⑥ 拆 job 并行(⚠️ 但要权衡「每个 job 都要 install」的成本——拆过头反而更慢);⑦ 缓存测试环境二进制(Playwright 浏览器、Docker 层);⑧ 机器规格(并行任务多时,2 核会更慢);⑨ 减少每次都跑的检查(paths 过滤、文档改动跳过构建)。

  6. 「actions/cache 的 key 怎么设计?」
    核心是「用「内容哈希」当主 key + 用 restore-keys 做「模糊回退」」:① ❌ 不要用 github.sha 当 key(每次 push 都是新 key → 永远 miss);② ✅ 主 key 用 hashFiles('pnpm-lock.yaml')(依赖变了才失效)或 hashFiles('src/**')(源码变了才失效);③ ✅ restore-keys 写「更宽松的前缀」(如 build-${os}-),这样「主 key miss 时能拿到「上一次的缓存」做增量」;④ ⭐ 注意「缓存会无限增长」(平台的缓存配额可能被占满 → 之后一直 miss)→ 定期清理或让 key 有自然的轮换。还有一个关键取舍:⭐ 不要缓存 node_modules(体积大、跨 Node/包管理器版本容易坏),而是缓存「包管理器的 store」(setup-node 的 cache: pnpm 已经做了)或「构建器的缓存目录」。

  7. 「怎么防止『构建悄悄变慢』?」
    ⭐ 把它变成「可见的数字」并在 PR 里呈现:① 写一个「阶段计时脚本」——依次跑 install/lint/typecheck/build/test,输出「各阶段耗时 + 与基线对比」的表格;② ⭐ 维护一个基线文件(.build-baseline.json,在规格一致的 CI 机器上生成);③ 写进 $GITHUB_STEP_SUMMARY(这样每个 PR 都能看到「构建耗时表」,变慢会被立刻注意到);④ 设阈值(如「单个阶段劣化 > 20% 就评论提醒」,但 ⚠️ 不要因为慢了几百毫秒就优化)。另外:关注「同一阶段」的对比(总时间可能因为「某阶段被跳过」而看起来变快)、以及「HMR 响应时间」(开发者每天改 100 次,HMR 每慢 200ms 就是每天多等 20 秒)。

  8. 「esbuild / swc 替换 babel 有什么风险?」
    四点:① ⭐ 行为差异——装饰器语义(legacy vs stage-3)、useDefineForClassFields 的默认值、某些 proposal(如 classProperties 的初始化顺序)可能与 babel 不同;② 插件生态——babel 有大量插件(如 babel-plugin-import、自定义宏),esbuild/swc 不兼容 babel 插件(swc 有自己的插件体系但生态小);③ 目标降级能力——esbuild 的语法降级比 babel 保守(有些老特性它不支持降级到 ES5,⭐ 所以「要支持老浏览器」的项目要注意 target);④ 类型剥离的边界——const enum、namespace、experimentalDecorators 等在「只剥类型」模式下行为不同(isolatedModules 相关的坑,见 10.9)。验证方式:换完之后「跑一遍完整测试 + 对比产物体积 + 抽查关键页面」。

一句话速记

加速三原则是「少做(缓存)、做快(换工具/减工作)、并行」,前提是「先量」(vite build --debug 看 modules transformed、webpack --profile --json、分阶段 time);⭐ Vite 开发快的机制是「依赖预构建(esbuild,缓存到 node_modules/.vite)+ 源码按需转换(原生 ESM)」,所以「冷启动 ≈ 依赖预构建」(与源码量无关),但生产构建仍用 Rollup;五个杠杆:① 缓存(webpack filesystem、Vite .vite、Turbo 产物缓存 + 远程缓存——⚠️ outputs 漏声明会「命中却缺产物」)、② 减少工作(别编译 node_modules、提升 target、⭐ 类型检查与打包分离、开发用 eval-cheap-module-source-map、生产 hidden-source-map)、③ 并行(多进程 loader、Turbo 任务图、CI 拆 job、测试分片)、④ 换工具(babel→esbuild/swc 快 10~20 倍、webpack→Rspack)、⑤ 调整结构(⭐ 去掉 barrel 文件——modules transformed 暴增的首要嫌疑);CI 提速按收益排序:远程缓存 > 只跑受影响的包(--filter='[origin/main]' + fetch-depth: 0)> 依赖安装缓存 > 构建器缓存 > 测试分片 > 拆 job(⚠️ 拆过头更慢);⭐ actions/cache 的 key 要用 hashFiles 而不是 github.sha(否则永远 miss),并别缓存 node_modules(缓存 store);防「悄悄变慢」靠「阶段计时脚本 + 基线文件 + 写进 PR($GITHUB_STEP_SUMMARY)」,只看「同阶段的 > 20% 劣化」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.14 构建性能与 CI 加速

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫