WSの小屋

Web 性能监控体系:从 RUM 到 Core Web Vitals 实战

引言

性能不是"感觉快不快",而是可量化的工程指标。Google 的 Core Web Vitals 已经成为搜索排名因子之一。本文系统性地讲解 RUM 体系搭建、Core Web Vitals 采集原理、Performance Observer API 实战,以及告警与优化策略。

一、RUM vs Synthetic Monitoring

维度 RUM(Real User Monitoring) Synthetic(合成监控)
数据来源 真实用户浏览器 固定环境脚本
网络条件 真实分布 实验室稳定
用户基数 大量真实样本 有限场景
反映问题 真实体验瓶颈 可重现的性能回归
典型工具 web-vitals、Sentry Performance Lighthouse CI、WebPageTest
成本 低(边缘采集) 中(机器实例)

最佳实践:RUM 为主,Synthetic 为辅,互为补充。

二、Core Web Vitals 三大指标

指标 全称 含义 良好 需改进
LCP Largest Contentful Paint 最大内容绘制 ≤2.5s ≤4.0s >4.0s
INP Interaction to Next Paint 交互到下一帧 ≤200ms ≤500ms >500ms
CLS Cumulative Layout Shift 累积布局偏移 ≤0.1 ≤0.25 >0.25

⚠️ 2024 年 3 月起,INP 正式替代 FID 成为 Core Web Vital。

辅助指标:TTFB(≤800ms)、FCP(≤1.8s)、Long Task(>50ms 的任务,INP 的根因排查入口)。

三、Performance Observer API 实战

3.1 监听 LCP

const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const lastEntry = entries[entries.length - 1]
  console.log('LCP:', lastEntry.startTime, lastEntry.element)
})
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })

3.2 监听 CLS

let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) clsValue += entry.value
  }
})
clsObserver.observe({ type: 'layout-shift', buffered: true })

window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') console.log('Final CLS:', clsValue)
})

3.3 INP 采集

let maxInteraction = 0
const inpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > maxInteraction) maxInteraction = entry.duration
  }
})
inpObserver.observe({ type: 'event', buffered: true })

3.4 Long Task 监控

const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn('Long Task:', entry.duration, 'ms')
  }
})
longTaskObserver.observe({ type: 'longtask', buffered: true })

四、web-vitals 库实战

4.1 基础采集

import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'

function sendToAnalytics(metric: any) {
  const body = JSON.stringify({
    name: metric.name, value: metric.value, rating: metric.rating,
    id: metric.id, delta: metric.delta, navigationType: metric.navigationType,
    page: location.pathname, sessionId: getSessionId(),
  })
  if (navigator.sendBeacon) navigator.sendBeacon('/api/vitals', body)
  else fetch('/api/vitals', { body, method: 'POST', keepalive: true })
}

onLCP(sendToAnalytics); onINP(sendToAnalytics); onCLS(sendToAnalytics)
onFCP(sendToAnalytics); onTTFB(sendToAnalytics)

4.2 attribution:定位瓶颈

import { onLCP } from 'web-vitals/attribution'

onLCP((metric) => {
  console.log('LCP element:', metric.attribution.element)
  console.log('LCP TTFB:', metric.attribution.timeToFirstByte)
  console.log('LCP render delay:', metric.attribution.elementRenderDelay)
  console.log('LCP resource load:', metric.attribution.resourceLoadTime)
})
归因阶段 含义 优化方向
TTFB 服务端响应 CDN、SSR、edge
Resource load 资源加载 预加载、格式优化
Render delay 渲染阻塞 减少 JS、关键 CSS

五、完整 RUM 采集方案

5.1 性能 SDK 封装

class VitalsMonitor {
  private queue: VitalReport[] = []
  private readonly batchLimit = 10
  private readonly flushInterval = 5000

  constructor() {
    this.init(); this.scheduleFlush(); this.bindVisibilityChange()
  }

  private init() {
    const handler = (metric: any) => {
      this.queue.push({
        name: metric.name, value: metric.value, rating: metric.rating,
        page: location.pathname, sessionId: this.getSessionId(),
        attribution: metric.attribution, timestamp: Date.now(),
      })
      if (this.queue.length >= this.batchLimit) this.flush()
    }
    onLCP(handler); onINP(handler); onCLS(handler); onFCP(handler); onTTFB(handler)
  }

