性能预算
问题
什么是性能预算(Performance Budget)?如何制定和执行性能预算?如何在团队中推行性能文化?
什么是性能预算(Performance Budget)? 性能预算是团队认可的一组上限、目标和退化规则,把“尽量快”变成可验证的工程约束:
- 资源型预算:按路由、设备和压缩口径定义。例如在关键移动端路由上,可先尝试把首屏必需 JS 的 gzip 预算设为 100~200KB,再根据当前基线与用户数据调整。
- 体验型预算:线上按页面类型和用户群体看 P75 的 LCP、INP、CLS;LCP 2.5s、INP 200ms、CLS 0.1 是 Core Web Vitals 的“良好”边界,不等于每个 CI 单次运行的稳定门禁值。
- 回归型预算:同时限制绝对上限和相对退化,例如“LCP 中位数不得退化超过 10%,且不能越过绝对上限”。
- 规则型预算:关键图片不得懒加载、第三方脚本必须有 owner、图片必须提供尺寸与现代格式候选等。数量规则要说明适用场景,不能把“第三方最多 3 个”当普遍真理。
如何制定和执行性能预算? 三步走:
- 怎么定:用户任务与业务 SLO + Core Web Vitals 体验边界 + 当前基线 + 竞品参考。竞品受页面功能、地区、缓存和测试条件影响,只能作为输入,不能机械地“快 20%”。
- 怎么测:构建时检查路由入口、chunk 和增量体积;CI 对固定用户旅程重复运行,使用中位数或稳健统计并同时比较主分支。高噪声实验指标先告警,稳定后再阻断。
- 怎么报警:线上用
web-vitals上报真实用户数据(RUM),P75 退化超阈值就钉钉/企微告警。 - 工具链:本地
webpack-bundle-analyzer看产物、CI 用lhci autorun卡指标、生产用 SpeedCurve / Sentry Performance 持续监控。
如何在团队中推行性能文化? 靠机制和工具而非靠喊口号:
- 可视化:把性能指标做到看板上(Grafana / 自建大屏),每周看趋势;让性能数据像测试覆盖率一样人人可见。
- 门禁:把确定性强的产物体积、规则检查设为硬门禁;受环境影响大的实验室指标采用多次运行、基线对比和分级策略,避免误报让团队绕过门禁。
- 责任制:为每个路由、第三方和预算指定团队 owner;关注系统与模块责任,不把线上退化简单追责到个人。
- 激励:性能优化纳入 OKR / 季度复盘,分享好的优化案例,让做性能的人有荣誉感。
- 教育:定期开「性能避坑分享」、Code Review 时主动指出性能反模式(无 key 的列表、行内函数、未拆 chunk 的大依赖)。
答案
性能预算是一组有测量条件、适用范围、owner 和处理流程的约束。它可以是硬上限、目标区间、相对回归阈值或规则检查;不同类型应采用不同门禁强度。
什么是性能预算
定义
性能预算是一组对网页性能指标设定的限制值,用于在开发过程中持续跟踪和控制性能。
为什么需要性能预算
| 问题 | 没有预算 | 有预算 |
|---|---|---|
| 性能退化 | 难以察觉 | 立即发现 |
| 责任划分 | 模糊 | 明确 |
| 优化动力 | 不足 | 持续 |
| 团队协作 | 各自为政 | 统一标准 |
预算类型
1. 资源大小预算
以下数字是某个中等复杂度移动端关键路由的示例,不是行业统一阈值。应明确 gzip 还是 Brotli、单文件还是路由总量、首屏必需还是所有异步资源,并对不同路由类型分别配置。
// bundlesize 配置
// package.json
{
"bundlesize": [
{
"path": "./dist/js/*.js",
"maxSize": "200 kB",
"compression": "gzip"
},
{
"path": "./dist/css/*.css",
"maxSize": "50 kB",
"compression": "gzip"
},
{
"path": "./dist/img/**/*.{jpg,png,webp}",
"maxSize": "500 kB"
}
]
}
2. 性能指标预算
| 指标 | 良好 | 一般 | 差 |
|---|---|---|---|
| FCP | < 1.8s | 1.8-3s | > 3s |
| LCP | < 2.5s | 2.5-4s | > 4s |
| TBT | < 200ms | 200-600ms | > 600ms |
| CLS | < 0.1 | 0.1-0.25 | > 0.25 |
| INP(现场) | < 200ms | 200-500ms | > 500ms |
LCP、INP、CLS 的分级应在真实用户数据中按页面类型查看第 75 百分位。TBT 是实验室里诊断主线程阻塞的代理指标,不等于 INP;CI 里无法靠一次无交互 Lighthouse 运行直接证明线上 INP 达标。FCP 可作为诊断指标,但不是 Core Web Vitals。
3. 规则型预算
interface RuleBudget {
// 请求数量
maxRequests: number;
maxRequestsPerType: {
script: number;
stylesheet: number;
image: number;
font: number;
};
// 第三方资源
maxThirdPartyRequests: number;
maxThirdPartySize: string;
// 其他规则
requireHTTPS: boolean;
requireCompression: boolean;
requireModernImageFormats: boolean;
}
const budget: RuleBudget = {
maxRequests: 50,
maxRequestsPerType: {
script: 10,
stylesheet: 5,
image: 30,
font: 4,
},
maxThirdPartyRequests: 5,
maxThirdPartySize: '100KB',
requireHTTPS: true,
requireCompression: true,
requireModernImageFormats: true,
};
这段配置同样是示例。在 HTTP/2 或 HTTP/3 下,请求数量的意义与 HTTP/1.1 不同;第三方请求也要按执行成本、隐私、失败隔离和用户价值评估,而不是只数个数。
制定预算策略
基于竞争对手
// 竞品分析脚本
async function analyzeCompetitors(urls: string[]) {
const results = await Promise.all(
urls.map(url => runLighthouse(url))
);
const metrics = results.map(r => ({
url: r.url,
lcp: r.audits['largest-contentful-paint'].numericValue,
fcp: r.audits['first-contentful-paint'].numericValue,
cls: r.audits['cumulative-layout-shift'].numericValue,
}));
// 取最佳值
const bestLCP = Math.min(...metrics.map(m => m.lcp));
const bestFCP = Math.min(...metrics.map(m => m.fcp));
const bestCLS = Math.min(...metrics.map(m => m.cls));
// 示例目标:在相同测试条件下尝试比竞品最佳值快 20%。
// 正式预算还要结合自身功能、当前基线和真实用户数据。
return {
lcpBudget: bestLCP * 0.8,
fcpBudget: bestFCP * 0.8,
clsBudget: Math.min(bestCLS * 0.8, 0.1),
};
}
基于用户体验
// Google Core Web Vitals 推荐值
const coreWebVitalsBudget = {
lcp: 2500, // ms, Good
inp: 200, // ms, Good
cls: 0.1, // score, Good
};
// 更严格的预算(75th percentile)
const strictBudget = {
lcp: 2000,
inp: 100,
cls: 0.05,
};
“更严格”配置只是业务目标示例,不是新的官方分级。字段数据还要标注范围:关键路由、移动端、目标国家/地区、有效样本量和统计窗口。
基于当前基准
// 测量当前性能
async function measureBaseline() {
const runs = 5;
const results: number[] = [];
for (let i = 0; i < runs; i++) {
const result = await runLighthouse(PRODUCTION_URL);
results.push(result.categories.performance.score * 100);
}
const sorted = results.sort((a, b) => a - b);
const baseline = sorted[Math.floor(sorted.length / 2)];
// 中位数只是最小示例;实际还应与同环境主分支结果比较波动区间
return {
performanceScore: Math.floor(baseline),
};
}
预算执行工具
在选工具前,先把预算写成一张可执行矩阵:
| 字段 | 示例 |
|---|---|
| 范围 | 商品详情移动端、搜索结果桌面端、登录后编辑器 |
| 用户旅程 | 首次访问、搜索并打开结果、保存表单 |
| 条件 | 中端移动设备模拟、冷缓存、固定网络配置;线上为目标地区真实用户 |
| 资源口径 | 路由首屏 gzip JS、异步编辑器 chunk、第三方传输与执行成本 |
| 体验指标 | 字段 P75 LCP/INP/CLS;实验室 LCP/TBT/长任务 |
| 判定 | 绝对上限 + 相对主分支退化 + 最小样本量 |
| Owner | 业务团队、平台团队、第三方负责人 |
| 例外 | 工单、原因、补偿措施、审批人、到期日 |
一个首页、数据后台和在线编辑器不应共用完全相同的资源预算。可以建立“营销页、内容页、应用页、重交互页”等模板,再允许路由在有理由时覆盖。
Lighthouse Budget
// lighthouse-budget.json
{
"timings": [
{
"metric": "first-contentful-paint",
"budget": 1800
},
{
"metric": "largest-contentful-paint",
"budget": 2500
},
{
"metric": "cumulative-layout-shift",
"budget": 0.1
}
],
"resourceSizes": [
{
"resourceType": "script",
"budget": 200
},
{
"resourceType": "stylesheet",
"budget": 50
},
{
"resourceType": "image",
"budget": 500
},
{
"resourceType": "total",
"budget": 1000
}
],
"resourceCounts": [
{
"resourceType": "script",
"budget": 10
},
{
"resourceType": "third-party",
"budget": 5
}
]
}
# 使用预算运行 Lighthouse
lighthouse https://example.com --budget-path=./lighthouse-budget.json
Webpack Performance
// webpack.config.ts
import type { Configuration } from 'webpack';
const config: Configuration = {
performance: {
hints: 'error', // 'warning' | 'error' | false
maxAssetSize: 250 * 1024, // 250KB
maxEntrypointSize: 400 * 1024, // 400KB
assetFilter: (assetFilename) => {
return !/\.map$/.test(assetFilename);
},
},
};
export default config;
Bundle Analyzer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'stats.html',
gzipSize: true,
brotliSize: true,
}),
],
};
bundlesize
// package.json
{
"scripts": {
"check-size": "bundlesize"
},
"bundlesize": [
{
"path": "dist/js/main.*.js",
"maxSize": "150 kB",
"compression": "gzip"
},
{
"path": "dist/js/vendor.*.js",
"maxSize": "100 kB",
"compression": "gzip"
}
]
}
CI/CD 集成
GitHub Actions
下面展示集成位置,Action 与 Node 版本应按仓库支持矩阵固定并定期升级,不能把示例版本当成长期最新值。
# .github/workflows/performance.yml
name: Performance Budget Check
on:
pull_request:
branches: [main]
jobs:
budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
# 检查 bundle 大小
- name: Check bundle size
run: npm run check-size
# Lighthouse 预算检查
- name: Lighthouse Budget
uses: treosh/lighthouse-ci-action@v11
with:
configPath: ./lighthouserc.json
budgetPath: ./lighthouse-budget.json
# 上传结果到 PR
- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
path: ./lighthouse-report.md
自定义预算检查
// scripts/check-budget.ts
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
interface BudgetConfig {
path: string;
maxSize: number; // bytes
compression?: 'gzip' | 'brotli' | 'none';
}
const budgets: BudgetConfig[] = [
{ path: 'dist/js/main.*.js', maxSize: 150 * 1024, compression: 'gzip' },
{ path: 'dist/css/*.css', maxSize: 30 * 1024, compression: 'gzip' },
];
async function checkBudgets() {
const violations: string[] = [];
for (const budget of budgets) {
const files = globSync(budget.path);
for (const file of files) {
const content = fs.readFileSync(file);
let size = content.length;
if (budget.compression === 'gzip') {
size = zlib.gzipSync(content).length;
} else if (budget.compression === 'brotli') {
size = zlib.brotliCompressSync(content).length;
}
if (size > budget.maxSize) {
violations.push(
`${file}: ${formatSize(size)} > ${formatSize(budget.maxSize)}`
);
}
}
}
if (violations.length > 0) {
console.error('Budget violations:');
violations.forEach(v => console.error(` ❌ ${v}`));
process.exit(1);
}
console.log('✅ All budgets passed');
}
function formatSize(bytes: number): string {
return `${(bytes / 1024).toFixed(2)} KB`;
}
checkBudgets();
监控和告警
实时监控
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals/attribution';
function reportWebVital(metric: Metric): void {
const payload = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
delta: metric.delta,
rating: metric.rating,
navigationType: metric.navigationType,
route: location.pathname,
});
navigator.sendBeacon('/api/performance/vitals', payload);
}
onLCP(reportWebVital);
onINP(reportWebVital);
onCLS(reportWebVital);
生产侧不要按单个用户样本即时报警。应按页面类型、设备、地区和版本聚合,检查样本量与发布标记,再比较滚动窗口 P75、历史基线和变化幅度。URL、DOM 选择器和 attribution 字段需要脱敏与限基数。
告警配置
// 告警规则配置
interface AlertRule {
metric: string;
threshold: number;
operator: '>' | '<' | '==' | '>=' | '<=';
duration: string; // e.g., '5m'
severity: 'warning' | 'error' | 'critical';
channels: ('email' | 'slack' | 'pagerduty')[];
}
const alertRules: AlertRule[] = [
{
metric: 'p75_lcp',
threshold: 2500,
operator: '>',
duration: '10m',
severity: 'warning',
channels: ['slack'],
},
{
metric: 'p95_lcp',
threshold: 4000,
operator: '>',
duration: '5m',
severity: 'critical',
channels: ['slack', 'pagerduty'],
},
];
团队协作
预算文档
# 性能预算文档
## 当前预算
| 指标 | 预算值 | 当前值 | 状态 |
|------|--------|--------|------|
| LCP | 2.5s | 2.1s | ✅ |
| FCP | 1.8s | 1.5s | ✅ |
| CLS | 0.1 | 0.08 | ✅ |
| JS Size | 200KB | 180KB | ✅ |
| Total Size | 1MB | 850KB | ✅ |
## 预算变更历史
| 日期 | 指标 | 旧值 | 新值 | 原因 |
|------|------|------|------|------|
| 2024-01-15 | LCP | 3.0s | 2.5s | Core Web Vitals 更新 |
## 例外情况
需要超出预算时,必须:
1. 提交例外申请
2. 获得技术负责人批准
3. 记录原因、影响范围、补偿措施和预计恢复时间
4. 设置到期日,由自动化在到期前提醒并在到期后重新阻断
PR 检查清单
## 性能检查清单
- [ ] 新增的 JS/CSS 是否影响首屏?
- [ ] 图片是否使用现代格式(WebP/AVIF)?
- [ ] 是否添加了不必要的依赖?
- [ ] bundle size 是否在预算内?
- [ ] Lighthouse 分数是否达标?
- [ ] 关键用户旅程相对主分支是否退化?
- [ ] 新增第三方是否有 owner、超时、降级和隐私评估?
常见面试问题
Q1: 什么是性能预算?为什么需要它?
答案:
性能预算是设定的性能指标阈值,超出即视为问题。
必要性:
- 防止退化:每次变更都检查
- 明确目标:团队共同遵守
- 量化性能:从主观变客观
- 持续关注:性能是持续过程
Q2: 如何制定合理的性能预算?
答案:
| 策略 | 方法 | 适用场景 |
|---|---|---|
| 竞品分析 | 统一地区、设备、缓存和页面功能后作为参考 | 新项目 |
| 基准测量 | 不允许退化 | 成熟产品 |
| 用户期望 | Core Web Vitals | 通用 |
我通常会先按路由和核心用户旅程分组,写清设备、网络、缓存、地区和统计口径。预算同时包含绝对上限与相对主分支阈值:前者守住体验底线,后者尽早发现小幅回归。对于现状较差的页面,可以设置分阶段目标,但要有时间表和 owner。
Q3: 如何在 CI/CD 中执行预算检查?
答案:
CI 中应分层处理:产物体积、重复依赖、关键资源规则比较确定,可以直接阻断;Lighthouse 等实验室指标有噪声,应固定环境、预热并重复运行,用中位数和主分支对比,必要时先告警后阻断。
# GitHub Actions
steps:
- name: Build
run: npm run build
# 1. Bundle size 检查
- name: Check bundle size
run: npx bundlesize
# 2. Lighthouse 检查
- name: Lighthouse
uses: treosh/lighthouse-ci-action@v11
with:
budgetPath: ./budget.json
# 3. 自定义检查
- name: Custom budget check
run: node scripts/check-budget.js
还要跑代表性的多页面、多步骤旅程,而不只是首页一次加载。失败报告应展示本次值、基线值、绝对预算、波动范围和产物差异,方便开发者判断是代码回归还是测试噪声。
Q4: 预算超标如何处理?
答案:
- 阻止合并:CI 失败
- 分析原因:是否必要
- 优化或申请例外
- 记录变更并设置例外到期日
// 例外流程
interface BudgetException {
ticket: string; // 关联 issue
approver: string; // 审批人
deadline: Date; // 恢复期限
currentValue: number; // 当前值
budgetValue: number; // 预算值
reason: string; // 原因
}
Q5: 如何让团队重视性能?
答案:
- 可视化:Dashboard 展示性能趋势
- 自动化:CI 强制检查
- 责任制:路由、模块和第三方都有明确 owner
- 教育:定期分享性能知识
- 激励:性能优化的认可
关键是让数据可解释、门禁可信、修复路径清楚。如果噪声测试经常误报,团队很快会绕过它;如果只有“谁写坏了”的追责,也会抑制协作。更好的机制是把预算纳入需求评审、PR 报告、发布观测和复盘,并给平台团队建设公共工具的责任。