页面关闭前埋点丢失:sendBeacon验收

**直答:**页面关闭时浏览器会取消未完成请求,XHR 必丢。sendBeacon 交给后台排队更可靠,但只保证发出,不保证到达。

最后一批访客的停留时长全是 1~2 秒------不是用户真的划走了,是关闭页面前那一刻的「离开事件」根本没发出去。服务端只收到了进来的事件,没收到离开的事件,停留时长就被截断在最后一次活动上。

不写长篇大论,下面给一份可以直接复制到团队 Wiki 里的验收清单。

一、为什么 XHR 在页面关闭时一定会丢

浏览器在卸载页面时会做一件事:取消所有还没完成的网络请求。你在 beforeunload 里发一个 XHR,请求刚构造出来,页面已经被关了,请求被浏览器掐断。这不是网络问题,也不是 SDK bug,是浏览器故意的------它不想让一个即将销毁的页面继续占用网络。

历史上大家的做法是在页面里塞一个 1×1 像素的图片,把埋点参数拼在 src 上。图片请求是同步的,浏览器会等它发出去再关页面。但这种做法在 HTTPS、跨域、大数据包时都不稳定,现在已经被 W3C 的 Beacon API 正式取代。

要理解这件事,得先把页面关闭时的事件顺序理清楚。现代浏览器在页面离开时会依次触发 beforeunload、pagehide、unload 三个事件。beforeunload 原本用来弹「确定离开吗」的确认框,但 Chrome 117 之后对这类弹窗加了严格限制,移动端浏览器更是经常直接不触发它;unload 则在页面真正卸载时触发,可一旦走到这里,页面资源随时可能被回收,再发请求已经太晚。pagehide 夹在中间,它在页面被隐藏、进入往返缓存(bfcache)、或者被关闭时都会触发,时机更早、覆盖面更全,也是 web.dev 官方推荐用来做离开上报的事件。把上报绑在 pagehide 上,等于在浏览器还愿意配合的时候就把数据交出去。

二、sendBeacon 怎么写

复制代码
document.addEventListener('pagehide', function () {
  const payload = JSON.stringify({
    event: 'page_hide',
    path: location.pathname,
    ts: Date.now()
  });
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/logcount.html', new Blob([payload], { type: 'text/plain' }));
  } else {
    // 老浏览器降级方案
    new Image().src = '/logcount.gif?data=' + encodeURIComponent(payload);
  }
});

几个要点:

  • 绑 pagehide 而不是 beforeunload------后者在现代浏览器里已经不可靠,Chrome 117 起对它的弹窗都做了限制;
  • body 用 Blob 包一下,Content-Type 设成 text/plain 可以避开 CORS 预检;
  • sendBeacon 只支持 POST,不能自定义 Header,鉴权 token 要拼在 URL 或 body 里;
  • 单个 beacon 的 body 不要超过 64KB,超过会被浏览器静默丢弃。

这段代码里有几个选择需要解释一下。用 pagehide 而不是 unload,是因为 unload 触发得太晚,浏览器已经在回收资源;用 new Image() 兜底而不是继续用 XHR,是因为图片请求会被浏览器在卸载时尽量发完,这是老时代的兼容手段。Blob 的 type 写成 text/plain 是一个取巧的办法:这种简单内容类型不会触发跨域预检请求,beacon 能直接发出去,服务端再自己解析 body 里的 JSON。

实际项目里不会每次都手写 Blob,通常会封装一个统一的上报函数,把 JSON 序列化、类型声明、降级逻辑都收进去,业务侧只关心传什么事件。下面这个封装做了三件事:先把数据序列化成 JSON 并用 Blob 声明类型,优先走 sendBeacon,不支持时退回 fetch 的 keepalive,最后再退到图片像素。

复制代码
function reportBeacon(url, data) {
  const payload = new Blob(
    [JSON.stringify(data)],
    { type: 'application/json' }
  );
  if (navigator.sendBeacon && navigator.sendBeacon(url, payload)) {
    return; // 浏览器已接受后台发送
  }
  if (window.fetch) {
    // 降级:fetch 开启 keepalive,允许页面卸载后继续发送
    fetch(url, { method: 'POST', keepalive: true, body: payload });
    return;
  }
  // 最后兜底:图片像素(不推荐作为主路径)
  new Image().src = url + '?d=' + encodeURIComponent(JSON.stringify(data));
}

