可见性状态转换:在没有可靠 Beacon 的情况下测量页面浏览
pagehide、visibilitychange 与 Beacon API 在各浏览器中的行为并不一致。本文讲解如何在不使用 Cookie 和持久标识符的前提下,可靠地记录会话结束。
记录一个页面被浏览过很容易。要记录用户究竟何时离开——并且在切换标签页、退到后台、直接关闭等各种情形下都可靠——则是 Web 分析中比较棘手的问题之一。这很重要,因为参与度指标(停留时长、跳出、滚动深度)都依赖于捕获一个干净的会话结束信号。做错了,你要么丢数据,要么把数据吹大。
Monoid 在不使用 Cookie、localStorage 或任何持久标识符的情况下解决了这个问题。这一约束反而让事情变简单了:我们从不需要跨页面加载去拼合一个会话,所以我们唯一关心的,就是在每个页面生命周期内发出一条准确的最终样本。下面是这些浏览器原语的实际行为、坑在哪里,以及 Monoid 自己的追踪脚本实际用了哪一种。
为什么 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 采用的是最后一种做法:驱动停留时长样本的计时器会在每次 visibilitychange 转换时重置,因此同一页面生命周期内之后再触发的第二次 hidden 事件,只会报告自上次冲刷以来的时间。Monoid 监听 visibilitychange(与已有的 beforeunload 兜底并存),但并未额外添加 pagehide 监听器——两者在真正要紧的路径上重叠度已经足够高,再加一个监听器只会重新引入下面的重复计数问题,却覆盖不了明显更多的场景。
从濒死页面发送数据: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——Monoid 实际上反其道而行之,原因下一节会解释。
一个极简的无 Cookie 实现
下面是一个综合了以上所有内容的参考模式——先 sendBeacon,fetch keepalive 作为其回退,同时由 visibilitychange 和 pagehide 触发,并带有一个防止重复触发导致重复发送的保护:
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 自己的追踪脚本走的是相关但不同的路线:只用 fetch keepalive(不用 sendBeacon——它无法表达跨源安装所需要的 mode: 'cors'),没有 sent 保护标志,并且计时器会在每次可见性转换时重置,而不是只触发一次就归于沉默(为清晰起见,下面省略了同意与 Do Not Track 的过滤逻辑):
function dur() {
fetch('/duration', {
method: 'POST',
body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
mode: 'cors',
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') dur();
t = Date.now();
});
window.addEventListener('beforeunload', dur);
每次转换都重置 t,起到的作用和上面的 sent 保护标志一样,却不需要一个标志位:每次调用只报告自上次计时器重置以来的时间段,因此同一次关闭标签页触发的 visibilitychange 加上随后的 beforeunload,只会报告一个真实时间段和一个无害的接近于零的时间段,而不是重复计数。
为什么这契合隐私优先的分析
因为 Monoid 从不把会话缝合起来,每次请求都是一次完整、可丢弃的观测。没有跨页面标识符需要保护,所以丢失或重复的一条样本只让你损失一个数据点,而不是毁掉一份用户画像。Monoid 实际发布的 visibilitychange 加 beforeunload 模式,既能给出准确的停留时长测量,又能让页面保持快速且对 bfcache 友好——而这本身就会改善你可能正想测量的那些 Core Web Vitals。
测量离开这件事,而不是人。浏览器已经把你需要的一切都给你了。
Comments
Loading comments…