页面关闭时还在用同步请求?fetch keepalive 和 sendBeacon 让你优雅告别

0 阅读8分钟

引言

“我们的埋点数据总是少一些,尤其是用户快速关闭页面的时候。”

这是上周一个做数据分析的同事向我抱怨的问题。他们的埋点方案很简单:在页面卸载时(beforeunloadunload 事件中)用 fetch 发送一个 POST 请求,记录用户行为。但统计发现,大约 15% 的数据丢失了——尤其是用户在移动端切换 App 或直接关闭浏览器标签时。

为什么异步请求在页面关闭时会被取消?有没有可靠的方式在页面卸载时发送数据?

答案是肯定的。今天我们就来聊聊两个专门为这种场景设计的 API:navigator.sendBeaconfetchkeepalive 选项

一、问题的根源:页面卸载与网络请求的生命周期

当我们用常规的 fetchXMLHttpRequest 发送请求时,这些请求默认是与当前页面绑定的。如果页面正在卸载(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(可选):要发送的数据,可以是 ArrayBufferTypedArrayDataViewBlobstringFormDataURLSearchParams 对象。

它返回一个布尔值: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 的对比

特性sendBeaconfetch + keepalive
方法仅 POST任意 HTTP 方法
自定义头部有限(受浏览器控制)完全自定义(但受 CORS 限制)
请求体类型支持多种类型支持多种类型(同 fetch)
数据大小约 64KB约 64KB
响应处理无法获取无法获取(Promise 可能永不完成)
浏览器支持广泛(IE 不支持)较新(Chrome 66+、Firefox 68+、Safari 16+)
适用性简单的单次上报需要自定义方法/头部的上报

四、深入原理:keepalive 如何工作?

当我们在常规的 fetch 中设置 keepalive: true 时,浏览器会将这个请求标记为“独立于文档的生命周期”。这意味着:

  1. 即使发起请求的页面被卸载,请求也不会被取消。浏览器会将请求的控制权转移到后台,继续发送。
  2. 请求的资源消耗不计入当前页面的资源限制,而是计入全局的 Beacon 配额。
  3. 请求不能保证完成,如果浏览器进程被强制终止或网络断开,数据依然会丢失。

sendBeacon 的内部实现也是类似的,只是 API 更简洁,且默认使用 POST。

五、实战陷阱与最佳实践

5.1 不要在 beforeunload 中做复杂操作

beforeunload 中只能做轻量操作,不要执行耗时的同步任务。sendBeaconkeepalive fetch 都是异步的,不会阻塞,所以是安全的。

5.2 小心跨域限制

如果目标 URL 与当前页面不同源,fetch keepalive 会受到 CORS 限制,需要服务器支持适当的 CORS 头。sendBeacon 同样受 CORS 限制,但因为是 POST 方法,需要服务器处理预检请求吗?实际上,sendBeacon 发送的请求是简单请求(Content-Type 只能是 text/plainapplication/x-www-form-urlencodedmultipart/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);
  }
}

七、总结

页面卸载时的数据上报一直是前端埋点的一个痛点。sendBeaconfetch keepalive 为这个场景提供了标准、可靠的解决方案。理解它们的原理和限制,能让你在设计分析系统时更加从容。

下次遇到“关闭页面后数据丢失”的问题,不要再尝试同步 XHR 或 setTimeout 拖延了——用上这些现代 API,既保护用户体验,又提升数据完整性。

最后留一道思考题:假设你的页面需要在上报数据的同时,从服务器获取一个“再见”消息(比如显示一个弹窗),应该怎么做?可以用 keepalive fetch 吗?为什么?

(答案下期揭晓,也欢迎在评论区讨论)


每日一问:你在项目中遇到过页面卸载时数据丢失的情况吗?是如何解决的?分享你的经验,大家一起学习!