陌上人如玉
公子世无双

5.11 RUM 与 Web Vitals 深入

5.11 RUM 与 Web Vitals 深入

一句话:RUM(Real User Monitoring) 的核心不是「装个库」,而是「采集真实用户的 Core Web Vitals + 把每个指标归因到具体元素/交互 + 用分位数而不是平均值观察」——只有归因,指标才能变成可执行的优化项。

一、五个指标与它们的「构成拆解」

⭐⭐ 只看一个数字是没用的——必须知道它「由哪几段组成」:

① LCP(Largest Contentful Paint)——「主要内容多快出现」
   LCP = TTFB              (服务端响应)
       + 资源加载延迟       (发现 LCP 资源的时间:preload 能减少这一段)
       + 资源加载时间       (下载 LCP 资源:图片大小/压缩/CDN)
       + 元素渲染延迟       (主线程忙不忙:JS 是否阻塞渲染)
   → ⭐ 四段各自对应完全不同的优化手段(见下方「归因」)

② INP(Interaction to Next Paint)——「交互响应多快」
   INP = input delay        (主线程被占用,事件排队等待)
       + processing time    (事件处理函数执行耗时)
       + presentation delay (渲染/布局/绘制到「下一帧」)
   → ⭐ 三段分别对应「长任务」「你的代码」「DOM 复杂度」

③ CLS(Cumulative Layout Shift)——「布局稳定性」
   单次偏移 = impact fraction(影响区域比例)× distance fraction(移动距离比例)
   ⭐ 且必须按「会话窗口(session window)」聚合:
      把偏移按「间隔 < 1s、窗口总时长 ≤ 5s」分组,取【最大窗口的和】

④ TTFB(Time to First Byte)——「服务端多快开始返回」
   TTFB = 重定向 + DNS + TCP + TLS + 请求发送 + 服务端处理 + 首字节
   → 归因到「网络」还是「后端」是关键判断

⑤ FCP(First Contentful Paint)——「首屏第一个内容出现」
   主要受「渲染阻塞资源(CSS/同步 JS)」与「TTFB」影响
指标 好 需要改进 差 主要影响
LCP ≤ 2.5s ≤ 4.0s > 4.0s 用户「看到内容」的速度
INP ≤ 200ms ≤ 500ms > 500ms 交互「跟手」程度
CLS ≤ 0.1 ≤ 0.25 > 0.25 误点、阅读体验
FCP ≤ 1.8s ≤ 3.0s > 3.0s 首屏「有东西」
TTFB ≤ 800ms ≤ 1.8s > 1.8s 服务端与网络
⭐⭐ 「为什么看 P75 而不是平均值」?

   ① 平均值会被「极快的用户」(如内网、高端机)拉低 → 掩盖真实问题
   ② Google 的 CWV 评估用的是「**第 75 百分位**」——
      意思是「75% 的访问要在这个阈值内」才算「好」
   ③ 更应该看的是【分布】:P50 / P75 / P95 / P99
      · P50 与 P75 差距大 → 「部分用户明显更慢」(设备/网络分层)
      · P95/P99 特别差 → 「少数用户极慢」(可能是某种特定设备或路径)
   → 分位数 + 维度拆分(设备/网络/地区/版本)才能定位问题

二、采集:PerformanceObserver 是唯一正确的入口

// ✅ 五个指标的底层 Observer(这是 web-vitals 库内部做的事)
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';   // 生产推荐直接用这个库

// 手写采集(理解原理)
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // ⭐ LCP 会「多次上报」(每当有更大的元素出现就报一次)
    //    最终值 = 最后一次上报(页面卸载前)
    console.log('LCP', entry.startTime, entry.element, entry.url, entry.size);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });
// ⭐ 各项指标对应的 entry 类型与关键字段(这是「归因」的数据来源)
| 指标 | PerformanceObserver type | 关键字段 |
| --- | --- | --- |
| LCP | `largest-contentful-paint` | `startTime`、`element`、`url`、`size` |
| CLS | `layout-shift` | `value`、`hadRecentInput`、`sources[].node`、`sources[].previousRect/currentRect` |
| INP | `event`(`durationThreshold: 40`) | `duration`、`processingStart`、`processingEnd`、`target`、`interactionId` |
| FCP | `paint`(`name === 'first-contentful-paint'`) | `startTime` |
| TTFB | `navigation` | `responseStart`、`requestStart`、`activationStart` |
⭐⭐⭐ 三个「采集层面」的硬细节(面试与实战都常问):

