前端开发··2 阅读·预计 25 分钟

JavaScript 性能预算体系:从指标定义到 CI 门禁的工程化落地

为什么你的性能优化总是治标不治本?

很多团队做性能优化的模式是这样的:项目上线后发现首屏 8 秒,紧急开一个"性能优化"迭代,技术人员对着 Lighthouse 报告一顿操作,砍了几百 KB 的 bundle,首屏降到 3 秒,皆大欢喜。两周后新的业务需求合入,首屏又悄悄回到了 6 秒。

问题出在哪里?性能没有被当作一项持续性的工程约束。我们给后端 API 定义超时 SLA,给数据库连接池设上限,给接口 QPS 设阈值——但前端 bundle 体积、首屏时间、交互延迟却常常处于"凭感觉"的灰色地带。

解决这个问题的钥匙就是 性能预算(Performance Budget)

什么是性能预算?

性能预算是一组可量化的、可自动检测的性能阈值,当构建产物或运行时指标超出预算时,构建失败或 CI 拦截。它不是"建议",而是和类型检查一样硬的工程约束。

// ❌ 主观表述
"bundle 不能太大"
"首屏要快"

// ✅ 性能预算
"入口 JS ≤ 170KB (gzip)"
"LCP ≤ 2.5s (P75)"
"TBT ≤ 200ms (P75)"
"图片总资源 ≤ 500KB"

第一步:选择正确的指标

不是所有指标都适合作为预算项。选择标准:可自动化测量 + 与业务体验强相关 + 有明确的优化手段

指标测量方式建议预算优先级
JS Bundle Size (gzip)构建时≤ 170KB★★★★★
CSS Bundle Size (gzip)构建时≤ 30KB★★★★
Total Page Weight构建时≤ 500KB★★★★
LCP (Largest Contentful Paint)运行时≤ 2.5s (P75)★★★★★
TBT (Total Blocking Time)运行时≤ 200ms (P75)★★★★★
CLS (Cumulative Layout Shift)运行时≤ 0.1 (P75)★★★★
FID / INP运行时≤ 100ms (P75)★★★

核心原则:构建时指标优先落地,因为它们 100% 可复现、不受网络波动影响,是最硬的防线。运行时指标作为第二道防线,通过 CI 阶段的 Lighthouse / Playwright 自动化采集。

第二步:制定预算基线

预算不能拍脑袋,也不能直接用 Lighthouse 的"Good"阈值(那只代表及格线,不代表你的业务承受得起)。

正确的做法:以当前生产环境的 P50-P75 作为初始预算,每季度收紧 5-10%,渐进式逼近最优值。

// budget.config.js — 集中管理的性能预算配置
module.exports = {
  bundles: [
    {
      resourceType: 'script',
      budgets: [{ size: 170 * 1024, compression: 'gzip' }],
    },
    {
      resourceType: 'style',
      budgets: [{ size: 30 * 1024, compression: 'gzip' }],
    },
    {
      resourceType: 'image',
      budgets: [{ size: 500 * 1024 }],
    },
    {
      resourceType: 'font',
      budgets: [{ size: 100 * 1024 }],
    },
  ],
  // 运行时预算 — 通过 Lighthouse CI 执行
  runtime: {
    lcp: { p75: 2500 },   // ms
    tbt:  { p75: 200 },   // ms
    cls:  { p75: 0.1 },
  },
};

第三步:构建时防线 — webpack / Vite 集成

webpack 方案:内置 Performance Hints + 自定义插件

webpack 内置的 performance 配置只能做最粗粒度的检查(单文件大小),我们需要一个自定义插件来执行完整的预算检查:

// build-budget-plugin.js
const fs = require('fs');
const path = require('path');
const { gzipSync } = require('zlib');

