
去年给一个后台系统做用户行为埋点,功能上线半个月后,数据分析的同事找来了——"你看,这个退出率的图怎么这么奇怪,感觉数据不全。"
我拉出服务器日志一对,果然:实际关闭页面的事件比收到的少了将近两成。数据在半路消失了。
当时的代码是这么写的:
window.addEventListener('beforeunload', () => {
fetch('/api/track', {
method: 'POST',
body: JSON.stringify({ event: 'page_exit', ts: Date.now() })
})
})
看起来没毛病,逻辑也对。但这里有一个很反直觉的事:浏览器关闭标签页时,正在运行的异步请求会被直接 kill 掉。
fetch 是异步的。你调用它,它返回一个 Promise,整个流程建立在"页面还活着"这个前提上。
关标签页那一刻,浏览器要做的事情:停止所有 JS 执行、释放内存、断开网络连接。那个还没跑完的 fetch 请求,和未保存的 Word 文档一个待遇——直接没了。
页面关闭时的命运对比:
fetch 方式 sendBeacon 方式
───────── ──────────────
用户关闭标签页 用户关闭标签页
│ │
▼ ▼
浏览器开始卸载 浏览器开始卸载
│ │
▼ ▼
JS 执行停止 数据已进入浏览器队列
│ │
▼ ▼
fetch 连接被 kill 浏览器后台完成发送
│ │
▼ ▼
❌ 数据丢失 ✅ 数据送达
navigator.sendBeacon() 是浏览器十多年前就提供的 API,专门为"临死前发消息"这个场景设计的。
它的逻辑不一样:你调用它,数据直接进浏览器的后台发送队列,然后你就走,浏览器自己负责把这封信投出去。不依赖 JS 继续运行,不要求页面还活着。
改写之后:
// JavaScript 版本
window.addEventListener('beforeunload', () => {
const data = JSON.stringify({ event: 'page_exit', ts: Date.now() })
navigator.sendBeacon('/api/track', data)
})
// TypeScript 版本
interface TrackPayload {
event: string
ts: number
userId?: string
}
window.addEventListener('beforeunload', () => {
const payload: TrackPayload = {
event: 'page_exit',
ts: Date.now(),
userId: getCurrentUserId()
}
const success = navigator.sendBeacon('/api/track', JSON.stringify(payload))
if (!success) {
console.warn('sendBeacon 入队失败,可能超出 64KB 限制')
}
})
sendBeacon 返回一个布尔值——true 表示数据成功进入队列,false 表示入队失败(通常是超出大小限制)。注意,true 不代表服务器收到了,只是"投进信箱了"。
64KB 上限。 单次请求超过 64KB 直接返回 false,数据无声消失。有一次把完整的报错堆栈塞进去发监控,服务端半天没收到,排查了好久才发现是超重了。解法:分批发,或者大数据改走正常请求。
没法自定义 Header。 sendBeacon 默认 Content-Type: text/plain,改不了。如果后端要求 application/json,可以用 Blob 包一层指定类型,但这可能触发 CORS 预检请求。我们团队最后统一用 text/plain,后端自己解析 JSON 字符串,简单省事。
Token 没法带。 走不了 Authorization Header,需要鉴权的接口用不了。
批量发送比每次点击都发一条要好很多,服务端压力小,数据也更好处理。内存里攒够再发:
// JavaScript 版本
let buffer = []
document.addEventListener('click', (e) => {
buffer.push({ type: 'click', x: e.clientX, y: e.clientY, ts: Date.now() })
// 攒够 10 条,或者页面进后台了,立刻发出去
if (buffer.length >= 10 || document.visibilityState === 'hidden') {
navigator.sendBeacon('/api/interactions', JSON.stringify(buffer))
buffer = []
}
})
// TypeScript 版本
interface ClickEvent {
type: 'click'
x: number
y: number
ts: number
}
let buffer: ClickEvent[] = []
document.addEventListener('click', (e: MouseEvent) => {
buffer.push({ type: 'click', x: e.clientX, y: e.clientY, ts: Date.now() })
if (buffer.length >= 10 || document.visibilityState === 'hidden') {
navigator.sendBeacon('/api/interactions', JSON.stringify(buffer))
buffer = []
}
})
这里用的是 visibilityState === 'hidden' 而不是 beforeunload,覆盖的场景更全——切换 Tab、锁屏、手机 App 切到后台都会触发,不只是关标签页。
最近几年 fetch 加了 keepalive 参数,页面卸载时不会被 kill 掉:
fetch('/api/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({ event: 'session_end', ts: Date.now() }),
keepalive: true
})
可以带自定义 Header,也可以处理响应,适合需要鉴权的场景。同样受 64KB 约束,这点和 sendBeacon 一样。
选型的时候看这张图就够了:
你要发什么?
│
├─ 简单埋点 / 错误上报 / 不需要鉴权
│ └─ → sendBeacon ✅ 轻量、稳定、零配置
│
├─ 需要带 Token / 需要处理响应
│ └─ → fetch + keepalive ✅ 灵活,记得 64KB 限制
│
└─ 数据量大 / 需要投递确认
└─ → WebSocket 或普通请求 + 重试机制
sendBeacon 是那种平时不会主动去查、但踩过坑之后会牢牢记住的 API。如果你的项目里有在 beforeunload 或 visibilitychange 里发 fetch 的代码,值得检查一下——可能有 20% 的数据正在悄悄蒸发。
你们项目的埋点方案是怎么做的?
几个好奇的点:用的是自研方案还是三方 SDK(神策、GrowingIO 之类的)?有没有遇到过数据莫名少了一截、排查了半天才发现根本不是业务 bug 的情况?
评论区聊聊,说不定能帮到踩同样坑的人 👇