① 【`buffered: true` 是必须的】
   如果 Observer 在「指标已经发生之后」才创建(如懒加载后初始化监控),
   不加 `buffered` 就拿不到「之前已经发生」的 entry。
   → 而 RUM 的初始化时机往往在「框架加载完」之后,所以必须加。

② 【LCP 是「递增上报」,取最后一次】
   页面渲染过程中,更大/更晚的元素会不断刷新 LCP 值。
   做法:每次上报都记下来,**在 `visibilitychange → hidden` 时上报最终值**(或用库的 `onLCP`)。

③ 【CLS 必须用「会话窗口」聚合,不能简单求和】
   一个页面滚到底可能产生几十次 layout-shift;
   简单的「累加」会得到一个巨大的值(误导)。
   正确算法:
     · 过滤 `hadRecentInput === true` 的偏移(用户输入后 500ms 内的偏移不算)
     · 按「相邻偏移间隔 < 1s」分组,且「每组总时长 ≤ 5s」
     · 取所有组里【和最大】的那组的和

三、归因:让指标变成「可执行的优化项」

// ⭐ 一个 LCP 的归因报告长什么样(这才是 RUM 的价值)
interface LcpAttribution {
  value: number;                                  // 2.8s
  element: string;                                 // 'IMG#hero.hero-image'
  url: string;                                     // '/images/hero.jpg'
  size: number;                                    // 元素面积
  breakdown: {
    ttfb: number;                                  // 0.4s ← 服务端
    resourceLoadDelay: number;                     // 0.9s ⚠️ 发现太晚 → preload
    resourceLoadTime: number;                      // 1.1s ⚠️ 图片太大 → 压缩/CDN
    elementRenderDelay: number;                    // 0.4s ⚠️ 主线程忙 → 减少 JS
  };
}
// → 一眼看出「问题在『图片太大』」,而不是笼统地「LCP 慢」
// ⭐ INP 的归因
interface InpAttribution {
  value: number;                                  // 320ms
  target: string;                                  // 'BUTTON#submit'
  type: string;                                    // 'click'
  breakdown: {
    inputDelay: number;                            // 200ms ⚠️ 长任务阻塞 → 拆分任务
    processingTime: number;                        // 80ms  ⚠️ 事件处理器慢 → 优化代码
    presentationDelay: number;                     // 40ms  ⚠️ 渲染慢 → 减少 DOM/样式
  };
  longestScript: { name: string; duration: number };   // 最长的那个脚本(直接指出元凶)
  interactionTime: number;
}

// ⭐ CLS 的归因(找出「是谁在跳」)
interface ClsAttribution {
  value: number;                                  // 0.18
  largestShiftTarget: string;                     // 'DIV.ad-slot'
  largestShiftValue: number;
  largestShiftTime: number;
  sources: Array<{ node: string; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly }>;
}
// → 一眼看出「是广告位插入导致的偏移」,而不是「CLS 高」这种无法行动的描述
⭐⭐ 四种「归因维度」的组合,才能定位问题:

① 【用户维度】设备类型(`navigator.hardwareConcurrency` / UA)、
   网络类型(`navigator.connection.effectiveType`:4g/3g/slow-2g)、
   内存(`navigator.deviceMemory`)、地区(CDN 边缘 / 时区)
② 【版本维度】发布版本(`release`)、A/B 分组、
   「本次会话是否是首次进入」(冷缓存 vs 热缓存)
③ 【页面维度】路由路径、是否 SSR/CSR、是否命中了代码分割
④ 【归因维度】LCP 元素 / INP 目标与最长脚本 / CLS 偏移源
   → ⭐ 这一层最关键:没有它,指标只能告诉你「慢了」,不能告诉你「为什么慢」

四、完整实现:一个 210 行的 RUM 采集器

// rum.ts —— 一个可直接用的 RUM 采集器(含三指标 + 归因 + 分位数聚合 + 上报)
// ⚠️ 生产环境建议直接用 `web-vitals` 库(它处理了更多浏览器兼容与边界);
//    这个实现的价值是「让你看清每一步的数据从哪来」。

