引言
“我们的埋点数据总是少一些,尤其是用户快速关闭页面的时候。”
这是上周一个做数据分析的同事向我抱怨的问题。他们的埋点方案很简单:在页面卸载时(beforeunload 或 unload 事件中)用 fetch 发送一个 POST 请求,记录用户行为。但统计发现,大约 15% 的数据丢失了——尤其是用户在移动端切换 App 或直接关闭浏览器标签时。
为什么异步请求在页面关闭时会被取消?有没有可靠的方式在页面卸载时发送数据?
答案是肯定的。今天我们就来聊聊两个专门为这种场景设计的 API:navigator.sendBeacon 和 fetch 的 keepalive 选项。
一、问题的根源:页面卸载与网络请求的生命周期
当我们用常规的 fetch 或 XMLHttpRequest 发送请求时,这些请求默认是与当前页面绑定的。如果页面正在卸载(unload),浏览器可能会终止这些尚未完成的网络请求,以释放资源或加快卸载过程。
即使在 beforeunload 事件中发起同步请求(如 xhr.open('POST', url, false)),虽然能确保请求发出,但会阻塞页面卸载,严重影响用户体验(浏览器会弹出确认框,并且页面会卡死)。现代浏览器甚至可能限制或废弃同步 XHR。
因此,我们需要一种不阻塞页面卸载,且浏览器会尽力发送的请求方式。
二、第一把利器:navigator.sendBeacon
navigator.sendBeacon() 是专门为解决这个问题而设计的。它在 2014 年随 Beacon API 引入,旨在可靠地、异步地发送少量数据,且不会延迟页面卸载。
2.1 基本用法
window.addEventListener('unload', (event) => {
const data = JSON.stringify({ event: 'page_exit', time: Date.now() });
navigator.sendBeacon('/log', data);
});
sendBeacon 接收两个参数:
- URL:数据发送的地址。
- data(可选):要发送的数据,可以是
ArrayBuffer、TypedArray、DataView、Blob、string、FormData或URLSearchParams对象。
它返回一个布尔值:true 表示浏览器已经将请求加入发送队列(但不保证成功到达服务器);false 表示队列已满或其他原因导致请求无法加入。
2.2 特点与限制
- 方法固定为 POST,无法更改。
- 数据大小有限制:一般建议不超过 64KB(不同浏览器有差异,Chrome 是 64KB)。
- 可靠但不保证:浏览器会尽力在页面卸载后发送,但如果用户离线或网络中断,请求可能丢失。
- 低优先级:Beacon 请求的优先级较低,不会与关键资源竞争带宽。
- 不会阻塞页面:即使数据量较大,也不会影响页面关闭速度。
2.3 适用场景
- 用户行为埋点(如点击、停留时长、页面退出)。
- 错误日志上报。
- 任何需要在页面卸载时发送的“一锤子买卖”数据。
三、第二把利器:fetch 的 keepalive 选项
fetch API 在较新的浏览器中提供了一个 keepalive 选项,专门用于在页面卸载时保持请求存活。它的功能与 sendBeacon 类似,但更灵活。
3.1 基本用法
window.addEventListener('unload', () => {
fetch('/log', {
method: 'POST',
body: JSON.stringify({ event: 'page_exit', time: Date.now() }),
headers: { 'Content-Type': 'application/json' },
keepalive: true
});
});
只需要在 fetch 的配置对象中加上 keepalive: true。
3.2 特点与限制
- 可以使用任何 HTTP 方法(GET、POST、PUT 等)。
- 可以自定义请求头,但受限于 CORS 策略(如果是跨域)。
- 数据大小限制:与
sendBeacon类似,总请求大小通常限制在 64KB 以内(包括 URL 和头部)。 - 不支持流式响应:因为页面可能已经关闭,无法处理响应。
fetch返回的 Promise 在页面卸载后可能永远不会 resolve/reject。 - 可能被浏览器延迟发送:在极端情况下,如果请求排队过长或网络差,可能被丢弃。
3.3 与 sendBeacon 的对比
| 特性 | sendBeacon | fetch + keepalive |
|---|---|---|
| 方法 | 仅 POST | 任意 HTTP 方法 |
| 自定义头部 | 有限(受浏览器控制) | 完全自定义(但受 CORS 限制) |
| 请求体类型 | 支持多种类型 | 支持多种类型(同 fetch) |
| 数据大小 | 约 64KB | 约 64KB |
| 响应处理 | 无法获取 | 无法获取(Promise 可能永不完成) |
| 浏览器支持 | 广泛(IE 不支持) | 较新(Chrome 66+、Firefox 68+、Safari 16+) |
| 适用性 | 简单的单次上报 | 需要自定义方法/头部的上报 |
四、深入原理:keepalive 如何工作?
当我们在常规的 fetch 中设置 keepalive: true 时,浏览器会将这个请求标记为“独立于文档的生命周期”。这意味着:
- 即使发起请求的页面被卸载,请求也不会被取消。浏览器会将请求的控制权转移到后台,继续发送。
- 请求的资源消耗不计入当前页面的资源限制,而是计入全局的 Beacon 配额。
- 请求不能保证完成,如果浏览器进程被强制终止或网络断开,数据依然会丢失。
sendBeacon 的内部实现也是类似的,只是 API 更简洁,且默认使用 POST。
五、实战陷阱与最佳实践
5.1 不要在 beforeunload 中做复杂操作
beforeunload 中只能做轻量操作,不要执行耗时的同步任务。sendBeacon 和 keepalive fetch 都是异步的,不会阻塞,所以是安全的。
5.2 小心跨域限制
如果目标 URL 与当前页面不同源,fetch keepalive 会受到 CORS 限制,需要服务器支持适当的 CORS 头。sendBeacon 同样受 CORS 限制,但因为是 POST 方法,需要服务器处理预检请求吗?实际上,sendBeacon 发送的请求是简单请求(Content-Type 只能是 text/plain、application/x-www-form-urlencoded 或 multipart/form-data),所以不会触发预检。如果需要发送 JSON,需要特别注意:直接传 JSON 字符串时,Content-Type 会被设为 text/plain;charset=UTF-8,服务器需要相应处理。
5.3 数据大小不要超过限制
尽量控制上报数据在几千字节内。如果需要发送大量数据,可以考虑在页面可见时批量发送,或者使用 Service Worker 进行离线存储,待页面恢复后再发送。
5.4 不要依赖响应结果
因为页面可能已经关闭,你无法获得响应。如果必须确认数据到达,可以改用普通请求在页面可见时发送,或者设计一种去重机制。
5.5 何时用 sendBeacon,何时用 fetch keepalive?
- 如果你只需要简单的 POST 上报,且数据量小,优先用
sendBeacon,兼容性更好,语法简单。 - 如果需要自定义请求方法(如 PUT)或自定义头部(如携带认证 token),或者要发送的数据格式要求特殊 Content-Type,就用
fetch keepalive。
5.6 备选方案:Service Worker
Service Worker 可以拦截 fetch 事件,即使页面关闭也能在后台处理请求(如果浏览器支持后台同步)。但配置较复杂,适合需要离线能力的高级场景。
六、常见问题解答
Q1:sendBeacon 能保证数据一定送达吗?
不能。它只是“尽力而为”。如果用户设备断网、浏览器崩溃或强制关机,数据依然会丢失。但对于大多数正常关闭页面的场景,可靠性远高于普通异步请求。
Q2:为什么我的 keepalive fetch 在 Chrome 中报错“Failed to fetch”?
可能原因:
- 跨域且服务器未正确配置 CORS。
- 请求体超过大小限制(Chrome 64KB)。
- 页面卸载太快,浏览器来不及将请求加入队列(罕见)。
Q3:keepalive fetch 的 Promise 会怎样?
Promise 可能会一直处于 pending 状态,永远不会 resolve 或 reject,因为页面上下文已销毁。所以不要对其使用 await 或 .then,除非你能确保页面不会立即卸载。
Q4:如何在移动端 Safari 上使用?
Safari 从 16.0 开始支持 fetch keepalive,之前的版本只能使用 sendBeacon(Safari 12.1+ 支持)。可以写一个 fallback:
function sendOnUnload(url, data) {
if (navigator.sendBeacon) {
navigator.sendBeacon(url, data);
} else if (fetch && Request.prototype.hasOwnProperty('keepalive')) {
fetch(url, { method: 'POST', body: data, keepalive: true });
} else {
// 最后的后备:同步 XHR(不推荐,但总比丢失好)
const xhr = new XMLHttpRequest();
xhr.open('POST', url, false);
xhr.send(data);
}
}
七、总结
页面卸载时的数据上报一直是前端埋点的一个痛点。sendBeacon 和 fetch keepalive 为这个场景提供了标准、可靠的解决方案。理解它们的原理和限制,能让你在设计分析系统时更加从容。
下次遇到“关闭页面后数据丢失”的问题,不要再尝试同步 XHR 或 setTimeout 拖延了——用上这些现代 API,既保护用户体验,又提升数据完整性。
最后留一道思考题:假设你的页面需要在上报数据的同时,从服务器获取一个“再见”消息(比如显示一个弹窗),应该怎么做?可以用 keepalive fetch 吗?为什么?
(答案下期揭晓,也欢迎在评论区讨论)
每日一问:你在项目中遇到过页面卸载时数据丢失的情况吗?是如何解决的?分享你的经验,大家一起学习!