别只收三个数字:前端 RUM 如何建立可解释的体验数据链
很多团队第一次建设前端性能监控时,通常从三行代码开始:采集 LCP、INP 和 CLS,然后把结果发送到某个接口。
这一步并不难。真正困难的是,几天后看到某个页面的 LCP p75 从 2.3 秒升到 3.1 秒时,系统能否继续回答:受到影响的是所有用户,还是低端 Android 用户?问题集中在路由、地区、网络类型,还是某个版本?LCP 变慢是因为首屏图片、字体、服务端响应,还是主线程阻塞?这是真实体验回退,还是采集量下降、字段缺失或样本结构变化?
如果回答不了,监控系统就只能告诉团队"哪里变红",却不能帮助判断"应该查什么"。本文从一个可解释的用户体验样本出发,设计一条从浏览器采集到告警定位的 RUM 数据链。重点不是重复介绍三个指标,而是建立样本边界、上下文、归因信息和告警证据之间的关系。
一、先定义样本:一次导航,而不是三个独立数字
性能监控的第一个设计对象不应该是 LCP、INP 或 CLS,而应该是一次可以被还原的页面体验样本。
建议把数据分为三层:
- 原始事件:浏览器观察到的候选项、交互、布局偏移或长动画帧。
- 导航体验样本:围绕一次页面导航,保存最终的 LCP、INP、CLS 及其上下文。
- 聚合结果:按时间窗口、路由、版本和用户群体计算 p75、p90、达标率、样本量与影响用户数。
原始事件适合诊断,导航样本适合描述一次访问,聚合结果才适合趋势分析和告警。把三者混在一张表中,容易造成指标重复计算、生命周期边界不清和明细数据成本失控。
1. 一次样本至少需要哪些字段
ts
type WebVitalSample = {
metric: 'LCP' | 'INP' | 'CLS';
value: number;
rating: 'good' | 'needs-improvement' | 'poor' | 'unknown';
delta: number;
metricId: string;
pageViewId: string;
navigationId?: string;
navigationType?: string;
navigationUrl?: string;
route: string;
timestamp: string;
appVersion: string;
releaseId?: string;
browser?: string;
browserVersion?: string;
deviceClass?: 'low' | 'mid' | 'high' | 'unknown';
connectionType?: string;
region?: string;
sampleRate: number;
visibilityState?: string;
attribution?: Record<string, unknown>;
};
pageViewId 用于标识一次页面访问,metricId 用于识别同一指标实例的增量更新或重复上报。navigationId 和 navigationUrl 应从受支持的 Navigation Timing 数据或应用自己的导航上下文中取得,不要假设它们一定是 web-vitals 回调对象的顶层字段。
route、appVersion、deviceClass 和 connectionType 是优先保留的切片维度。用户标识、完整 URL、DOM 文本和资源 URL 不应因为"以后可能有用"而默认全部采集。
2. 不要默认把 SPA 路由当成页面加载
多页应用中,一次完整文档导航通常可以作为一个页面体验样本;SPA 则需要先确定统计口径:
- 只统计完整页面导航;
- 将每次业务路由切换视为独立体验样本;
- 同时保存完整导航与软导航,但在报表中分开展示。
三种口径都可能合理,但不能混用。否则初始加载和路由切换会被放进同一分布,具体功能页的问题可能被掩盖。
web-vitals 提供导航类型和指标实例 ID 等信息;支持软导航的 Chromium 浏览器还可以针对软导航重新计算部分 Web Vitals,但浏览器支持范围和测量行为并不一致。因此,第一版系统应明确记录 navigationType,并将软导航作为独立能力开关,而不是假设所有 SPA 路由都能跨浏览器得到一致数据。(github.com)
二、先守住指标语义,再讨论阈值
Core Web Vitals 关注不同的体验维度:
- LCP:主要内容何时呈现,侧重感知加载速度。
- INP:用户交互后多久看到下一次视觉反馈,侧重响应性。
- CLS:页面可见内容发生了多少意外位移,侧重视觉稳定性。
官方推荐使用真实用户数据的第 75 百分位评估体验。当前常用的"良好"阈值为:LCP 不超过 2.5 秒、INP 不超过 200 毫秒、CLS 不超过 0.1;进入 poor 区间的阈值分别高于 4 秒、500 毫秒和 0.25。(web.dev)
这些阈值是分布判断的参考线,不是每次访问都必须满足的硬性上限。单个用户的一次 6 秒 LCP 可以用于诊断,但不能直接证明页面整体恶化;平均值较低也不能证明长尾用户没有问题。
RUM 与实验室数据不能直接替代
实验室数据在固定设备、网络和脚本条件下运行,适合复现问题和验证改动;RUM 数据来自真实用户,反映真实设备、网络、地区和访问路径下的体验分布。两者测量场景不同,数值不一致并不意味着某一方必然错误。(web.dev)
建议显式标记数据来源:
text
source = field | lab
不要把 Lighthouse 的单次 LCP 与生产环境的 LCP p75 放进同一张趋势图,也不要用实验室分数直接替代真实用户告警依据。
三、采集实现:优先使用标准库,再补充诊断层
从零实现时,优先使用 web-vitals,而不是自行拼装多个 PerformanceObserver。该库负责遵循指标测量方法和相关最佳实践。(web.dev)
ts
import { onCLS, onINP, onLCP } from 'web-vitals';
const report = (metric: any) => {
const navigation = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined;
enqueue({
metric: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
metricId: metric.id,
navigationType: metric.navigationType,
navigationId: navigation?.navigationId,
navigationUrl: navigation?.name,
pageViewId: getPageViewId(),
route: getSanitizedRoute(),
appVersion: APP_VERSION,
sampleRate: 1,
});
};
onLCP(report);
onINP(report);
onCLS(report);
示例中的 any 仅用于展示,生产代码应使用库提供的 Metric 类型,并根据浏览器能力处理 navigationId 等可选字段。navigationUrl 是应用样本字段,不应误认为 web-vitals 指标对象必然提供同名属性。
生产环境中要特别注意:
- 不要为同一个指标反复注册观察器。
- 不要默认打开高频
reportAllChanges,除非确实需要观察增量变化。 - 标准指标与 attribution 诊断字段分层加载,避免所有用户承担额外采集成本。
web-vitals 的常规指标对象包含 name、value、rating、delta、id、entries 和 navigationType 等字段;归因字段通常需要使用相应的 attribution 构建或自行整理,不能把所有诊断字段都当作基础指标的稳定属性。(github.com)
页面生命周期是采集边界的一部分
性能指标并不一定在页面加载结束时同时完成。采集系统至少要考虑:初始加载、SPA 路由切换、页面从后台恢复、页面变为 hidden、bfcache 恢复、浏览器不支持某项 API,以及用户在指标最终确定前离开页面。
采集 SDK 不应只等待 load 事件,也不能简单认为"每个页面只上报三条记录"。在指标最终确定前,应允许指标回调产生增量数据;页面隐藏时再进行一次受控 flush,并依靠 metricId 和服务端幂等逻辑去重。不同浏览器对后台、bfcache 和软导航的支持并不完全一致,样本模型应保留导航类型和指标实例 ID。(github.com)
四、让指标可以解释:三种归因字段怎么设计
只有指标值时,告警只能定位到页面;加入受控归因字段后,告警才有机会定位到资源、交互或布局变化。但归因字段应通过采样、白名单、截断和规范化控制体积与高基数风险。
1. LCP:记录最终候选,而不是第一个候选
LCP 会随着页面继续渲染产生新的候选内容。用户滚动或发生输入后,浏览器通常会停止寻找更大的内容,因此最后一个有效候选通常更接近最终 LCP。(developer.mozilla.org)
text
lcp.elementType = img | text | video | background | unknown
lcp.target = 规范化后的元素标识
lcp.resourceUrl = 经过域名白名单与路径截断的资源地址
lcp.loadTime
lcp.renderTime
资源 URL 应删除用户标识、签名和业务参数,仅保留允许采集的域名,并截断路径长度。不要记录完整 DOM 文本。跨域资源缺少 Timing-Allow-Origin 时,资源时序信息可能不可用或精度受限。(developer.mozilla.org)
2. INP:记录最差交互及其处理阶段
定位 INP 时,至少需要知道:
text
inp.eventType
inp.target
inp.inputDelay
inp.processingDuration
inp.presentationDelay
inp.interactionId
这些字段可帮助区分输入延迟、事件处理耗时和呈现延迟。在支持 Long Animation Frames API 的浏览器中,还可以将交互与相关长动画帧关联,补充脚本入口、执行时长、强制布局和阻塞时长等摘要。Chrome 文档将 LoAF 用于诊断 INP,但建议发送筛选后的摘要,而不是完整长帧明细。(developer.chrome.com)
3. CLS:记录位移来源,而不是页面快照
CLS 是无单位分数,关注可见内容发生的意外布局偏移。定位时可以保存相关元素类型、来源数量和规范化目标,但不建议默认上传截图或完整 DOM 快照。
text
cls.shiftValue
cls.sourceCount
cls.target
cls.elementType
cls.navigationType
图片、广告未预留尺寸、异步内容插入、字体替换和动态组件展开都是常见排查方向,但指标异常本身不能直接证明某段代码就是根因,仍需结合版本、资源加载信息和用户路径验证。
五、上报链路:可靠性不能以牺牲页面性能为代价
采集 SDK 应尽可能轻:少创建观察器、少做序列化、少占用主线程、少发送高基数明细。
建议将链路拆成:
text
采集 → 标准化 → 队列 → 发送
页面进入后台或即将离开时,应优先监听 visibilitychange,而不是依赖 unload 或 beforeunload。sendBeacon() 适合发送小型异步 POST 数据;需要自定义方法、请求配置或读取响应时,可以使用带 keepalive: true 的 fetch。(developer.mozilla.org)
ts
function flush() {
const payload = buildBatch();
if (!payload) return;
const body = JSON.stringify(payload);
const blob = new Blob([body], { type: 'application/json' });
const sent = navigator.sendBeacon('/rum/collect', blob);
if (!sent) {
fetch('/rum/collect', {
method: 'POST',
body,
headers: { 'content-type': 'application/json' },
keepalive: true,
}).catch(() => markAsDropped(payload));
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
sendBeacon() 适合小批量数据,浏览器对单次排队数据存在约 64 KiB 的限制,因此归因明细必须受控。(developer.mozilla.org)
最小发送策略可以是:指标进入内存队列;达到数量或时间阈值后批量发送;页面隐藏时立即 flush;失败时有限重试;离线时只保留小容量本地队列;服务端按 pageViewId + metricId + metric 幂等去重;attribution 使用更低采样率;单次请求设置大小上限。
不能承诺浏览器端上报"绝不丢失"。更实际的目标是,在不阻塞用户操作、不阻止 bfcache 且不显著增加页面开销的前提下,提高样本到达率,并用数据质量指标监控丢失程度。
六、聚合层:不要用平均值替代用户分布
RUM 数据本质上是用户访问体验的分布。官方建议使用 p75 判断 Core Web Vitals 是否达标,而不是用平均值、中位数或单个最差值替代。(web.dev)
建议聚合表至少包含:
text
window_start
window_end
metric
route
app_version
segment_dimensions
p50
p75
p90
good_rate
needs_improvement_rate
poor_rate
sample_count
affected_user_count
p75 用于主要体验判断,p90 用于观察长尾恶化,达标率表达用户比例,sample_count 判断统计可靠性,affected_user_count 则把指标变化转换为用户影响规模。
不要把不同采样率的原始数量直接相加。若使用采样,应保存 sampleRate 或权重,并在聚合时明确采用加权还是非加权口径。
七、切片策略:先回答"谁受影响",再追求更细归因
建议按以下顺序增加切片:应用版本、业务路由、浏览器内核与版本、设备档位、网络类型、地区或数据中心、入口页面、关键用户旅程,最后再下钻到组件或归因目标。
稳定维度进入常规报表和告警,诊断维度按异常样本或低比例用户采集。不要一开始就把完整 URL、任意 CSS selector、第三方资源 URL、用户 ID 和所有事件属性作为聚合维度。高基数会迅速增加存储、索引和分位数计算成本,也会增加隐私风险。
八、告警设计:同时监控体验质量与数据质量
1. 体验质量告警
体验告警应同时考虑绝对阈值、相对回退、连续窗口、最小样本量、持续时间和影响用户数。例如:
text
当前窗口 p75 > 绝对阈值
且 当前窗口样本量 >= 最小样本量
且 连续 N 个窗口成立
且 受影响用户数 >= 用户影响阈值
相对回退可以补充为:
text
当前 p75 > 基线 p75 × 1.20
且 绝对差值 > 最小变化量
同时使用比例和绝对差值,可以避免基线很小时出现没有实际意义的百分比波动。
2. 数据质量告警
需要单独监控上报量下降、字段缺失、路由或版本为空、重复样本比例升高、上报延迟异常、采样率不符合配置、attribution 长度或基数膨胀,以及指标浏览器覆盖范围异常。
例如,发布后 LCP p75 看似从 2.4 秒降到 1.8 秒,但上报量同时下降 70%,第一判断应是检查 SDK、接口和浏览器覆盖,而不是立即宣布性能提升。
九、告警消息必须自带定位证据
一条有用的告警不应只有"LCP 超过 2.5 秒",至少还应包含:
text
指标:LCP
时间窗口:某小时窗口
路由:/checkout
版本:web-某发布版本
切片:低端移动设备 / 4G / 美国东部
p75:3.4s
p90:5.8s
样本量:18,420
受影响用户数:约 6,900
变化:相比过去 7 天同窗口上升 31%
主要归因:首屏图片资源占比上升
关联信息:发布记录、错误率、资源时序、代表性样本
告警上下文的目标不是自动宣布根因,而是缩短从"发现异常"到"形成验证假设"的时间。定位时可依次关联版本、路由、人群、归因、错误与日志、用户旅程。
指标异常不能直接等同于代码缺陷。LCP 变慢可能由 CDN、第三方资源、地区网络、个性化内容或浏览器差异引起;INP 恶化也可能与用户交互路径变化有关。告警系统应提供证据链,而不是越权下结论。
十、隐私治理:归因信息越具体,风险越高
性能监控可能采集 URL、元素选择器、资源地址和环境信息。这些字段可能携带业务参数、用户标识或页面文本,因此应建立字段治理规则:
| 字段 | 默认策略 |
|---|---|
| URL 查询参数 | 默认删除,仅保留批准的非敏感参数 |
| 用户 ID | 能不采集则不采集;必须使用时采用不可逆或短期标识 |
| IP | 尽量由服务端做粗粒度地区解析后丢弃原值 |
| DOM 文本 | 默认禁止采集 |
| CSS selector | 只保留稳定、白名单化的组件标识 |
| 资源 URL | 仅保留允许域名、规范化路径和必要资源类型 |
| 第三方脚本 | 记录域名或资源类别,不默认保存完整地址 |
| 设备与网络 | 采用档位和枚举值,避免过细的指纹字段 |
自定义 selector 生成逻辑尤其需要谨慎。稳定的组件标识有助于聚合,但包含订单号、邮箱、昵称或动态文本的选择器会同时造成高基数和敏感数据泄露。优先使用开发团队显式声明的 data-performance-target 等稳定标识,而不是上传任意 DOM 路径。
十一、基础设施选型:先看能力,不要先选产品
无论使用数据仓库、列式数据库、时序系统还是现有可观测平台,至少应确认其支持批量事件接收、时间和人群切片、p75 与 p90 计算、采样权重、高基数控制、明细与聚合分层保存、权限和保留周期管理,以及发布、错误、日志和资源数据关联。
不要一开始就永久保存所有原始 entries。更合理的做法是:聚合层长期保存趋势和告警字段;导航样本保留中等周期用于定位;原始事件和 LoAF 明细按采样率、异常条件或短周期保留;经过验证的归因字段再进入长期维度。
十二、分阶段落地:先形成闭环,再扩展诊断能力
阶段一:核心指标可见
采集 LCP、INP、CLS,保存页面访问、导航、路由和版本;通过 visibilitychange 发送最终样本;按路由和版本计算 p75、p90、达标率;建立上报量、字段缺失和重复率监控。
阶段二:补齐生命周期和归因
增加 bfcache 恢复和后台边界处理,明确 SPA 路由统计口径,补充 LCP 最终候选、INP 交互目标与阶段耗时、CLS 位移来源,并建立归因字段白名单和采样策略。
阶段三:接入问题上下文
通过 appVersion、releaseId、route、pageViewId 等键关联发布记录、前端异常、接口日志、CDN 与资源时序、关键用户旅程和页面组件标识。
阶段四:建立可执行告警
增加绝对阈值和相对回退告警、样本量与影响用户数门槛、连续窗口和告警抑制、数据质量与体验质量分离、代表性样本和归因摘要,以及面向负责人或值班团队的通知路由。
结语:监控的终点不是"采集成功",而是"异常可解释"
从零搭建前端性能监控,最容易完成的是把三个指标发送到服务端,最容易遗漏的则是样本边界和解释上下文。
一套真正有用的 RUM 系统,应当把以下信息连在一起:
text
用户访问
→ 一次明确的导航样本
→ LCP / INP / CLS 及生命周期
→ 版本、路由、设备、网络与地区
→ 受控的元素、资源和交互归因
→ 分位数、样本量与用户影响
→ 告警证据与定位线索
这样,LCP、INP 和 CLS 才不再是孤立的三个数字,而会成为一条可以被查询、验证和追责的用户体验数据链。


参考资料
- How the Core Web Vitals metrics thresholds were defined:Google web.dev,介绍 Core Web Vitals 的指标语义、p75 评估方式和推荐阈值。
- Getting started with measuring Web Vitals:Google web.dev,介绍 RUM、实验室数据及
web-vitals库的使用方式。 - web-vitals README:GoogleChrome,说明指标对象、归因构建、导航类型和软导航支持。
- web-vitals CHANGELOG:GoogleChrome,记录 Soft Navigation 等版本能力变化。
- Why lab and field data can be different:Google web.dev,解释实验室数据与真实用户数据的差异。
- LargestContentfulPaint:MDN Web Docs,说明 LCP 候选、最终候选和跨域资源时序限制。
- Navigator: sendBeacon() method:MDN Web Docs,说明
sendBeacon、visibilitychange和fetch keepalive的适用边界。 - Long Animation Frames API:Chrome for Developers,介绍 LoAF 与 INP 诊断、脚本归因和数据筛选方式。