interface MetricEvent {
  name: 'LCP' | 'INP' | 'CLS' | 'TTFB' | 'FCP';
  value: number;
  /** 归因信息(不同指标字段不同,这里统一为可读字符串 + 原始数据) */
  target?: string;
  breakdown?: Record<string, number>;
  url: string;
  release: string;
  device: { connection: string; memory: number | null; cores: number | null };
  /** 本次会话是否「首次进入」 */
  firstVisit: boolean;
  ts: number;
}

type Reporter = (metric: MetricEvent) => void;

/** ============ 1. 设备与上下文(归因维度) ============ */
function getDeviceInfo() {
  const conn = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
  const mem = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
  const cores = navigator.hardwareConcurrency;
  return {
    connection: conn?.effectiveType ?? 'unknown',
    memory: typeof mem === 'number' ? mem : null,
    cores: typeof cores === 'number' ? cores : null,
  };
}

const isFirstVisit = (() => {
  const KEY = 'rum:visited';
  const seen = sessionStorage.getItem(KEY) === '1';
  sessionStorage.setItem(KEY, '1');
  return !seen;
})();

/** ============ 2. 上报(用 sendBeacon,避免页面卸载时丢失) ============ */
export function createReporter(endpoint: string, release: string): Reporter {
  const buffer: MetricEvent[] = [];
  let flushed = false;

  const flush = (useBeacon: boolean) => {
    if (!buffer.length) return;
    const body = JSON.stringify(buffer.splice(0, buffer.length));

    if (useBeacon && navigator.sendBeacon) {
      navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
      return;
    }
    void fetch(endpoint, {
      method: 'POST',
      body,
      headers: { 'Content-Type': 'application/json' },
      keepalive: true,                       // ⭐ 页面卸载时也能发出
    }).catch(() => {});
  };

  // ⭐ 页面隐藏/卸载时「冲洗」缓冲(LCP 的最终值只能在这时确定)
  document.addEventListener(
    'visibilitychange',
    () => {
      if (document.visibilityState === 'hidden') flush(true);
    },
    { once: false }
  );

  // 兜底:定时批量上报(避免长会话一直不发)
  setInterval(() => flush(false), 10_000);

  return (metric) => {
    buffer.push(metric);
    // ⭐ 明确的「差」指标立即上报(便于快速发现问题)
    const isPoor =
      (metric.name === 'LCP' && metric.value > 4000) ||
      (metric.name === 'INP' && metric.value > 500) ||
      (metric.name === 'CLS' && metric.value > 0.25);
    if (isPoor && !flushed) {
      flushed = true;
      flush(false);
    }
  };
}
/** ============ 3. 采集:LCP / CLS / INP(含归因) ============ */
export function observeWebVitals(report: Reporter, release: string) {
  const base = { url: location.pathname, release, device: getDeviceInfo(), firstVisit: isFirstVisit };

  /** ---------- LCP:递增上报,取最后一次 ---------- */
  let lcpValue = 0;
  let lcpEntry: PerformanceEntry | null = null;

  try {
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const last = entries[entries.length - 1];
      if (!last) return;
      lcpValue = last.startTime;
      lcpEntry = last;
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  } catch {
    /* 不支持则跳过 */
  }

  // ⭐ 页面隐藏时上报 LCP 的最终值 + 归因
  document.addEventListener(
    'visibilitychange',
    () => {
      if (document.visibilityState !== 'hidden' || !lcpValue) return;
      const e = lcpEntry as (PerformanceEntry & { element?: Element; url?: string; size?: number }) | null;
      report({
        ...base,
        name: 'LCP',
        value: lcpValue,
        target: e?.element ? describeElement(e.element) : undefined,
        breakdown: lcpBreakdown(lcpValue, e?.url),
        ts: Date.now(),
      });
    },
    { once: true }
  );

  /** ---------- CLS:会话窗口算法 ---------- */
  let clsValue = 0;
  let worstShift: { target: string; value: number } | null = null;

  // 会话窗口状态
  let windowValue = 0;
  let windowStart = 0;
  let windowLast = 0;
  const WINDOW_GAP = 1000;      // 窗口内相邻偏移的最大间隔 1s
  const WINDOW_MAX = 5000;      // 窗口最大时长 5s

  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as Array<PerformanceEntry & {
        value: number;
        hadRecentInput: boolean;
        sources?: Array<{ node?: Node }>;
      }>) {
        if (entry.hadRecentInput) continue;                     // ⭐ 用户输入后的偏移不算

        const now = entry.startTime;

        // ⭐ 会话窗口:间隔太久 或 窗口超时 → 开新窗口
        if (windowValue === 0 || now - windowLast > WINDOW_GAP || now - windowStart > WINDOW_MAX) {
          windowValue = 0;
          windowStart = now;
        }
        windowValue += entry.value;
        windowLast = now;

        if (windowValue > clsValue) {
          clsValue = windowValue;
          const node = entry.sources?.[0]?.node;
          worstShift = { target: node ? describeNode(node) : 'unknown', value: entry.value };
        }
      }
    }).observe({ type: 'layout-shift', buffered: true });
  } catch {
    /* 忽略 */
  }

  document.addEventListener(
    'visibilitychange',
    () => {
      if (document.visibilityState !== 'hidden' || !clsValue) return;
      report({
        ...base,
        name: 'CLS',
        value: Number(clsValue.toFixed(4)),
        target: worstShift?.target,
        breakdown: { largestShift: worstShift?.value ?? 0 },
        ts: Date.now(),
      });
    },
    { once: true }
  );

  /** ---------- INP:取「最慢的交互」(规范上是 P98,这里取最大 + 记录元凶脚本) ---------- */
  let worstInp: {
    value: number;
    target: string;
    type: string;
    breakdown: Record<string, number>;
  } | null = null;

  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as Array<PerformanceEntry & {
        duration: number;
        processingStart: number;
        processingEnd: number;
        target?: Node;
        interactionId?: number;
        name: string;
      }>) {
        if (!entry.interactionId) continue;                     // ⭐ 只统计「真实交互」(有 interactionId)
        if (worstInp && entry.duration <= worstInp.value) continue;

        const inputDelay = entry.processingStart - entry.startTime;
        const processingTime = entry.processingEnd - entry.processingStart;
        const presentationDelay = Math.max(0, entry.duration - (entry.processingEnd - entry.startTime));

        worstInp = {
          value: entry.duration,
          target: entry.target ? describeNode(entry.target) : 'unknown',
          type: entry.name,
          breakdown: {
            inputDelay: Math.round(inputDelay),
            processingTime: Math.round(processingTime),
            presentationDelay: Math.round(presentationDelay),
          },
        };
      }
    }).observe({ type: 'event', buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
  } catch {
    /* 忽略 */
  }

  document.addEventListener(
    'visibilitychange',
    () => {
      if (document.visibilityState !== 'hidden' || !worstInp) return;
      report({
        ...base,
        name: 'INP',
        value: Math.round(worstInp.value),
        target: worstInp.target,
        breakdown: { ...worstInp.breakdown, interactionType: worstInp.type as unknown as number },
        ts: Date.now(),
      });
    },
    { once: true }
  );

  /** ---------- TTFB / FCP:一次性 ---------- */
  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          report({ ...base, name: 'FCP', value: Math.round(entry.startTime), ts: Date.now() });
        }
      }
    }).observe({ type: 'paint', buffered: true });
  } catch {
    /* 忽略 */
  }

  window.addEventListener('load', () => {
    const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
    if (nav) {
      report({ ...base, name: 'TTFB', value: Math.round(nav.responseStart), ts: Date.now() });
    }
  });
}

