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 迁移等)作为理由。
总结
性能预算体系不是银弹,但它是将性能从"玄学"拉回"工程"的关键一步:
- 定义可量化指标:JS/CSS bundle gzip 体积 + LCP/TBT/CLS 运行时指标
- 制定渐进式基线:从当前 P75 出发,每季度收紧
- 构建时 + 运行时双防线:webpack/Vite 插件 + Lighthouse CI
- 渐进式治理:告警 → 阻断新增 → 全量阻断
- 反模式警觉:不照搬通用阈值、不遗漏 chunk、不以调高预算代替优化
把你下一季度的性能目标写进 budget.config.js,让 CI 替你守住底线。
评论区
登录 后参与评论