直答:页面关闭时浏览器会取消未完成请求,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——这三件事做到位,剩下的只是把它固化成团队规范。工具能帮你看数据看板,但这三关必须自己盯;发送、到达、入库任何一段断了,看板上的漂亮数字都站不住。
数据来源:
- W3C Beacon API 规范
- MDN Web Docs《navigator.sendBeacon》
- MDN Web Docs《Page Visibility API》
- MDN Web Docs《beforeunload 事件》