class BuildBudgetPlugin {
  constructor(budgets) {
    this.budgets = budgets;
  }

  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync('BuildBudgetPlugin', (compilation, callback) => {
      const errors = [];
      const { assets } = compilation;

      for (const [filename, asset] of Object.entries(assets)) {
        const ext = path.extname(filename).toLowerCase();
        const gzipSize = gzipSync(asset.source()).length;
        const rawSize = asset.size();

        // 检查 JS bundle
        if (ext === '.js' && filename.includes('app')) {
          const budget = this.budgets.find(b => b.resourceType === 'script');
          if (gzipSize > budget.budgets[0].size) {
            errors.push(
              `[预算超限] ${filename}: ${(gzipSize / 1024).toFixed(1)}KB (gzip) ` +
              `> 预算 ${(budget.budgets[0].size / 1024).toFixed(1)}KB. ` +
              `超额 ${((gzipSize - budget.budgets[0].size) / 1024).toFixed(1)}KB`
            );
          }
        }

        // 检查 CSS bundle
        if (ext === '.css') {
          const budget = this.budgets.find(b => b.resourceType === 'style');
          if (gzipSize > budget.budgets[0].size) {
            errors.push(
              `[预算超限] ${filename}: ${(gzipSize / 1024).toFixed(1)}KB (gzip) ` +
              `> 预算 ${(budget.budgets[0].size / 1024).toFixed(1)}KB`
            );
          }
        }
      }

      if (errors.length > 0) {
        compilation.errors.push(
          new Error(`\n🚨 性能预算超限,构建已中断:\n${errors.join('\n')}`)
        );
      } else {
        console.log('✅ 性能预算检查通过');
      }

      callback();
    });
  }
}

module.exports = BuildBudgetPlugin;
// webpack.config.js
const BuildBudgetPlugin = require('./build-budget-plugin');
const budgets = require('./budget.config').bundles;

module.exports = {
  // ... 其他配置
  plugins: [new BuildBudgetPlugin(budgets)],
  // 关闭 webpack 内置的简陋警告,完全由自定义插件接管
  performance: { hints: false },
};

Vite 方案:利用 closeBundle 钩子

// vite-budget-plugin.ts — Vite 插件版本
import { type Plugin, type ResolvedConfig } from 'vite';
import { gzipSync } from 'node:zlib';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';

interface BudgetRule {
  resourceType: 'script' | 'style' | 'image' | 'font';
  budgets: Array<{ size: number; compression?: 'gzip' }>;
}

function viteBudgetPlugin(budgets: BudgetRule[]): Plugin {
  return {
    name: 'vite-performance-budget',
    apply: 'build',
    enforce: 'post',

    closeBundle() {
      const outDir = 'dist'; // 简化示例,实际应从 config 读取
      const errors: string[] = [];

      function walk(dir: string) {
        for (const entry of readdirSync(dir)) {
          const fullPath = join(dir, entry);
          if (statSync(fullPath).isDirectory()) {
            walk(fullPath);
            continue;
          }

          const ext = extname(entry).toLowerCase();
          const rawSize = statSync(fullPath).size;
          const gzipSize = gzipSync(readFileSync(fullPath)).length;

          const rule =
            ext === '.js'
              ? budgets.find(b => b.resourceType === 'script')
              : ext === '.css'
                ? budgets.find(b => b.resourceType === 'style')
                : null;

          if (!rule) continue;

          const threshold = rule.budgets[0].size;
          const measured = rule.budgets[0].compression === 'gzip' ? gzipSize : rawSize;

          if (measured > threshold) {
            const label = rule.budgets[0].compression ? '(gzip)' : '';
            errors.push(
              `[超限] ${entry}: ${(measured / 1024).toFixed(1)}KB ${label} > ` +
              `预算 ${(threshold / 1024).toFixed(1)}KB`
            );
          }
        }
      }

      walk(outDir);

      if (errors.length > 0) {
        this.error(`\n🚨 性能预算检查失败:\n${errors.join('\n')}\n`);
      } else {
        console.log('✅ [vite-performance-budget] 预算检查通过');
      }
    },
  };
}

export default viteBudgetPlugin;
// vite.config.ts
import { defineConfig } from 'vite';
import viteBudgetPlugin from './plugins/vite-budget-plugin';
import budgetConfig from './budget.config';

export default defineConfig({
  plugins: [viteBudgetPlugin(budgetConfig.bundles)],
});

第四步:运行时防线 — Lighthouse CI

构建时只检查了体积。首屏渲染速度、交互延迟等运行时指标需要 Lighthouse CI 来守护。

