返回博客

navigator.sendBeacon 与 fetch keepalive:不阻塞卸载的可靠分析数据传输

如何在不拖慢页面跳转的前提下发送能挺过页面卸载的分析数据——以及为何 sendBeacon 与 fetch keepalive 的选择对无 Cookie 采集至关重要。

在页面存活期间采集一次页面浏览或一个事件很容易。真正棘手的是,在用户跳转离开、关闭标签页,或在移动端将应用切到后台的那一刻,把最后这份数据可靠地送出去。处理不当,要么丢数据,要么更糟——拖慢了用户本想执行的那次导航。本文比较两种可行方案——navigator.sendBeacon() 与带 keepalive: truefetch()——并说明 Monoid 的追踪脚本实际使用哪一种、为什么,且全程不依赖 Cookie、存储或指纹识别。

为什么卸载是一种特殊情况

文档开始卸载时,浏览器正在关闭进程。历史上,同步 XMLHttpRequest 曾被滥用于此,用来阻塞导航直到请求完成,这严重损害了响应速度。现代浏览器已经在积极遏制这种做法,unload 期间发起的请求也经常被直接取消。Beacon 规范正是为解决这个问题而生:它允许页面调度一个请求,用户代理会保证以异步方式尝试发送,且不会阻塞下一个页面的加载。

对分析采集的实际意义是:永远不要指望从 beforeunload/unload 处理函数中发出的普通 fetchXHR 能送达。它很可能根本就没能离开用户的设备。

navigator.sendBeacon

sendBeacon 就是为此专门设计的。它将一个小的 POST 请求排入队列,并立即返回一个布尔值,该值只表示请求是否成功入队——而不表示请求本身是否成功。此后浏览器会在后台传输它,即便页面早已消失。

const ok = navigator.sendBeacon('/collect', payload);

优点:

  • 传输过程不依赖于文档的生命周期。
  • 优先级低,被设计为不与下一次导航争抢资源。
  • 没有响应需要处理,因此无需等待任何东西。

根据规范与浏览器实现,存在以下限制:

  • 仅支持 POST。无法设置任意方法。
  • 对请求头的控制有限。Content-Type 是根据 payload 类型推断出来的(例如,Blob 可以间接影响它,但你无法自由设置自定义请求头)。
  • payload 会计入用户代理的数据上限;超size 的 beacon 会返回 false

对于无 Cookie 分析来说,这通常就够了——不少追踪脚本正是这样实现的,且从未回头。Monoid 出于一个具体原因没有这样做:sendBeacon 无法表达 mode: 'cors',而 Monoid 的安装代码片段允许站点将 data-api-url 指向与宿主页面不同的源。正是这一条限制,决定了下面的比较结果。

带 keepalive 的 fetch

Fetch 标准定义了一个 keepalive 标志,可以让请求的存活时间超过发起它的页面,从而在拥有更丰富 API 的同时,获得与 sendBeacon 类似的可靠性。

fetch('/collect', {
  method: 'POST',
  keepalive: true,
  headers: { 'Content-Type': 'application/json' },
  body: payload,
});

优点:

  • 对方法、请求头和请求体拥有完全控制权。
  • 可以拿到一个真正的 Response 供检查(不过在卸载期间,通常不应该去等待它)。

限制:

  • Fetch 规范将同一文档下所有正在进行的 keepalive 请求的请求体总大小限制为 64 KiB。超出限制,fetch 会被拒绝。这是按文档计算的总预算,多个并发的 keepalive 请求会共享这一额度。
  • 历史上,keepalive 的支持一直落后于 sendBeacon,并且存在一些实现上的差异。请在你的用户实际使用的浏览器上进行测试。

这正是 Monoid 的追踪脚本在每一个请求上使用的方式——/collect/duration/event 一律如此:

fetch(endpoint, {
  method: 'POST',
  body: JSON.stringify(payload),
  headers: { 'Content-Type': 'application/json' },
  keepalive: true,
  mode: 'cors',
});

上一节中真正起决定作用的是 mode: 'cors',而不是 payload 大小或请求头数量——这一点值得记住,因为大多数文章都把这个选择完全归结为大小问题。

该如何选择

一条务实的经验法则:当你不需要任何返回结果时,用 sendBeacon 做发后不理式的遥测;只有在你确实需要自定义请求头、更大的结构化请求,或需要控制跨源 mode 时,才使用带 keepalivefetch。无论选哪种,都要让 payload 保持很小——远低于 keepalive 的 64 KiB 预算——因为分析事件本身在设计上就应当是最小化的。数据最小化不仅仅是一种合规姿态,它正是让卸载期间可靠传输成为可能的前提。Monoid 恰好是这条规则本就预留出的例外:payload 很小,响应也从不被读取,这正是 sendBeacon 为之设计的场景——但对 mode: 'cors' 的支持最终占了上风。

应该监听的正确事件

不要依赖 unload。它并不可靠,而且会破坏前进/后退缓存(bfcache)。Page Lifecycle 指南建议监听 visibilitychange,并把切换到 hidden 状态的那一刻当作最后一个可靠的数据发送时机。无论是桌面端关闭页面、切换标签页,还是移动端把应用切到后台——这些 unload 永远不会触发的场景——这种方式都能稳定生效。

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

Monoid 的追踪脚本正是遵循这一模式:一个 visibilitychange 监听器与一个 beforeunload 兜底方案并存,因此无论标签页是在移动端被切到后台,还是在桌面端被彻底关闭,都会上报一次时长样本。

这如何契合无 Cookie 的模型

由于 Monoid 从不读取 Cookie 或存储、也从不做指纹识别,每个请求携带的信息仅限于本就会暴露的内容——一个粗粒度的事件,加上浏览器本来就会发送的请求头。没有任何标识符需要持久化,因此我们也从不需要双向交互来同步状态。这正是为什么一旦 mode: 'cors'sendBeacon 排除在外,带 keepalivefetch 就成了顺理成章的选择:一次性的 POST 请求、其响应从不被检查,对我们而言这不是限制,而恰恰是让传输可靠性与隐私设计原则(privacy by design)保持一致的关键。

Sources

Comments

Loading comments…