/** ============ 4. 归因辅助 ============ */
function describeElement(el: Element): string {
  const id = el.id ? `#${el.id}` : '';
  const cls = el.className && typeof el.className === 'string' ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
  return `${el.tagName.toLowerCase()}${id}${cls}`;
}

function describeNode(node: Node): string {
  if (node.nodeType === Node.ELEMENT_NODE) return describeElement(node as Element);
  const parent = node.parentElement;
  return parent ? `${describeElement(parent)} (text)` : 'text';
}

/** ⭐ LCP 的四段拆解(用 Performance 的 resource timing 估算) */
function lcpBreakdown(lcpTime: number, resourceUrl?: string): Record<string, number> {
  const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
  const ttfb = nav?.responseStart ?? 0;

  // 找到 LCP 资源的加载记录
  const resource = resourceUrl
    ? (performance.getEntriesByName(resourceUrl)[0] as PerformanceResourceTiming | undefined)
    : undefined;

  if (!resource) {
    return { ttfb: Math.round(ttfb), other: Math.round(lcpTime - ttfb) };
  }

  const resourceLoadDelay = Math.max(0, resource.startTime - ttfb);              // 发现资源的延迟
  const resourceLoadTime = resource.responseEnd - resource.startTime;            // 下载时间
  const elementRenderDelay = Math.max(0, lcpTime - resource.responseEnd);        // 渲染延迟

  return {
    ttfb: Math.round(ttfb),
    resourceLoadDelay: Math.round(resourceLoadDelay),
    resourceLoadTime: Math.round(resourceLoadTime),
    elementRenderDelay: Math.round(elementRenderDelay),
  };
}
/** ============ 5. 服务端:分位数聚合(不要只看平均) ============ */
// 后端(示意):把上报的数据按「指标 × 维度」分桶,算 P50/P75/P95/P99
type Buckets = Map<string, number[]>;

