返回博客

可见性状态转换:在没有可靠 Beacon 的情况下测量页面浏览

pagehide、visibilitychange 与 Beacon API 在各浏览器中的行为并不一致。本文讲解如何在不使用 Cookie 和持久标识符的前提下,可靠地记录会话结束。

记录一个页面被浏览过很容易。要记录用户究竟何时离开——并且在切换标签页、退到后台、直接关闭等各种情形下都可靠——则是 Web 分析中比较棘手的问题之一。这很重要,因为参与度指标(停留时长、跳出、滚动深度)都依赖于捕获一个干净的会话结束信号。做错了,你要么丢数据,要么把数据吹大。

Monoid 在不使用 Cookie、localStorage 或任何持久标识符的情况下解决了这个问题。这一约束反而让事情变简单了:我们从不需要跨页面加载去拼合一个会话,所以我们唯一关心的,就是在每个页面生命周期内发出一条准确的最终 beacon。下面是这些浏览器原语的实际行为,以及坑在哪里。

为什么 unload 是错误的事件

历史上的做法是监听 unloadbeforeunload,然后发一个同步 XHR。如今这种做法是有实质危害的。unload 事件在移动端并不可靠:当用户把标签页退到后台、操作系统回收它时,unload 常常根本不会触发。更糟的是,注册 unload 处理函数会让页面在多个浏览器中失去进入前进/后退缓存(bfcache)的资格,损害导航性能。

web.dev 关于 Page Lifecycle API 的指南说得很明确:把 visibilitychange 转为 hidden 当作你能观察到的最后一个可靠事件。不要指望 unloadpagehide 在移动端一定会触发。

visibilitychangepagehide 的组合

稳健的模式是监听 visibilitychange 并检查 document.visibilityState === 'hidden',再把 pagehide 作为次级信号。每当页面转为隐藏,你就把需要发送的内容冲刷出去。MDN 的 Page Visibility API 文档确认:标签页退到后台或浏览器被最小化时 visibilitychange 会触发——这是对“用户不再看了”最接近的近似。

麻烦之处在于:转为隐藏在一次会话中可能触发多次(切走、切回、再切走)。所以你必须让冲刷操作具备幂等性,或者做防抖,或者只发送自上次冲刷以来的增量。由于 Monoid 不保存任何按用户维度的状态,我们发送单条自包含的负载,并让边缘在请求作用域内去重。

从濒死页面发送数据:navigator.sendBeacon

你不能在 visibilitychange 期间发起一个异步 fetch 并指望它完成——浏览器可能先把页面拆掉。Beacon API 规范存在的意义正在于此。navigator.sendBeacon(url, data) 会把一个小的 POST 排入队列,浏览器保证即便文档已经消失也会尝试发送,且不阻塞主线程、不拖慢导航。

Beacon 有一些值得尊重的限制:

  • 负载大小。 规范允许用户代理拒绝超过实现自定义上限(通常为 64 KB)的 beacon。把负载保持得极小——Monoid 的只有几百字节。
  • 方法。 Beacon 始终是 POST。如果你的边缘端点期望 GET,请做适配。
  • 无响应。 你读不回任何东西。发完即忘。

现代的回退方案是 fetch(url, { keepalive: true })Fetch 标准将其定义为允许请求的生命周期超过文档。keepalive 提供了 Beacon 所缺少的请求头和方法,但共享同样的大小上限。先用 Beacon,失败再回退到带 keepalivefetch

一个极简的无 Cookie 实现

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // 无标识符、无 Cookie、无指纹
    visibleMs: Math.round(performance.now())
  });
  const ok = navigator.sendBeacon('/collect', payload);
  if (!ok) {
    fetch('/collect', { method: 'POST', body: payload, keepalive: true });
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);

请注意这里没有什么:没有 unload 监听器,因此保留了 bfcache 资格;没有在多次加载之间存储的 ID。visibleMs 的数值来自 performance.now(),一个既不需要挂钟时间戳、也不需要持久状态的单调时钟。

为什么这契合隐私优先的分析

因为 Monoid 从不把会话缝合起来,每条 beacon 都是一次完整、可丢弃的观测。没有跨页面标识符需要保护,所以丢失一条 beacon 只让你损失一个数据点,而不是毁掉一份用户画像。visibilitychange 加 Beacon 的模式既能给出准确的停留时长测量,又能让页面保持快速且对 bfcache 友好——而这本身就会改善你可能正想测量的那些 Core Web Vitals。

测量离开这件事,而不是人。浏览器已经把你需要的一切都给你了。

Sources

Comments

Loading comments…