
一句话: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 正常——
// 因为那可能是「没有反馈」而不是「响应慢」)
七、面试延伸
- 「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;用「会话窗口」算法聚合。 -
「为什么看 P75 而不是平均值?」
因为「平均值会被极快的用户拉低」(内网、高端机),掩盖真实问题;而 Google 的 CWV 评估口径就是「第 75 百分位」(75% 的访问要在阈值内才算「好」)。更重要的是「看分布」:P50 与 P75 差距大 → 「部分用户明显更慢」(设备/网络分层);P95/P99 特别差 → 「少数用户极慢」(特定设备或路径)。所以正确做法是「分位数 + 维度拆分」。 -
「CLS 为什么会跳?怎么归因?」
CLS 由「单次偏移 = 影响区域比例 × 移动距离比例」累加而成,且必须用「会话窗口」聚合(相邻偏移间隔 < 1s 为一组、组总时长 ≤ 5s、取最大组的和),同时过滤hadRecentInput的偏移。归因靠layout-shiftentry 的sources[].node——它能告诉你「是哪个元素在跳」(如「广告位插入」「图片没有尺寸」「字体切换」),从而给出可执行的修复(预留尺寸、aspect-ratio、font-display: optional)。 -
「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 慢」就无法行动。 -
「RUM 和实验室数据(Lighthouse)有什么区别?」
RUM(字段数据)——来自真实用户(各种设备、网络、地区、缓存状态),能回答「影响范围与严重程度」,但不可复现(同一个页面每次数字都不同);Lighthouse(实验室数据)——固定设备与网络,可复现、可对比,能回答「为什么慢」(有详细的诊断与建议),但不代表真实用户。正确用法:RUM 发现「哪里慢、多严重」→ Lighthouse/Performance 面板复现并定位原因 → 修复后回到 RUM 看 P75 是否下降。 -
「采集这三个指标,技术上有哪些坑?」
四个关键点:①buffered: true必须加——RUM 的初始化往往在框架加载完之后,不加就拿不到「已经发生」的 entry;② LCP 是「递增上报」——更大/更晚的元素会不断刷新,必须在visibilitychange → hidden时上报最终值;③ CLS 要用会话窗口(不能简单累加,且要过滤hadRecentInput);④ INP 只统计有interactionId的 event entry(否则会把非交互事件算进来)。此外上报要用sendBeacon/keepalive,避免页面卸载时丢数据。 -
「指标怎么和『具体的代码问题』关联起来?」
靠归因数据:① LCP →entry.element(是哪个元素)+ resource timing(它的资源大小与加载时间);② INP →entry.target(哪个元素上的交互)+processingStart/End(三段时间)+ 最长脚本(long-animation-frame/longtaskentry 能指出元凶脚本);③ CLS →sources[].node(哪个节点在跳)。再加上「版本/设备/网络」维度,就能回答「哪一类用户、在哪个页面、因为什么而慢」。 -
「SPA(客户端路由)的指标采集要注意什么?」
三点:① TTFB / LCP 只在「首次硬导航」有意义——SPA 的软导航不会产生新的navigationentry,所以这两个指标反映的是「首次进入」的体验;② 软导航要单独埋点——用「路由变化 → 目标内容首次绘制」来近似一个「导航 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 只反映首次硬导航,软导航要单独埋点。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!