  private flush() {
    if (this.queue.length === 0) return
    const batch = this.queue.splice(0)
    const body = JSON.stringify(batch)
    if (navigator.sendBeacon) navigator.sendBeacon('/api/vitals/batch', body)
    else fetch('/api/vitals/batch', { method: 'POST', body, keepalive: true })
  }

  private scheduleFlush() {
    window.setInterval(() => this.flush(), this.flushInterval)
  }

  private bindVisibilityChange() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush()
    })
  }

  private getSessionId(): string {
    let id = sessionStorage.getItem('vitals_session')
    if (!id) { id = crypto.randomUUID(); sessionStorage.setItem('vitals_session', id) }
    return id
  }
}

5.2 采样策略

场景 采样率 说明
高流量站点 1-5% 日志量可控
中流量 10% 平衡
低流量 50-100% 保证样本量
关键页面 100% 结账、登录

5.3 服务端聚合

SELECT
  page,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75_lcp,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value) AS p95_lcp,
  COUNT(*) AS sample_count,
  AVG(CASE WHEN rating = 'good' THEN 1.0 ELSE 0 END) AS good_rate
FROM vitals
WHERE name = 'LCP' AND timestamp > NOW() - INTERVAL '24 hours'
GROUP BY page
ORDER BY p75_lcp DESC;

Google 用 P75 作为 Web Vitals 评估分位数:即 75% 的用户体验达到良好,才算通过。

六、告警体系

6.1 阈值告警

const alerts = [
  { name: 'LCP_P75_HIGH', query: 'histogram_quantile(0.75, rate(vitals_lcp_bucket[5m]))',
    threshold: 4000, for: '10m', severity: 'warning' },
  { name: 'INP_P75_CRITICAL', query: 'histogram_quantile(0.75, rate(vitals_inp_bucket[5m]))',
    threshold: 500, for: '5m', severity: 'critical' },
  { name: 'CLS_P75_HIGH', query: 'histogram_quantile(0.75, rate(vitals_cls_bucket[5m]))',
    threshold: 0.25, for: '10m', severity: 'warning' },
]

6.2 SLO burn rate 告警

基于历史数据的 SLO burn rate 告警更精准:

  • 1h burn rate > 13.16x AND 5m burn rate > 14.4x → Page(快速告警)
  • 6h burn rate > 1.0x AND 30m burn rate > 3.0x → Ticket(慢速告警)

七、优化策略对应表

指标差 根因 优化
TTFB 高 服务端慢 / 无 CDN Edge SSR、CDN 缓存
LCP 高 大图 / 渲染阻塞 图片 preload、关键 CSS inline、字体 font-display: swap
LCP 高 第三方 JS 延迟加载、Partytown
INP 高 Long Task 代码分割、requestIdleCallback、Web Worker
INP 高 大量 reflow 虚拟列表、content-visibility: auto
CLS 高 无尺寸媒体 aspect-ratio、img width/height
CLS 高 动态注入内容 预留空间、min-height

7.1 LCP 优化:图片 preload

<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<link rel="preload" as="image"
  imagesrcset="/hero-480.webp 480w, /hero-960.webp 960w" imagesizes="100vw" />

7.2 INP 优化:让出主线程

async function processData(items: Item[]) {
  for (const item of items) {
    process(item)
    if ('scheduler' in window && 'yield' in scheduler) {
      await (scheduler as any).yield()
    } else {
      await new Promise(r => setTimeout(r, 0))
    }
  }
}

7.3 CLS 优化:预留空间

.media-container {
  aspect-ratio: 16 / 9;
  background: #f5f5f5;
}

.skeleton {
  min-height: var(--expected-height, 200px);
}

.long-list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 60px;
}

八、总结

性能监控体系的三层建设:

  1. 采集层web-vitals + PerformanceObserver,覆盖 LCP/INP/CLS/TTFB/FCP,带 attribution 归因。
  2. 聚合层:Beacon 批量上报 → 时序数据库 → P75/P95 分位数聚合。
  3. 告警层:基于 SLO burn rate 而非固定阈值,结合 Slack/PagerDuty 通知。

没有 RUM 的性能优化是盲人摸象。先量化、再优化、再量化,形成闭环。

Comments | 0条评论