前端性能巡检怎么落地:把 LCP、长任务和包体预算接进 CI
前端性能巡检怎么落地:把 LCP、长任务和包体预算接进 CI
前端性能巡检别迷信 Lighthouse 总分。按页面固定设备与网络,多跑几次看 LCP、长任务和资源体积的分位数,再跟 RUM 分组数据对照。
1. 人工巡检的盲区:别让“平均值”骗了你
很多团队在做日常性能巡检时,最常犯的错误就是看 Lighthouse 跑出来的平均分。
本地 Lighthouse 的网络与 CPU 条件通常好于部分用户设备。巡检应固定浏览器、网络与 CPU 仿真,多次运行后取分位数;实验室数据再与按页面和设备分组的 RUM 数据对照。
不要只看平均值。实验室数据可使用多次运行的分位数;真实用户数据再按页面、设备和网络分组分析。P90 或 P95 是否合适,取决于样本量和告警目标。
2. 自动化性能巡检脚本实现
可用 Playwright 做资源体积、LCP 与 CLS 的实验室巡检。INP 需要真实交互和页面生命周期处理,建议使用web-vitals的参考实现采集真实用户数据,不要把任意PerformanceEntry.duration当作 INP。
下面是生产环境中可落地的自动化巡检 TypeScript 代码:
import { chromium, type Page } from 'playwright'; export interface PerformanceBudget { maxLcpMs: number; maxCls: number; maxInpMs: number; maxTotalJsSizeKb: number; } export interface MetricReport { url: string; lcpMs: number; cls: number; inpMs: number; totalJsSizeKb: number; timestamp: number; passed: boolean; violations: string[]; } export const DefaultBudget: PerformanceBudget = { maxLcpMs: 2000, maxCls: 0.1, maxInpMs: 200, maxTotalJsSizeKb: 500, }; export async function inspectPagePerformance( targetUrl: string, budget: PerformanceBudget = DefaultBudget ): Promise<MetricReport> { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, // 仿真中端移动设备或中等网速环境 userAgent: 'Mozilla/5.0 (Linux; Android 10; Mobile) PerformanceInspector/1.0', }); const page = await context.newPage(); // 1. 注入性能指标收集器 await page.addInitScript(() => { (window as any).__perfMetrics = { lcp: 0, cls: 0, }; // 监听 LCP new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); const lastEntry = entries[entries.length - 1]; if (lastEntry) { (window as any).__perfMetrics.lcp = lastEntry.startTime; } }).observe({ type: 'largest-contentful-paint', buffered: true }); // 监听 CLS let clsValue = 0; new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries() as any[]) { if (!entry.hadRecentInput) { clsValue += entry.value; (window as any).__perfMetrics.cls = clsValue; } } }).observe({ type: 'layout-shift', buffered: true }); }); // 2. 访问目标页面并等待网络空闲 await page.goto(targetUrl, { waitUntil: 'networkidle' }); // 触发基本交互;此示例不计算 INP,INP 应通过 web-vitals 在真实用户侧采集。 await page.mouse.move(100, 100); await page.evaluate(() => window.scrollBy(0, 300)); await page.waitForTimeout(1000); // 3. 计算已加载 JS 资源总体积 const jsSizeByte = await page.evaluate(() => { const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; return resources .filter((r) => r.initiatorType === 'script' || r.name.endsWith('.js')) .reduce((sum, r) => sum + (r.transferSize || 0), 0); }); const rawMetrics = await page.evaluate(() => (window as any).__perfMetrics); await browser.close(); const lcpMs = Math.round(rawMetrics.lcp || 0); const cls = Number((rawMetrics.cls || 0).toFixed(3)); const totalJsSizeKb = Math.round(jsSizeByte / 1024); // 4. 比对性能预算 const violations: string[] = []; if (lcpMs > budget.maxLcpMs) { violations.push(`LCP 耗时 ${lcpMs}ms 超出预算门禁 (${budget.maxLcpMs}ms)`); } if (cls > budget.maxCls) { violations.push(`CLS 偏移值 ${cls} 超出预算门禁 (${budget.maxCls})`); } if (totalJsSizeKb > budget.maxTotalJsSizeKb) { violations.push(`JS 资源总体积 ${totalJsSizeKb}KB 超出预算门禁 (${budget.maxTotalJsSizeKb}KB)`); } return { url: targetUrl, lcpMs, cls, inpMs: 0, // 未采集时不要将该值用于 INP 预算判断。 totalJsSizeKb, timestamp: Date.now(), passed: violations.length === 0, violations, }; }4. 总结
巡检指标用于发现回归,而不是替代真实用户监测。将 LCP、CLS、资源体积的实验室数据与 RUM 数据分开记录,超出预算时附上 trace 和变更范围,团队才更容易处理告警。