document.addEventListener('pagehide', function () {
  reportBeacon('/logcount.html', {
    event: 'page_hide',
    path: location.pathname,
    ts: Date.now()
  });
});

这里有两个细节容易踩。一是 Blob 的 type 要显式写成 application/json,服务端才能按 JSON 解析;如果留空或写成 text/plain,后端的 JSON 中间件可能直接报错。二是 sendBeacon 的返回值只是「浏览器接不接受这个任务」,true 不代表服务端收到了,所以关键数据不要只靠它,用户真正点按钮、下单这种动作发生时就该立刻发,而不是攒到关闭时才交。

二·补:错误写法与正确写法对照

把团队里常见的写法摆在一起对照,问题会更直观。下面这张表左边是线上常见的错误写法,右边是修正后的写法,每一行都是真实排查时反复出现的坑。

错误写法 问题 正确写法
在 unload 里发 XHR 页面已开始销毁,请求被取消 绑 pagehide,用 sendBeacon
fetch 不带 keepalive 卸载时 fetch 同样被掐 fetch 加 keepalive:true
直接传字符串当 body Content-Type 不对,后端解析失败 用 Blob 声明 application/json
只在 beforeunload 上报 移动端常不触发,数据大量丢失 pagehide 为主,beforeunload 兜底
一次 beacon 塞整屏数据 超过体积上限被静默丢弃 拆包,只发关键字段

这张表可以直接贴进 code review 的 checklist。新人最容易犯的不是不知道 sendBeacon,而是知道了却把它绑在 unload 上、或者忘了用 Blob 包 JSON,结果数据照样丢,还以为是 API 本身不可靠。

(如下图)
关闭页埋点的三段验收路径

三、三段验收清单

写完代码不算完,按下面三张清单逐项打勾,任何一段漏了都不能上线。

三段的意思是:客户端发出去不等于服务端收到,服务端收到不等于数据真的进了分析库。很多团队只看 DevTools 里那条请求变绿就以为万事大吉,结果过两天发现看板上根本没有对应事件------问题出在服务端解析失败或者入库管道断了,而客户端那一端是绿的。所以三段必须分别验收,任何一段漏了都不能上线。

第一段:客户端「真的发出去了」

这一段的具体操作是这样的:打开 Chrome DevTools 切到 Network 面板,先勾选面板上方的 Preserve log(保留日志)------这一步最关键,默认情况下页面一关闭,请求列表会被清空,你根本看不到关闭瞬间发出去的那条请求。接着正常操作页面,再通过关标签页、点外链或点浏览器返回触发离开,回到 Network 面板过滤你的打点域名。这时应该能看到一条 POST 请求,发起类型(Initiator)里能看到 beacon 相关字样。点开这条请求,确认 Status 是 200 或 (pending) 而不是 (canceled),再切到 Payload 标签核对 body 里的事件名、页面路径和时间戳是否完整。如果看到的是 (canceled),说明上报还在被浏览器取消,多半是事件绑错了或者没用 sendBeacon。

  • Chrome DevTools Network 过滤埋点域名,关闭页面时能看到一条 POST 请求
  • 请求状态是 (pending) 或 200,不是 (canceled)
  • 请求 body 完整,没有被截断
  • 切换到移动端 Safari 复测一遍(iOS 的限制和 Chrome 不同)
  • 断网状态下关闭页面,请求不应该阻塞 UI

第二段:服务端「真的收到了」

  • 服务端 access log 里能看到对应的 POST 请求记录
  • 请求到达时间和客户端关闭时间差在 1 秒内
  • 跨域 OPTIONS 预检没有被触发(beacon 用 text/plain 本应免预检)
  • 用 curl 模拟 beacon 请求,服务端能正常解析 body

第三段:数据「真的落库了」

  • 后台事件列表里能查到这条 page_hide 事件
  • 把同一时间窗口的 page_view 和 page_hide 配对,配对率应在 90% 以上
  • 停留时长分布不再集中在 1~2 秒这种异常区间
  • 观察 24 小时,关闭事件量与 PV 比例稳定,不突增突降

(如下图)
三段验收各自的检查项与通过标准

四、beacon 也救不了的场景

sendBeacon 不是万能的。下面这些情况它照样丢:

场景 会丢吗 对策
用户正常关闭标签页 基本不丢 靠 pagehide + beacon
用户切换到后台一段时间 可能丢 visibilitychange 也打一次
用户直接杀浏览器进程 会丢 关键事件在用户操作时立即发,不要等关闭
断网状态关闭 会丢 本地队列 + 下次启动补发
请求 body 超过 64KB 会被静默丢弃 拆包或只发关键字段