export function aggregate(events: MetricEvent[]) {
  const buckets: Buckets = new Map();

  for (const e of events) {
    // ⭐ 按「指标 × 设备 × 网络 × 版本」分桶
    const key = [e.name, e.device.connection, e.release].join('|');
    const arr = buckets.get(key) ?? [];
    arr.push(e.value);
    buckets.set(key, arr);
  }

  const quantile = (sorted: number[], q: number) => {
    if (!sorted.length) return 0;
    const pos = (sorted.length - 1) * q;
    const base = Math.floor(pos);
    const rest = pos - base;
    const next = sorted[base + 1];
    return next === undefined ? sorted[base]! : sorted[base]! + rest * (next - sorted[base]!);
  };

  const result: Array<{ key: string; p50: number; p75: number; p95: number; p99: number; count: number }> = [];
  buckets.forEach((values, key) => {
    const sorted = [...values].sort((a, b) => a - b);
    result.push({
      key,
      p50: quantile(sorted, 0.5),
      p75: quantile(sorted, 0.75),
      p95: quantile(sorted, 0.95),
      p99: quantile(sorted, 0.99),
      count: sorted.length,
    });
  });

  return result.sort((a, b) => b.p75 - a.p75);
}
// ✅ 初始化(在应用入口,尽早调用)
import { createReporter, observeWebVitals } from './rum';

const report = createReporter('/api/rum', __APP_VERSION__);
observeWebVitals(report, __APP_VERSION__);

五、指标 → 优化手段的对应表

指标的哪一段 症状 优化手段
LCP · TTFB 服务端响应慢 缓存(ISR/PPR,见 4.14)、CDN、聚合接口(BFF)、数据库索引
LCP · resourceLoadDelay 「发现 LCP 资源太晚」 <link rel="preload">、把图片写在 HTML 里(别等 JS 插入)、fetchpriority="high"
LCP · resourceLoadTime 资源太大 压缩(AVIF/WebP/brotli)、响应式图片(srcset)、CDN、字体子集化
LCP · elementRenderDelay 主线程忙 减少阻塞 JS、代码分割、font-display: swap、避免「先渲染空壳再插入内容」
INP · inputDelay 长任务占住主线程 拆分长任务(时间切片)、useTransition/useDeferredValue、把重计算移出主线程(Worker)
INP · processingTime 事件处理函数慢 优化算法、避免在 onClick 里做重计算、虚拟滚动
INP · presentationDelay 渲染/布局慢 减少 DOM 数量、避免布局抖动、合成层优化(见 5.17)
CLS · 广告/嵌入内容 插入时推挤内容 预留固定尺寸的容器
CLS · 图片/视频无尺寸 加载后撑开 显式 width/height 或 aspect-ratio
CLS · 字体切换 FOUT 导致文本尺寸变化 font-display: optional / size-adjust / 预加载字体(见 5.15)
CLS · 动态插入的提示条 顶部插入 banner 用「绝对定位 + 预留空间」而不是「插入文档流」
⭐⭐ 一个重要的实践原则:

   「**先归因,再优化**」——
   同样是「LCP 差」,可能是「服务端慢」「图片太大」「渲染被 JS 阻塞」三种完全不同的原因,
   对应的解法也完全不同。
   没有归因的 RUM 只能告诉你「用户觉得慢」,不能告诉你「该改什么」。

六、本篇特有的坑

// ① 用「平均值」汇报(被快用户拉低,掩盖问题)
const avg = values.reduce((a, b) => a + b, 0) / values.length;   // ❌
// ✅ 用 P75(对齐 Google 的 CWV 口径)+ 看 P95/P99

