可见性状态转换:在没有可靠 Beacon 的情况下测量页面浏览
pagehide、visibilitychange 与 Beacon API 在各浏览器中的行为并不一致。本文讲解如何在不使用 Cookie 和持久标识符的前提下,可靠地记录会话结束。
记录一个页面被浏览过很容易。要记录用户究竟何时离开——并且在切换标签页、退到后台、直接关闭等各种情形下都可靠——则是 Web 分析中比较棘手的问题之一。这很重要,因为参与度指标(停留时长、跳出、滚动深度)都依赖于捕获一个干净的会话结束信号。做错了,你要么丢数据,要么把数据吹大。
Monoid 在不使用 Cookie、localStorage 或任何持久标识符的情况下解决了这个问题。这一约束反而让事情变简单了:我们从不需要跨页面加载去拼合一个会话,所以我们唯一关心的,就是在每个页面生命周期内发出一条准确的最终 beacon。下面是这些浏览器原语的实际行为,以及坑在哪里。
为什么 unload 是错误的事件
历史上的做法是监听 unload 或 beforeunload,然后发一个同步 XHR。如今这种做法是有实质危害的。unload 事件在移动端并不可靠:当用户把标签页退到后台、操作系统回收它时,unload 常常根本不会触发。更糟的是,注册 unload 处理函数会让页面在多个浏览器中失去进入前进/后退缓存(bfcache)的资格,损害导航性能。
web.dev 关于 Page Lifecycle API 的指南说得很明确:把 visibilitychange 转为 hidden 当作你能观察到的最后一个可靠事件。不要指望 unload 或 pagehide 在移动端一定会触发。
visibilitychange 与 pagehide 的组合
稳健的模式是监听 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,失败再回退到带 keepalive 的 fetch。
一个极简的无 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。
测量离开这件事,而不是人。浏览器已经把你需要的一切都给你了。
Comments
Loading comments…