# .github/workflows/lighthouse-budget.yml
name: Performance Budget Check
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v12
        with:
          urls: |
            http://localhost:4173/
          budgetPath: '.github/lighthouse-budget.json'
          runs: 3
          server: npm run preview
// .github/lighthouse-budget.json
{
  "resourceSizes": [
    { "resourceType": "script", "budget": 170 },
    { "resourceType": "stylesheet", "budget": 30 },
    { "resourceType": "image", "budget": 500 },
    { "resourceType": "font", "budget": 100 }
  ],
  "timings": [
    { "metric": "largest-contentful-paint", "budget": 2500 },
    { "metric": "total-blocking-time", "budget": 200 },
    { "metric": "cumulative-layout-shift", "budget": 0.1 }
  ]
}

第五步:预算治理的渐进式策略

一次性将预算设得过于激进是"政治自杀"——团队会直接绕过或关闭检查。正确的落地节奏:

阶段一(1-2周):仅告警,不阻断
  → 在 CI 中以 warn 级别输出超限信息
  → 团队建立"性能是有成本的"心智模型

阶段二(2-4周):阻断新增超限
  → PR 中不允许引入新的超限
  → 已有超限记录在 technical debt backlog 中

阶段三(4周+):全量阻断
  → 所有超限直接构建失败
  → 每季度收紧预算 5-10%
// 渐进式策略实现
class GradualBudgetPlugin extends BuildBudgetPlugin {
  constructor(budgets, { mode = 'warn' } = {}) {
    super(budgets);
    this.mode = mode; // 'warn' | 'block-new' | 'block-all'
    this.baselinePath = './.budget-baseline.json';
  }

  loadBaseline() {
    try {
      return JSON.parse(fs.readFileSync(this.baselinePath, 'utf-8'));
    } catch {
      return {};
    }
  }

  checkBudget(compilation, errors) {
    const baseline = this.loadBaseline();
    const newErrors = [];

    for (const err of errors) {
      const [bundleName] = err.split(':');
      const baselineSize = baseline[bundleName];

      if (this.mode === 'warn') {
        console.warn(`⚠️ ${err}`);
      } else if (this.mode === 'block-new' && baselineSize === undefined) {
        newErrors.push(err); // 只阻断新引入的超限
      } else if (this.mode === 'block-all') {
        newErrors.push(err); // 全量阻断
      }
    }

    return newErrors;
  }
}

常见反模式

反模式一:预算值照搬 Lighthouse"Good"阈值

// ❌ 粗暴照搬
budget: { lcp: 2500 }  // 这只是「绿色」及格线,不是你的业务目标

// ✅ 基于实际数据制定
// 取生产 P75 数据:当前 LCP P75 = 3200ms
// 季度目标:收紧至 2800ms → budget: { lcp: 2800 }

反模式二:只检查入口 bundle,忽略异步 chunk

// ❌ 只查 app.js,忽略路由懒加载的 chunk
if (filename === 'app.js') { /* check */ }

// ✅ 按资源类型检查所有产物
if (ext === '.js') {
  const chunkName = filename.replace(/-[a-f0-9]+(\.js)$/, '$1');
  // 对每个 chunk 执行预算检查
}

反模式三:CI 失败后直接调高预算值

这不是在解决问题,而是在解决"指标"。预算值上调必须有对应的优化 commit(代码分割、依赖精简、CDN 迁移等)作为理由。

总结

性能预算体系不是银弹,但它是将性能从"玄学"拉回"工程"的关键一步:

  1. 定义可量化指标:JS/CSS bundle gzip 体积 + LCP/TBT/CLS 运行时指标
  2. 制定渐进式基线:从当前 P75 出发,每季度收紧
  3. 构建时 + 运行时双防线:webpack/Vite 插件 + Lighthouse CI
  4. 渐进式治理:告警 → 阻断新增 → 全量阻断
  5. 反模式警觉:不照搬通用阈值、不遗漏 chunk、不以调高预算代替优化

把你下一季度的性能目标写进 budget.config.js,让 CI 替你守住底线。

0 评论

评论区

登录 后参与评论