// ② 不加 `buffered: true`
new PerformanceObserver(cb).observe({ type: 'largest-contentful-paint' });   // ⚠️ 拿不到已经发生的
// ✅ observe({ type: '...', buffered: true })

// ③ CLS 直接累加(不理解会话窗口)
let cls = 0; cb = (list) => list.getEntries().forEach((e) => { cls += e.value; });
// ❌ 会得到一个巨大值(把「用户主动滚动/交互导致的偏移」也算进去了)
// ✅ 过滤 `hadRecentInput` + 用「会话窗口」取最大窗口的和

// ④ INP 统计「所有 event entry」(包括非交互的)
// ⚠️ 只统计 `entry.interactionId` 存在的(才是真实用户交互)
if (!entry.interactionId) continue;                              // ✅

// ⑤ 在「页面加载后很晚」才初始化监控(错过 LCP/CLS)
// ✅ 在 HTML 里「尽早」注入初始化脚本(或用库的自动初始化)

// ⑥ 上报时用普通 fetch 而不带 keepalive → 页面卸载时丢数据
// ✅ sendBeacon 或 fetch(..., { keepalive: true })

// ⑦ 把 LCP 的「中间值」上报(LCP 会递增刷新)
// ✅ 在 visibilitychange → hidden 时上报最终值

// ⑧ 只采指标不采归因
// ⚠️ 「LCP 2.8s」没有行动价值;「LCP 2.8s,因为 hero.jpg 太大」才有

// ⑨ 忽略「字段数据与实验室数据的差异」
//   · 实验室(Lighthouse):固定设备与网络,可复现,适合「定位原因」
//   · 字段(RUM):真实用户,适合「判断影响范围与严重程度」
//   → 两者应该一起看:RUM 发现「哪里慢」,Lighthouse 复现「为什么慢」

// ⑩ 忽略「SPA 的软导航」导致指标失真
//   ⚠️ SPA 里「切换路由」不会产生新的 `navigation` entry
//     → TTFB/LCP 只在「首次硬导航」有意义
//     → 软导航要单独埋点(用「路由切换 → 首个内容绘制」来近似)
//   ✅ web-vitals 的 `onLCP` 支持「reportAllChanges」,但软导航仍需自定义

// ⑪ 采样太粗(只上报 1%)→ 长尾问题时看不到
// ✅ 分级采样:「全部上报『差』的 + 采样『好』的」

// ⑫ 上报内容包含隐私数据(URL 里的 token、用户 ID)
// ✅ 只上报「路径」(不含 query),或对敏感字段做脱敏

// ⑬ 忽略「浏览器不支持」的兜底(部分 entry 类型在老浏览器缺失)
// ✅ 每个 observe 都 try/catch(或直接用 web-vitals 库)

// ⑭ 把「跨域资源的耗时」也当自己的(没有 Timing-Allow-Origin 时拿不到详情)
// ✅ 要么给第三方资源加 `Timing-Allow-Origin`,要么在归因里标注「跨域,无法拆分」

// ⑮ 认为「指标好就体验好」
//   ⚠️ 还有「视觉稳定性之外的观感」(如「点了没反应」但 INP 正常——
//      因为那可能是「没有反馈」而不是「响应慢」)

