相关:一次点击如何变成一条埋点 · 上报
系列:前端数据埋点 · 第 7 篇
第 6 篇已经会订
onLCP/onINP/onCLS,也知道Metric里有value和最大元素。本篇接上去:数还会变,何时才变成一条埋点,切标签和后退缓存不是一回事。代码取自 Sentry JavaScript SDK 对 web-vitals 的接入,讲机制,不是产品接入。
先说结论
页还开着的时候,三个数还能变。LCP 可能先是标题 800ms,大图上来变成 2.4s。每次变都当一条 EVENT 发出去,同一页会留下一串越来越大的值,服务端再聚合也分不清这一页到底有多慢。
做法和第 1 篇的 DURATION 一样:过程中只改内存里的当前值,页被切走、关掉、或站内跳到下一页时,发一次终值。关页那次 POST 仍走第 4 篇的原生 fetch + keepalive。
切到别的标签再切回来,只是 hidden ↔ visible,同一套数,不会重算。后退缓存是另一件事:点链接离开文档后,后退时旧页从内存解冻,库会再给一套很快的数。这份实现丢掉,不是把当前 vitals 清零。
一句话:Web Vitals 是这一页体感如何的终值,不是每次绘制、每次点击的埋点。
1. 三层里,本篇只谈最后一层
浏览器出原料(PerformanceObserver)
→ 库收成 Metric(第 6 篇的 onLCP / onINP / onCLS)
→ SDK 只写内存;页变成 hidden / 离开时发一次终值
第 6 篇把中间一层写完了。SDK 订的就是那三个函数。Sentry 还给 LCP、CLS 开了 reportAllChanges: true,好刷新内存。那是订阅方的选择,不是库的默认。
没有 Performance API 就放弃,不要手搓假 LCP。终值不要当 click 进行为栈。
2. 概念词典
第 6 篇的 Metric、rating、reportAllChanges 不再重复。本篇只用这几个:
| 词 | 是什么 | 解决什么 |
|---|---|---|
| 终值 | 这一页可以定稿时的那一个数 | 中间值发出去,聚合会乱 |
| 后退缓存(bfcache) | 离开这个文档后,浏览器把整页冻在内存里,点后退或前进时解冻,JS 不重跑 | 和切到别的标签不是一回事 |
| 软导航 | 文档没换,路由换了(SPA) | 浏览器默认的 LCP 只认硬加载那一次 |
3. 回调密,不等于已经上报
web-vitals 默认会等标签页进后台,再回调一次。SDK 给 LCP、CLS 开了 reportAllChanges: true:值一变就通知,方便改内存里的当前值。通知不等于 POST。
摘自 packages/browser-utils/src/instrumentation/performanceObserver.ts:
240:251:packages/browser-utils/src/instrumentation/performanceObserver.ts
function instrumentCls(): StopListening {
return onCLS(
withoutBfcache(metric => {
triggerHandlers('cls', {
metric,
});
_previousCls = metric;
}),
// We want the callback to be called whenever the CLS value updates.
// By default, the callback is only called when the tab goes to the background.
{ reportAllChanges: true },
);
}
LCP 同样。信封是另一处触发的:页变成 hidden(切走、关掉、合上笔记本),或者站内软跳转开始。后者只收这一次进入页的 LCP,下一页是新的一页。
摘自 packages/browser-utils/src/web-vitals/reportEvents.ts:
34:45:packages/browser-utils/src/web-vitals/reportEvents.ts
onHidden(() => {
_runCollectorCallbackOnce('pagehide');
});
const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => {
// we only want to collect LCP if we actually navigate. Redirects should be ignored.
if (!options?.isRedirect) {
_runCollectorCallbackOnce('navigation');
unsubscribeStartNavigation();
unsubscribeAfterStartPageLoadSpan();
}
});
_runCollectorCallbackOnce 保证同一页只发一次。落到埋点上,就是 track('DURATION', { trackId: 'checkout_lcp', value }),不是每 200ms 一条。
INP 没有开 reportAllChanges。库照旧在页进后台时给出终值,Sentry 在那次 onINP 里发出去。对你来说仍是离开或隐藏时那一下,不是每次点击一条。
关页那次要带 keepalive。最后一跳的 LCP 若常丢,统计会偏向那些一直挂着、没关页的人。
结算页上实际是这样走的:0.8s 标题成为 LCP,内存写成 800,不发;2.4s 主图更大,改成 2400,还不发;用户点了三次,最差一次 350ms,INP 停在 350。直到切走、关掉或路由离开,才把这三个终值发出去。
接到自己的管线上:
js
onLCP((metric) => {
if (metric.navigationType === 'back-forward-cache') return
current.lcp = metric
})
// 页 hidden / 离开时
track('DURATION', {
trackId: 'checkout_lcp',
value: current.lcp.value,
rating: current.lcp.rating,
})
4. 切到别的标签,不是后退缓存
页从看不见变回看得见,不等于发生了后退缓存。
切到旁边的标签再切回来,文档还在,只是 hidden 过一次。终值在第一次 hidden 时已经发过,回来不会重算,也不会再 POST。
点了站内链接离开这个文档,再按浏览器后退,有两种结果。一种是整页重新加载,旧文档没了,这是新的一页,vitals 正常再测,Navigation Timing 上的类型是 back_forward。另一种是旧页还冻在内存里,JS 没重跑,整页解冻------这才是 bfcache。
名字里的 back-forward,指浏览器给后退、前进留的整页快照。切标签不走这条路。软导航也不是 bfcache:软导航是没离开这个文档。
navigationType === 'back-forward-cache' 不是 Sentry 算的,更不是 performance.getEntriesByType('navigation')[0].type。那条 entry 没有这个取值。第 6 篇写过:库听的是 pageshow,event.persisted === true 才表示解冻,然后新建一套 Metric 再回调。
离开文档时,终值多半已经发出去了,「这一页发过」也记下了。解冻后 JS 堆还在,旧数字还在,并没有被清零。库只是又塞来一套新 id 的数。画面早就画好了,这套数会偏快。混进这一页的统计,会把真的慢稀释掉。
这份实现看到这个类型就直接返回,不改内存,也不发信封:
231:237:packages/browser-utils/src/instrumentation/performanceObserver.ts
function withoutBfcache(callback: (metric: Metric) => void): (metric: Metric) => void {
return metric => {
if (metric.navigationType === 'back-forward-cache') {
return;
}
callback(metric);
};
}
如果以后要单独看「从缓存回来」的体感,再为这个类型建模型。默认丢掉即可。切标签回来没有这套新数,不用处理。
发出去时至少带上:指标名、value、rating、当前路由 name(第 5 篇)、LCP / INP 的 DOM 路径(元素仍从第 6 篇的 metric.entries 取)。vitals 每 PV 最多三条,比满屏 EVENT 稀,采样可以是 1 或略低。错误仍用 1。
5. 业务自己的时长:mark / measure
核心三个只覆盖页级体感。支付从点下到出结果,要用自己的时间:
js
performance.mark('pay_start')
await doPay()
performance.mark('pay_end')
performance.measure('pay', 'pay_start', 'pay_end')
const entry = performance.getEntriesByName('pay').pop()
track('DURATION', { trackId: 'pay_duration', value: entry.duration })
这是 User Timing,和 LCP 不是同一个观察者。浏览器没有 performance 就不要发假 0。也不要把 pay_duration 叫成 LCP:一个是交互链路,一个是首屏最大块。
和前作怎么接
| 篇 | 补哪一段 |
|---|---|
| (6)web-vitals | 三个函数、Metric、value 和最大元素怎么来 |
| (1)管线 | DURATION 这一类时长事件 |
| (3)行为栈 | DOM 路径;vitals 终值不要当 click 进栈 |
| (4)上报 | 关页发出、keepalive、采样 |
| (5)框架钩子 | 路由 name 挂在 vitals 上,才知道是哪一页慢 |
到这里,错误、业务点击、页面曝光、体感时长都有了入口。服务端怎么聚合、怎么告警,仍是信封出去之后的事。
你可以从这里带走什么?
- 第 6 篇算出的数,页开着时还会变。
reportAllChanges只刷新内存;信封等 hidden 或离开时发一次。 - INP 是最差一次交互,不是第一次点击,也不是点击量。
- 切标签只是看不见。bfcache 是离开文档后,后退或前进把冻住的页解冻;库会再给一套很快的数,默认丢掉,当前值并没有被清零。
- 业务步骤用
performance.measure。关页发出仍要keepalive。
仓库与相关文档
- 机制例子 :sentry-javascript(
packages/browser-utils/src/instrumentation/performanceObserver.ts、packages/browser-utils/src/web-vitals/reportEvents.ts) - 指标定义 :web.dev/articles/vitals
- 相关前作 :(6)web-vitals · (4)上报 · (5)框架钩子
本文是「前端数据埋点」第 7 篇:三个数何时发终值。不展开完整 Tracing span 模型和离线 Performance 日志。