性能指标采集与问题诊断
使用 Web Vitals、PerformanceObserver、Network 和 Performance 面板建立可复现的性能诊断流程。
性能指标采集与问题诊断
1. 先区分三个时间轴
性能问题常被混成“页面加载慢”。实际至少有三条时间轴:
- 加载:导航开始、响应、解析、首屏内容和主要资源完成。
- 交互:用户输入、事件处理、下一次绘制和任务完成。
- 稳定性:布局是否跳动、资源是否失败、页面是否随时间变慢。
同一个优化可能改善其中一条,却恶化另一条。例如把大量代码提前执行可能让 FCP 变快,却增加 INP;把图片延迟加载可能减少首屏字节,却让可见区域的 LCP 变差。
2. 指标定义与采集时机
2.1 Core Web Vitals
| 指标 | 观察对象 | 采集要点 | 常见根因 |
|---|---|---|---|
| LCP | 视口内最大的文本块、图片或视频海报 | 监听页面生命周期,最终值通常在页面隐藏/卸载时汇总 | 服务器慢、关键资源优先级低、渲染阻塞、主线程忙 |
| INP | 交互到下一次绘制的延迟,覆盖页面生命周期 | 记录最慢或代表性的交互,不要只测首次点击 | 长任务、事件处理、样式/布局、同步状态更新 |
| CLS | 无用户预期的布局偏移累计 | 监听 layout-shift,排除 hadRecentInput 的用户输入偏移 |
图片/广告无尺寸、字体替换、异步插入内容 |
2.2 辅助指标
- FP/FCP 适合定位白屏和早期反馈;FCP 不是“页面可用”。
- TTFB 可进一步拆成重定向、DNS、连接、TLS、请求等待;服务端和网络问题要分别归因。
- TBT 把长任务中超过 50ms 的阻塞部分相加,主要用于实验室环境。
- 资源耗时 来自
PerformanceResourceTiming,可按initiatorType、缓存状态和大小聚合。 - 长任务 由
PerformanceLongTaskTiming提供,常用于解释 INP/TBT 的主线程阻塞。
3. 使用 web-vitals 采集现场指标
web-vitals 负责处理浏览器差异、指标结束时机和归因细节,适合在 RUM SDK 中使用。当前版本应优先采集 onLCP、onINP、onCLS,而不是把已淘汰的 FID 当作唯一交互指标。
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'
type Vital = {
name: string
value: number
id: string
navigationType?: string
}
const queue: Vital[] = []
function collect(metric: Vital) {
queue.push(metric)
// 真实项目中应按条数/字节数批量发送,并在 pagehide 时 flush。
if (queue.length >= 5) flush()
}
function flush() {
if (!queue.length) return
const body = JSON.stringify({
metrics: queue.splice(0),
route: location.pathname,
release: document.documentElement.dataset.release,
})
navigator.sendBeacon('/rum/vitals', body)
}
onFCP(collect)
onLCP(collect)
onINP(collect)
onCLS(collect)
onTTFB(collect)
window.addEventListener('pagehide', flush, { once: true })
上报前应删除完整 URL 查询参数、输入内容和凭据;sendBeacon 失败时不要阻塞页面,也不要无限重试。SDK 的队列上限、采样和失败退避可参考前端监控 SDK 设计。
4. 原生 PerformanceObserver
4.1 Paint 和 LCP
function observePaint(onMetric: (name: string, value: number) => void) {
if (!('PerformanceObserver' in window)) return () => {}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint' || entry.name === 'first-contentful-paint') {
onMetric(entry.name, entry.startTime)
}
}
})
observer.observe({ type: 'paint', buffered: true })
return () => observer.disconnect()
}
function observeLcp(onMetric: (value: number, element?: Element | null) => void) {
if (!('PerformanceObserver' in window)) return () => {}
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEntry[]
const last = entries.at(-1) as PerformanceEntry & { element?: Element }
if (last) onMetric(last.startTime, last.element)
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
return () => observer.disconnect()
}
LCP 会随着候选元素变化而更新,不能在第一次回调后立刻认为它是最终值;通常在 visibilitychange 变为 hidden 时读取最后候选并停止观察。不同浏览器对可观察条目有差异,应保留能力检测和降级路径。
4.2 CLS
function observeCls(report: (value: number) => void) {
if (!('PerformanceObserver' in window)) return () => {}
let value = 0
let sessionValue = 0
let sessionStart = 0
let lastShift = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as Array<PerformanceEntry & {
value: number
hadRecentInput: boolean
startTime: number
}>) {
if (entry.hadRecentInput) continue
// CLS 按 1 秒窗口、5 秒会话窗口聚合,避免把整个页面生命周期简单相加。
const gap = entry.startTime - lastShift
if (!sessionStart || gap > 1000 || entry.startTime - sessionStart > 5000) {
sessionStart = entry.startTime
sessionValue = 0
}
sessionValue += entry.value
value = Math.max(value, sessionValue)
lastShift = entry.startTime
}
report(value)
})
try {
observer.observe({ type: 'layout-shift', buffered: true })
} catch {
observer.disconnect()
return () => {}
}
return () => observer.disconnect()
}
真实项目建议直接使用经过验证的 Web Vitals 实现;上面的代码用于理解“排除用户输入 + 会话窗口”的核心逻辑。不要把每一个偏移事件都单独上报。
4.3 长任务和交互线索
function observeLongTasks(report: (entry: PerformanceEntry) => void) {
if (!('PerformanceObserver' in window)) return () => {}
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(report)
})
try {
observer.observe({ type: 'longtask', buffered: true })
} catch {
return () => observer.disconnect()
}
return () => observer.disconnect()
}
长任务只是“哪里可能阻塞”的证据。排查 INP 时还要关联交互事件、事件处理耗时、样式计算和下一次绘制,不能只看长任务数量。
5. Resource Timing 与导航拆解
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const navigationBreakdown = navigation && {
redirect: navigation.redirectEnd - navigation.redirectStart,
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
tcp: navigation.connectEnd - navigation.connectStart,
tls: navigation.secureConnectionStart > 0
? navigation.connectEnd - navigation.secureConnectionStart
: 0,
request: navigation.responseStart - navigation.requestStart,
download: navigation.responseEnd - navigation.responseStart,
domParse: navigation.domInteractive - navigation.responseEnd,
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime,
}
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const slowResources = resources
.filter((entry) => entry.duration > 200)
.map((entry) => ({
name: entry.name,
type: entry.initiatorType,
duration: entry.duration,
transferSize: entry.transferSize,
decodedBodySize: entry.decodedBodySize,
cached: entry.transferSize === 0,
}))
transferSize === 0 不能在所有缓存/协议场景下简单等价于“命中缓存”;应结合响应头、deliveryType(若可用)和 Network 面板验证。跨源资源若没有 Timing-Allow-Origin,会缺少细粒度时间。
6. DevTools 诊断流程
6.1 Network 面板
- 先清理缓存并记录一次冷启动,再记录一次热启动,避免混淆。
- 查看请求优先级、状态、协议、Initiator 和 Waterfall;优先处理关键链路上的阻塞请求。
- 将总耗时拆成 Queueing、Stalled、Request sent、Waiting/TTFB 和 Content Download。
- 检查响应头:
Cache-Control、ETag/Last-Modified、压缩、Content-Type、CORS 和Timing-Allow-Origin。 - 对图片、字体和第三方脚本单独看解码/执行影响,不只看传输大小。
6.2 Performance 面板
- 以真实用户操作录制:加载、点击、输入、滚动和提交分别记录。
- 在 Main 线程火焰图中找超过 50ms 的长任务,展开到脚本和调用位置。
- 关联
Scripting、Rendering、Painting、Loading和Idle的占比。 - 查看 Frames 是否掉帧、布局事件是否频繁、是否存在重复样式计算或强制同步布局。
- 打开 Memory/Heap Snapshot 检查 detached DOM、监听器和闭包;重复操作后比较快照而不是只看一次峰值。
6.3 Lighthouse
Lighthouse 是受控实验室工具,适合 CI 回归和生成建议。它的 Performance 结果常包含 FCP、LCP、TBT、CLS、Speed Index;不要把实验室 TBT 当成现场 INP,也不要把一次运行的分数当作绝对结论。固定测试设备、网络、缓存状态和数据规模,才有比较意义。
7. 把发现转成可执行假设
| 证据 | 假设 | 最小实验 | 验证指标 |
|---|---|---|---|
| LCP 元素是大图,图片请求排在多个脚本后 | 关键图像优先级不足 | 为首屏图设置尺寸并审慎 preload/fetchpriority |
LCP、图片请求开始时间、首屏字节 |
| INP 峰值前有 200ms 以上脚本任务 | 事件被长任务阻塞 | 拆任务/延后非关键计算 | INP、Long Task、任务总时长 |
| CLS 来源是异步图片容器 | 布局没有预留空间 | width/height 或 aspect-ratio |
CLS、偏移来源元素 |
| P95 TTFB 高而下载时间正常 | 服务端/连接等待慢 | 对比 CDN、缓存和后端 trace | TTFB、命中率、服务端耗时 |
| 首屏包包含未访问路由依赖 | 入口过重 | 路由动态导入并分析 chunk | LCP、首屏 JS、缓存命中 |
一次只改变一个主要变量,保留构建版本和实验条件;否则即使指标变好,也无法知道哪项改动产生了效果。
8. 资料来源与现有内容
本篇综合三份性能 PDF 中关于 FCP/LCP/FID/TTFB/TTI、PerformanceObserver、Network/Performance 面板和 Lighthouse 的内容,并按当前 Web Vitals 口径将 FID 更新为 INP。相关专题:前端性能优化知识地图、前端监控 SDK 设计、JavaScript 加载机制。