七、面试延伸

  1. 「Core Web Vitals 是哪三个?各自的含义与阈值?」
    ① LCP(Largest Contentful Paint)——「主要内容多快出现」,好 ≤ 2.5s;由「TTFB + 资源加载延迟 + 资源加载时间 + 元素渲染延迟」四段组成。② INP(Interaction to Next Paint)——「交互响应多快」(2024 年起取代 FID),好 ≤ 200ms;由「input delay + processing time + presentation delay」三段组成。③ CLS(Cumulative Layout Shift)——「布局稳定性」,好 ≤ 0.1;用「会话窗口」算法聚合。

  2. 「为什么看 P75 而不是平均值?」
    因为「平均值会被极快的用户拉低」(内网、高端机),掩盖真实问题;而 Google 的 CWV 评估口径就是「第 75 百分位」(75% 的访问要在阈值内才算「好」)。更重要的是「看分布」:P50 与 P75 差距大 → 「部分用户明显更慢」(设备/网络分层);P95/P99 特别差 → 「少数用户极慢」(特定设备或路径)。所以正确做法是「分位数 + 维度拆分」。

  3. 「CLS 为什么会跳?怎么归因?」
    CLS 由「单次偏移 = 影响区域比例 × 移动距离比例」累加而成,且必须用「会话窗口」聚合(相邻偏移间隔 < 1s 为一组、组总时长 ≤ 5s、取最大组的和),同时过滤 hadRecentInput 的偏移。归因靠 layout-shift entry 的 sources[].node ——它能告诉你「是哪个元素在跳」(如「广告位插入」「图片没有尺寸」「字体切换」),从而给出可执行的修复(预留尺寸、aspect-ratio、font-display: optional)。

  4. 「LCP 慢该怎么定位?」
    把 LCP 拆成四段:① TTFB(服务端/网络慢)→ 缓存、CDN、BFF 聚合;② resourceLoadDelay(发现资源太晚)→ <link rel="preload">、fetchpriority="high"、别用 JS 插入图片;③ resourceLoadTime(资源太大)→ AVIF/WebP、srcset、字体子集化;④ elementRenderDelay(主线程忙)→ 减少阻塞 JS、代码分割。归因数据来自 LCP entry 的 element/url 加上 resource timing——没有这个拆分,「LCP 慢」就无法行动。

  5. 「RUM 和实验室数据(Lighthouse)有什么区别?」
    RUM(字段数据)——来自真实用户(各种设备、网络、地区、缓存状态),能回答「影响范围与严重程度」,但不可复现(同一个页面每次数字都不同);Lighthouse(实验室数据)——固定设备与网络,可复现、可对比,能回答「为什么慢」(有详细的诊断与建议),但不代表真实用户。正确用法:RUM 发现「哪里慢、多严重」→ Lighthouse/Performance 面板复现并定位原因 → 修复后回到 RUM 看 P75 是否下降。

  6. 「采集这三个指标,技术上有哪些坑?」
    四个关键点:① buffered: true 必须加——RUM 的初始化往往在框架加载完之后,不加就拿不到「已经发生」的 entry;② LCP 是「递增上报」——更大/更晚的元素会不断刷新,必须在 visibilitychange → hidden 时上报最终值;③ CLS 要用会话窗口(不能简单累加,且要过滤 hadRecentInput);④ INP 只统计有 interactionId 的 event entry(否则会把非交互事件算进来)。此外上报要用 sendBeacon / keepalive,避免页面卸载时丢数据。

  7. 「指标怎么和『具体的代码问题』关联起来?」
    靠归因数据:① LCP → entry.element(是哪个元素)+ resource timing(它的资源大小与加载时间);② INP → entry.target(哪个元素上的交互)+ processingStart/End(三段时间)+ 最长脚本(long-animation-frame / longtask entry 能指出元凶脚本);③ CLS → sources[].node(哪个节点在跳)。再加上「版本/设备/网络」维度,就能回答「哪一类用户、在哪个页面、因为什么而慢」。

  8. 「SPA(客户端路由)的指标采集要注意什么?」
    三点:① TTFB / LCP 只在「首次硬导航」有意义——SPA 的软导航不会产生新的 navigation entry,所以这两个指标反映的是「首次进入」的体验;② 软导航要单独埋点——用「路由变化 → 目标内容首次绘制」来近似一个「导航 LCP」(可以用 PerformanceObserver 观察路由切换后的首个 paint,或自行在路由组件里打点);③ CLS 的「会话窗口」在 SPA 里依然适用,但要注意「路由切换本身」不应算作 CLS(布局整体替换不算偏移)。

一句话速记

RUM 的价值不在「装库」,而在「归因 + 分位数」:LCP 拆成「TTFB + 资源加载延迟 + 资源加载时间 + 元素渲染延迟」、INP 拆成「input delay + processing time + presentation delay」、CLS 按「会话窗口(间隔 < 1s、组 ≤ 5s、取最大组的和,且过滤 hadRecentInput)」算;看 P75(对齐 CWV 口径)+ 看分布(P50/P75/P95/P99),不要看平均值;采集必须有「buffered: true」「LCP 在 visibilitychange → hidden 上报最终值」「INP 只统计有 interactionId 的 event」「sendBeacon / keepalive 上报」;归因靠 element / target / sources[].node,再加「版本 / 设备 / 网络 / 首访」维度;RUM 找问题(范围与严重度)、Lighthouse 定位原因(可复现);SPA 的 TTFB/LCP 只反映首次硬导航,软导航要单独埋点。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.11 RUM 与 Web Vitals 深入

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