还有一个移动端特有的场景值得单独说。用户在手机上把浏览器切到后台、或者直接切到别的 App,这时候页面并没有被关闭,pagehide 不一定触发,但人其实已经走了。这种情况下建议同时监听 visibilitychange,当 document.visibilityState 变成 hidden 时再补一次离开上报。否则移动用户的停留时长会被算得偏长------系统以为他还在看,其实早就切走了。把 pagehide 和 visibilitychange 两个事件都绑上同一份上报逻辑,移动端的数据才不会虚高。

落到具体的统计工具上,主流 Web JS SDK 一般都把常规浏览和事件上报做了队列缓冲,自定义事件通过全局队列 _yhxw456_trackdata.push('event', 分类, 名称, 属性) 上报。开发者在卸载时刻补充的离开、停留时长事件,可以在 SDK 常规上报之外,再用上面这套 pagehide 加 sendBeacon 的逻辑兜底发一份,两者配合,关闭瞬间的流失才不会成为盲区。在工具侧,事件分析与实时流量监控可看到 page_view 和 page_hide 的配对率;要按「丢失率」这种维度做长期下钻,需要按维度拆分事件,这部分在专业版及以上更顺手。具体档位与配额以官网定价页为准。

常见问题

Q1:为什么页面关闭时 XHR 埋点会丢?

A:页面卸载时浏览器会取消未完成的请求,XHR/fetch 在 beforeunload 里发出往往发不出去。这是浏览器行为,不是网络问题。

Q2:sendBeacon 有什么限制?

A:只支持 POST,body 大小通常限制在 64KB 以内,不能自定义请求头里的鉴权字段,且是异步、不等响应的。

Q3:为什么不建议用 beforeunload?

A:现代浏览器已逐步弃用 beforeunload,改用 pagehide。Chrome 从 117 起对 beforeunload 弹窗做了限制,埋点也建议绑到 pagehide。

Q4:sendBeacon 一定能发出去吗?

A:不能。它由浏览器保证在后台排队,但如果用户直接杀进程或断网,照样会丢。所以关键路径埋点不能只靠 beacon,要在用户操作时就发。

Q5:怎么验收 beacon 真的到了服务端?

A:三段验收:DevTools Network 看到请求、服务端 access log 收到、数据库落库。只看客户端发出去不算数。

总结

关闭页埋点的核心不是「用什么 API」,而是「怎么证明它真的到了」。把发送、到达、入库这三段验收清单贴进上线 Checklist,每次接新埋点都过一遍,停留时长这种敏感指标就不会再莫名短一截。绑事件选 pagehide 不选 unload,发数据选 sendBeacon 不选裸 XHR,调试时记得勾 Preserve log------这三件事做到位,剩下的只是把它固化成团队规范。工具能帮你看数据看板,但这三关必须自己盯;发送、到达、入库任何一段断了,看板上的漂亮数字都站不住。

数据来源:

  1. W3C Beacon API 规范
  2. MDN Web Docs《navigator.sendBeacon》
  3. MDN Web Docs《Page Visibility API》
  4. MDN Web Docs《beforeunload 事件》
相关推荐
Java后端的Ai之路1 小时前
Python 进阶探索25 - difflib模块之文本对比
开发语言·python·django·哈希算法·difflib
Evand J1 小时前
【MATLAB例程】三维快速扩展随机树(RRT)路径规划与到达角(AOA)定位算法|三维避障+定位仿真。附下载链接
开发语言·算法·matlab·路径规划·定位
空堂与归1 小时前
GPT-6.1 Sol 来了:1/5 价逼近 Astra 级
开发语言·人工智能·gpt·ai
程序员老陆1 小时前
C++ 悬空指针:从原理到工程级解决方案
开发语言·c++·程序设计
释厄6231 小时前
BSD 简单真理循环论——简单真理 × 复杂循环=大一统
android·开发语言·kotlin
我就是不信1 小时前
TCP 套接字中的 I/O 缓冲:原理、机制与调优实践
网络·tcp/ip·php
CS创新实验室1 小时前
Python 的鸭子类型
开发语言·windows·python
1024奇点2 小时前
【仓颉语言入门 · 第24课】
开发语言·ide·开源
栈溢出了2 小时前
VS Code Python 自动导包失效排查笔记
开发语言·笔记·python