跳到正文
前端知识库
性能

性能指标采集与问题诊断

使用 Web Vitals、PerformanceObserver、Network 和 Performance 面板建立可复现的性能诊断流程。

6 分钟LCP · INP · CLS · FCP · TTFB · PerformanceObserver · Lighthouse · RUM

性能指标采集与问题诊断

1. 先区分三个时间轴

性能问题常被混成“页面加载慢”。实际至少有三条时间轴:

  1. 加载:导航开始、响应、解析、首屏内容和主要资源完成。
  2. 交互:用户输入、事件处理、下一次绘制和任务完成。
  3. 稳定性:布局是否跳动、资源是否失败、页面是否随时间变慢。

同一个优化可能改善其中一条,却恶化另一条。例如把大量代码提前执行可能让 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 中使用。当前版本应优先采集 onLCPonINPonCLS,而不是把已淘汰的 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 面板

  1. 先清理缓存并记录一次冷启动,再记录一次热启动,避免混淆。
  2. 查看请求优先级、状态、协议、Initiator 和 Waterfall;优先处理关键链路上的阻塞请求。
  3. 将总耗时拆成 Queueing、Stalled、Request sent、Waiting/TTFB 和 Content Download。
  4. 检查响应头:Cache-ControlETag/Last-Modified、压缩、Content-Type、CORS 和 Timing-Allow-Origin
  5. 对图片、字体和第三方脚本单独看解码/执行影响,不只看传输大小。

6.2 Performance 面板

  1. 以真实用户操作录制:加载、点击、输入、滚动和提交分别记录。
  2. 在 Main 线程火焰图中找超过 50ms 的长任务,展开到脚本和调用位置。
  3. 关联 ScriptingRenderingPaintingLoadingIdle 的占比。
  4. 查看 Frames 是否掉帧、布局事件是否频繁、是否存在重复样式计算或强制同步布局。
  5. 打开 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/heightaspect-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 加载机制