前端数据埋点(7):Web Vitals——页面慢不慢,不是再包一层 fetch

上一篇:web-vitals------三个数怎么算出来

相关:一次点击如何变成一条埋点 · 上报

系列:前端数据埋点 · 第 7 篇

第 6 篇已经会订 onLCP / onINP / onCLS,也知道 Metric 里有 value 和最大元素。本篇接上去:数还会变,何时才变成一条埋点,切标签和后退缓存不是一回事。代码取自 Sentry JavaScript SDK 对 web-vitals 的接入,讲机制,不是产品接入。


先说结论

页还开着的时候,三个数还能变。LCP 可能先是标题 800ms,大图上来变成 2.4s。每次变都当一条 EVENT 发出去,同一页会留下一串越来越大的值,服务端再聚合也分不清这一页到底有多慢。

做法和第 1 篇的 DURATION 一样:过程中只改内存里的当前值,页被切走、关掉、或站内跳到下一页时,发一次终值。关页那次 POST 仍走第 4 篇的原生 fetch + keepalive

切到别的标签再切回来,只是 hiddenvisible,同一套数,不会重算。后退缓存是另一件事:点链接离开文档后,后退时旧页从内存解冻,库会再给一套很快的数。这份实现丢掉,不是把当前 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 篇的 MetricratingreportAllChanges 不再重复。本篇只用这几个:

是什么 解决什么
终值 这一页可以定稿时的那一个数 中间值发出去,聚合会乱
后退缓存(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 篇写过:库听的是 pageshowevent.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);
  };
}

如果以后要单独看「从缓存回来」的体感,再为这个类型建模型。默认丢掉即可。切标签回来没有这套新数,不用处理。

发出去时至少带上:指标名、valuerating、当前路由 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 三个函数、Metricvalue 和最大元素怎么来
(1)管线 DURATION 这一类时长事件
(3)行为栈 DOM 路径;vitals 终值不要当 click 进栈
(4)上报 关页发出、keepalive、采样
(5)框架钩子 路由 name 挂在 vitals 上,才知道是哪一页慢

到这里,错误、业务点击、页面曝光、体感时长都有了入口。服务端怎么聚合、怎么告警,仍是信封出去之后的事。


你可以从这里带走什么?

  1. 第 6 篇算出的数,页开着时还会变。reportAllChanges 只刷新内存;信封等 hidden 或离开时发一次。
  2. INP 是最差一次交互,不是第一次点击,也不是点击量。
  3. 切标签只是看不见。bfcache 是离开文档后,后退或前进把冻住的页解冻;库会再给一套很快的数,默认丢掉,当前值并没有被清零。
  4. 业务步骤用 performance.measure。关页发出仍要 keepalive

仓库与相关文档


本文是「前端数据埋点」第 7 篇:三个数何时发终值。不展开完整 Tracing span 模型和离线 Performance 日志。

相关推荐
右耳朵猫AI1 分钟前
Web前端周刊2026W38 | React 19.3 发布、StyleX 深潜、jsdom 30.1 提速
前端·javascript·react.js·typescript·node.js
人工智能培训33 分钟前
大语言模型:从语言理解到通用智能的跃迁
linux·服务器·前端·人工智能
一拳不是超人43 分钟前
做独立开发三个月,我的第一个产品终于有了 1000 个用户
前端·程序员
Amos_Web1 小时前
Rspack 源码解析(十):Hash 与 Asset 生成
前端·rust·源码阅读
福兮说1 小时前
用 IndexedDB 存用户的文件,我踩过的五个坑
前端·javascript
闪耀之光M781 小时前
前端依赖自动导入:unplugin-auto-import
前端
Ricon组态薄荷糖1 小时前
Ricon组态系统:工业组件开发指南与实践
前端·后端·物联网
flash俊杰1 小时前
Electron 桌面应用的进程模型:为什么 fork Next.js standalone
前端
沙蒿同学1 小时前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持1 小时前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright