性能预算
问题
什么是性能预算(Performance Budget)?如何制定和执行性能预算?如何在团队中推行性能文化?
答案
性能预算是设定网页性能指标的阈值,超出预算即视为性能问题。它将性能从"尽量优化"转变为"必须达标",是保持网站性能的有效手段。
什么是性能预算
定义
性能预算是一组对网页性能指标设定的限制值,用于在开发过程中持续跟踪和控制性能。
为什么需要性能预算
| 问题 | 没有预算 | 有预算 |
|---|---|---|
| 性能退化 | 难以察觉 | 立即发现 |
| 责任划分 | 模糊 | 明确 |
| 优化动力 | 不足 | 持续 |
| 团队协作 | 各自为政 | 统一标准 |
预算类型
1. 资源大小预算
// 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 |
| TTI | < 3.8s | 3.8-7.3s | > 7.3s |
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,
};
制定预算策略
基于竞争对手
// 竞品分析脚本
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));
// 预算 = 最佳值 * 0.8(比最佳快 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
fid: 100, // ms, Good
inp: 200, // ms, Good
cls: 0.1, // score, Good
};
// 更严格的预算(75th percentile)
const strictBudget = {
lcp: 2000,
fid: 50,
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 baseline = results.reduce((a, b) => a + b) / runs;
// 预算 = 基准值,不允许退化
return {
performanceScore: Math.floor(baseline),
};
}
预算执行工具
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
# .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();
监控和告警
实时监控
// 真实用户监控 (RUM)
interface PerformanceData {
lcp: number;
fcp: number;
cls: number;
timestamp: number;
}
class PerformanceMonitor {
private budgets = {
lcp: 2500,
fcp: 1800,
cls: 0.1,
};
collect() {
// 收集 LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
this.check('lcp', lcp.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// 收集 CLS
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
cls += (entry as any).value;
}
}
this.check('cls', cls);
}).observe({ type: 'layout-shift', buffered: true });
}
private check(metric: keyof typeof this.budgets, value: number) {
if (value > this.budgets[metric]) {
this.reportViolation(metric, value);
}
}
private reportViolation(metric: string, value: number) {
// 上报到监控系统
fetch('/api/performance/violation', {
method: 'POST',
body: JSON.stringify({
metric,
value,
budget: this.budgets[metric as keyof typeof this.budgets],
url: location.href,
userAgent: navigator.userAgent,
}),
});
}
}
告警配置
// 告警规则配置
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. 记录原因和预计恢复时间
PR 检查清单
## 性能检查清单
- [ ] 新增的 JS/CSS 是否影响首屏?
- [ ] 图片是否使用现代格式(WebP/AVIF)?
- [ ] 是否添加了不必要的依赖?
- [ ] bundle size 是否在预算内?
- [ ] Lighthouse 分数是否达标?
常见面试问题
Q1: 什么是性能预算?为什么需要它?
答案:
性能预算是设定的性能指标阈值,超出即视为问题。
必要性:
- 防止退化:每次变更都检查
- 明确目标:团队共同遵守
- 量化性能:从主观变客观
- 持续关注:性能是持续过程
Q2: 如何制定合理的性能预算?
答案:
| 策略 | 方法 | 适用场景 |
|---|---|---|
| 竞品分析 | 比最佳竞品快 20% | 新项目 |
| 基准测量 | 不允许退化 | 成熟产品 |
| 用户期望 | Core Web Vitals | 通用 |
Q3: 如何在 CI/CD 中执行预算检查?
答案:
# 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 强制检查
- 责任制:性能退化追溯到人
- 教育:定期分享性能知识
- 激励:性能优化的认可