1. 一个埋点丢了的下午:页面关闭上报的痛点
1.1 从一次线上事故说起
前阵子我们给一个活动页做行为埋点,上线第二天发现一个怪现象:用户点击结算按钮进入支付,支付完成后返回活动页,服务端收到的“支付成功”事件却少了一批。一开始怀疑是后端接口漏了,排查半天没结果。后来把日志按时间轴拉出来,发现丢失的事件有一个共同特征:用户从活动页跳出或者直接关闭标签页的那一瞬间,事件才发出来。准确说,是前端在页面卸载阶段尝试上报的请求,根本就没到达服务端。
这种场景你应该不陌生:用户在一个页面上停留了一会儿,然后突然关掉浏览器标签页。你希望把最后那点行为数据传回服务器,但异步请求根本来不及跑完。因为页面一关,JavaScript执行环境就销毁了,正在进行的XMLHttpRequest或者fetch会被直接中断。当时群里有人提了个“老办法”:把XMLHttpRequest的async参数设为false,用同步请求发。理由是浏览器在页面关闭时会尽量把同步XHR发出去,这样数据就能送达。听起来像那么回事,但真正实现后问题更头疼:页面卡顿明显,关闭时浏览器像被拽住了一样。
1.2 关闭页面时浏览器的“撤摊”顺序
要理解为什么上报这么难,得先摸清浏览器在卸载页面时的动作。当你关闭标签页或者从A页面跳到B页面,浏览器会按顺序触发以下事件:beforeunload、pagehide,最后unload。在移动端,还有可能触发visibilitychange,页面从可见变成hidden状态。一旦进入卸载流程,页面上下文的生命周期就进入倒计时,渲染线程开始清理;加载中的资源会被取消,正在执行的异步回调也不会被保证执行完毕。
这里有个关键点:beforeunload时页面还没真正销毁,你依然能发请求;unload时大概率已经晚了。但即使你在beforeunload里写fetch,如果没有特殊标记,这个请求是“尽力而为”的——浏览器不保证它一定能发出去。它会排在正常网络队列里,页面销毁后队列也随之取消。所以裸用fetch在卸载阶段上报,跟瞎猜没两样。同步XHR的“保证送达”其实是牺牲了用户体验换来的,浏览器确实会为了同步请求冻结当前页面等待它完成,但这个冻结用户能明显感知到。
1.3 为什么说“同步请求”是看似稳妥实则粗暴
同步XHR能解决送达问题,代价却是把整个浏览器UI线程按在地上摩擦。页面关闭时发同步请求,浏览器需要阻塞渲染和脚本执行,直到请求返回。如果你在beforeunload里发起同步XHR,用户点击关闭后页面会顿一下,像卡住了一样。要是请求超时得等30秒,用户会怀疑电脑坏了,甚至强制杀掉进程。而且现代浏览器对同步XHR在主线程上的使用有严格警告,Chrome会直接提示“Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the user’s experience”。Chrome还进一步限制了同步请求必须发生在beforeunload这样的特定场景,否则直接抛异常。这说明浏览器官方已经不鼓励这条路了。
那有没有既不阻塞用户,又能保证送达的办法?有,就是标题里说的两位主角:navigator.sendBeacon和fetch的keepalive选项。这两个API专门为“页面生命周期末端上报”设计,我把两种方案的原理、差异和坑完整测了一遍,下面逐一拆给你看。
2. 被用惯的同步XMLHttpRequest到底牺牲了什么
2.1 同步请求如何绑架了浏览器UI
先别急着否定同步XHR,得先搞清它“底层做了什么”才这么招狠。浏览器的主线程默认只有一个,它既要跑JavaScript,又要处理DOM渲染、用户输入响应,还要调度网络事件。当你在主线程发起一个同步XHR,浏览器会让当前执行栈停在那里,死死等着服务器的响应回来。这期间你点任何按钮、滚动页面、输入文字,事件循环全部被堵住——不是变慢,是彻底不处理。用户感知最明显的是页面“假死”,尤其在断网或者服务端响应慢时,每次关闭标签页都像系统崩溃前的挣扎。
网络层面也没好哪去。同步XHR会占用一个连接直到完成,现代浏览器虽然对每个域名的连接数有限制,但同步请求优先级很高,它可能插队排到其他异步请求前面,拖累页面上其他资源加载。你在监听关闭事件时发这个请求,等于跟页面上最后一点渲染和Cookie存取抢时间。上游网络状态稍微波动,关闭页面就卡成PPT。与其说同步XHR是为了“可靠上报”,不如说它是用全局卡顿换取局部送达。
2.2 老项目的真实代码长什么样
我翻过不少老项目,类似这种代码特别常见:
window.addEventListener('beforeunload', function () { var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/track', false); // 第三个参数 false 表示同步 xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({ event: 'page_close', ts: Date.now() })); });在压测不太严格的环境里,这种代码偶尔能跑通。因为它确实会在页面关闭前把请求发出去,并且等待响应。但线上用户交互一复杂,问题就暴露了:如果服务端接口慢,用户关闭页面就要等很久;如果服务端直接hang住,甚至会让浏览器进程崩溃。更微妙的是,beforeunload里同步XHR在移动端Safari上表现很飘,有的iOS版本直接忽略,有的版本虽然发送但会把请求头里的Cookie搞乱。后来我们在统计后台看到这类上报的数据有大量重复,原因是用户在页面卡顿期间反复点击关闭按钮,触发了多次同步请求。
2.3 移动端尤其不能碰的原因
移动端网络环境比桌面端差得多,弱网、慢路由、切换Wi-Fi都会导致请求响应时间变长。同步XHR在主线程等待响应时,移动浏览器为了省电或保持性能,往往会直接终止页面进程。这等于你为了发一个请求,让整个浏览器被系统杀掉,数据照样没送到。还有Android上Chrome对同步XHR的干预很激进,如果用户正在下拉滚动,同步XHR会阻塞滚动合成器,系统会发出“页面无响应”的弹窗。我见过一个实际案例:在pagehide里用同步XHR上报,结果用户在弱网下关页面,白屏等待了十几秒,眼睛直勾勾盯着一个“正在退出”的动画。这种体验放谁身上都是流失风险。
所以问题的本质不是“同步能不能用”,而是“页面关闭时到底有没有一种不阻塞销毁流程的上报通道”。答案也就是接下来要聊的交给浏览器托管请求。
3. sendBeacon:浏览器为“最后一眼”开的小灶
3.1 sendBeacon的设计哲学
navigator.sendBeacon是第一批明确面向“页面世代结束”场景的API。它的设计思路很简单:你不是想在页面关闭时发一个请求吗?那好,把你的数据交给浏览器内部的一个独立队列,浏览器会接管这个队列,在页面卸载流程走完之后,由浏览器后台进程继续把请求发出去。页面销毁的进程里,主线程根本不需要等着响应回来,因为请求已经不在页面上下文里了,它被“转移”到了浏览器网络服务层。
用一句话总结就是:sendBeacon把“发请求”这个动作从页面脚本的职责里剥出去,变成浏览器的内部事务。这解决了同步XHR的卡顿,也解决了普通异步请求被随页面销毁而取消的问题。浏览器既然承诺了“由我发送”,自然会尽量保证在可靠的网络状态下把数据发出去。sendBeacon的请求会以POST方法发出,可以携带少量数据,但请求响应的内容你收不到,因为在请求被接管的那一刻,页面代码已经没法继续执行回调了。
3.2 数据格式与服务端接收要做什么
sendBeacon的传参设计有点特殊,第二个参数可以是ArrayBufferView、Blob、FormData、URLSearchParams或者普通字符串。但因为它只有POST功能,服务端接收时需要兼容你传入的Content-Type。最常见的是用Blob指定JSON格式,不然很多服务端框架会拿默认的text/plain当普通字符串解析:
function reportWithBeacon(eventName, payload) { const data = new Blob([JSON.stringify({ event: eventName, payload: payload, ts: Date.now() })], { type: 'application/json' }); const delivered = navigator.sendBeacon('/api/track', data); if (!delivered) { // 队列已满或者浏览器拒绝 console.warn('beacon queue full, fallback to fetch keepalive'); } }注意返回值是boolean。true表示数据已经成功进入浏览器发送队列,并不是说服务端已经收到;false表示浏览器拒绝接管,常见原因就是队列配额满了。我实测过,Chrome下sendBeacon和keepalive请求共用同一个内存预算,如果同一时间页面积压了大量未发送的beacon,后面的调用就会返回false。
服务端收这边要额外处理跨域问题。sendBeacon发送的请求如果跨域,需要服务端接口允许CORS,并且不能自定义一堆请求头。收到的Content-Type取决于你传入的数据类型,字符串就是text/plain,Blob就是你指定的application/json。我们当时后端用的是Spring Boot,直接在Controller里声明接收JSON字符串即可,CORS配置允许来源、允许/api/track的POST就行。
3.3 sendBeacon的限制:大小、并发、无法收到响应
sendBeacon的一大限制是数据体积。规范没强制规定上限,但Chrome的实现里,单次beacon请求的载荷和页面所有待发送beacon的总载荷都不能超过64KB。你如果在一个事件里塞了一大段base64图片,大概率会超。我们在做截图上报的时候踩过这个坑,当时把用户截图压缩到50KB左右依然偶尔被拒,后来才知道是所有beacon加起来不能超过64KB,不是单个。
另一个限制是:你无法知道请求到底成没成功。没有回调,没有response,连HTTP状态码都看不见。如果你的上报链路需要做失败重试,或者依赖服务端返回的错误码,sendBeacon根本满足不了。这也是为什么后来我们升级方案时,优先考虑了fetch keepalive——它好歹还能通过Promise的resolve/reject观测送达情况。
4. fetch keepalive:把异步请求改造成“告别也不断线”
4.1 keepalive: true 背后的机制
fetch本来是为常规页面生命周期设计的异步请求,它的命运跟页面上下文绑定,页面销毁时内部发出的请求也会被取消。但如果你在请求配置里加上keepalive: true,情况就完全不同了。这个标记告诉浏览器:这个请求的生命周期不绑定当前页面的执行环境。当页面开始卸载时,浏览器会把这类请求从页面线程中抽离,交给网络服务层继续处理,效果跟sendBeacon类似。
本质上,keepalive是让请求在发起后脱离当前页面上下文的“活性依赖”。普通fetch相当于你在工位上写完快递单,然后自己跑去寄,一旦你被保安请出大楼(页面销毁),快递单就废了;keepalive: true则相当于你把快递单塞进了一件“无论你在不在都会有人来收件”的信箱,浏览器会保证在后台替你寄出。它甚至还能拿到一个延迟超时的Promise结果,只是在页面卸载后,这个Promise的回调通常不会执行而已。
一个容易忽略的坑:keepalive请求的响应可以回来,但你页面都关了,JavaScript回调大概率永远执行不了。所以别指望用它处理依赖响应结果的业务逻辑,它只适合“发了就算数”的场景。另外,keepalive请求body的总大小跟sendBeacon共用预算,同样是64KB左右。还有,fetch的AbortController在配合keepalive时会变得不可靠,建议不要混用。
4.2 一次真实可跑的代码改造
我们把之前的同步XHR方案改成fetch keepalive后,代码大概长这样:
function reportWithFetchKeepalive(eventName, payload) { const url = '/api/track'; const data = JSON.stringify({ event: eventName, payload: payload, ts: Date.now() }); fetch(url, { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: data }).catch(function (err) { // 页面还在时可以做一些降级处理 console.warn('fetch keepalive failed:', err); }); }日常页面里调用reportWithFetchKeepalive('page_close', { x: 1 })即可。注意keepalive: true在Chrome、Edge、Firefox和Safari的现代版本都支持。Safari从14.1版本开始支持,但iOS 14之前的用户需要降级到sendBeacon。
有个细节:fetch在keepalive模式下,请求体要放到body字段,且不能使用ReadableStream这种流式内容。因为流的生命周期由页面控制,页面销毁流就断了,浏览器无法接管。你可以用字符串、Blob、FormData、URLSearchParams等普通类型。另外,如果你的fetch走跨域,服务端CORS必须明确允许POST和Content-Type头,并且keepalive请求不允许使用Authorization头携带凭证发送时带credentials跨域?准确说,跨域请求应设置credentials: 'include'并在服务端配合,但keepalive模式下某些浏览器对cookie的携带策略会更严格,建议实测。
4.3 fetch keepalive 与 sendBeacon 的核心差异对比
我用一张表把两者放在一起对照了实际表现:
| 对比维度 | navigator.sendBeacon | fetch keepalive |
|---|---|---|
| 底层机制 | 浏览器独立队列托管 | 请求脱离页面生命周期,由浏览器网络层接管 |
| 请求方法 | 仅POST | GET/POST等所有HTTP方法 |
| 自定义请求头 | 不支持,Content-Type由数据格式决定 | 支持,可自由设置Header |
| 获取响应/回调 | 无任何回调,完全盲发 | 返回Promise,页面未卸载时可尝试处理 |
| 数据上限(Chromium) | 所有待发送beacon总和约64KB | 所有keepalive请求body总和约64KB |
| 超时控制 | 由浏览器接管,页面无感知 | 理论上遵循fetch超时,但页面关闭后回调无意义 |
| 适用场景 | 极简埋点、快速替换 | 需要自定义头、需要判断送达状态的场景 |
你可以发现,sendBeacon最大的问题是没有反馈机制,而fetch keepalive至少在页面还活着的时候可以拿到Promise的状态。在visibilitychange到hidden的场景下,fetch keepalive的回调大概率还能在页面隐藏后短暂执行,效果会更好。不过要注意,fetch keepalive的请求如果服务端响应特别慢,它会占用连接预算,理论上超过一定时间后浏览器可能会中止它,实测中Chrome大概在几十秒后会自行放弃。
5. 看完对比怎么选:我的实践建议与兼容兜底
5.1 选型判断树
先给结论:如果你的站点只需要给用户“点赞”“关闭页面”这类轻量事件做统计,直接无脑用sendBeacon,因为它代码最少、语义最清晰。但如果你要上报的数据需要自定义Content-Type之外的Header,或者需要对同一个接口发送不同Method,甚至需要在关闭前临时修改请求参数、观测发送是否成功,那选fetch keepalive更合适。
还有一种特别适合fetch keepalive的场景:你正在一个fetch请求正在发送的途中,用户却突然关了页面。由于这个请求本来通过keepalive: true发起,它就能走完,不需要你在beforeunload里再补一次。你可以把需要“尽力送达”的请求都统一加上keepalive: true,这样不仅卸载期的专门上报能用,普通页面请求也获得了不随页面销毁而中断的增益。代价仅仅是多一个选项,基本为零。
选型时要留意兼容性。navigator.sendBeacon的覆盖率极高,Chrome 39+、Firefox 31+、Safari 11.1+都支持。fetch keepalive在Safari的支持相对晚一些,iOS 14.1才开始。如果你的业务还需要兼容iOS 13和旧版Android WebView,建议做一层能力检测:
function report(endpoint, data) { if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); return navigator.sendBeacon(endpoint, blob); } if (window.fetch && 'keepalive' in new Request('http://localhost', { method: 'POST' })) { fetch(endpoint, { method: 'POST', keepalive: true, body: JSON.stringify(data) }) .catch(function () {}); return true; } // 最后的兜底:降级成图片打点或者同步XHR(仅限现代浏览器的beforeunload) const img = new Image(); img.src = endpoint + '?data=' + encodeURIComponent(JSON.stringify(data)); return true; }这里有个小技巧:可以用new Request('http://localhost', {method:'POST'})检测当前浏览器是否支持Request的keepalive属性,避免误判。图片打点也是一种常见的兜底方案,缺点是只能发送GET,且数据长度受URL长度限制。
5.2 事件绑定不要只盯着beforeunload
很多人一听“页面关闭”就只写beforeunload。但对移动端来说,beforeunload触发的时机很晚,甚至经常不触发。更可靠的是监听visibilitychange,当document.visibilityState变成hidden时,通常意味着用户切走标签页、切到后台App,或者锁屏。这个时机比beforeunload早,而且触发频繁高。Google analytics也推荐在这种状态用sendBeacon上报。
我的实践建议是优先监听visibilitychange发送数据,再保留pagehide作为兜底。不要只依赖beforeunload,因为在移动端它经常不触发。对一个比较重要且体积小的上报事件,可以同时监听pagehide,但要用一个标志位防止重复发送。
let reported = false; function flushEvents() { if (reported) return; reported = true; const events = eventQueue.splice(0, eventQueue.length); if (events.length === 0) return; if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' }); navigator.sendBeacon('/api/track', blob); } else { fetch('/api/track', { method: 'POST', keepalive: true, body: JSON.stringify({ events }) }); } } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushEvents(); }); window.addEventListener('pagehide', flushEvents);上面这个模式在我实际项目中跑了近一年,累计上报量百万级,没有出现明显的丢失分发。事件队列可以在连续上报多个小事件时聚合为一次请求,减轻服务端压力,也更容易控制在64KB体积内。
5.3 防重复上报、节流与队列刷新的边界问题
处理诸如“滚动超过50%”“停留时长超过10秒”这类行为事件时,你需要设计一个事件队列,并且在上报前做好节流。如果每个滚动事件都实时上报,页面关闭那一瞬间队列里可能积压几十上百条,最终一股脑打包发送,总大小轻易超过64KB。我们采用的做法是:对高频事件先做本地聚合,比如每10秒刷新一次队列,或者累计20条就上报一次;到了hidden时再一次性flush剩余队列。flush前检查一下队列大小,如果太大,就丢弃最早的非关键事件,保证最新的行为数据能送出去。
还要小心不要重复上报。比如visibilitychange和pagehide都可能触发,且顺序不确定,如果不用标志位,同一个事件会被发送两次。另外,SPA路由切换时visibilitychange也可能触发,但页面没有真正关闭,你只是从一个路由跳到另一个路由。这种情况可以先判断是否为路由内跳转,再决定是否flush,不然每次切路由都会上报一次“页面关闭”,污染数据。
Keepalive请求本身还会占用内存预算,所以不要让队列里堆积大量未发送的请求。实测中如果页面上同时塞了超过64KB的keepalive body,浏览器会直接拒绝后续请求。我们当时就遇到一次:用户连续快速提交表单,每次提交都用fetch keepalive上报,结果因为请求还没发完,后面的beacon全部返回false,数据白白丢了。后来改成在表单提交时用普通fetch,只有真正到页面隐藏时才走keepalive/beacon,问题才消失。
5.4 最后再说一个测试时的坑
本地开发时,用DevTools的“网络”面板看sendBeacon经常看不到请求,因为它的优先级很低,而且可能在页面关闭后才真正发出。如果你刷新页面,请求可能被取消,导致你以为数据丢了。这一点特别坑人。正确的测试方式是:打开DevTools的Network面板,勾选Preserve log,然后直接在页面执行navigator.sendBeacon或fetch keepalive,再关闭标签页,观察Network里有没有请求发出。也可以在Performance面板里配置“模拟丢失网络”来故意制造发送时的断网状态,看看会不会被浏览器重试。
还有一个容易误判的地方:sendBeacon返回true并不代表服务端收到了。它只代表浏览器收下了这份数据。服务端有没有收到,得看服务端日志或专门做一个回显接口。我们当时在测试环境加了一个/api/track接口,直接记录请求体并返回一个特殊header,在DevTools里能靠响应头确认请求确实触达了服务端。fetch keepalive则可以通过Promise.resolve后在控制台打日志确认,但要小心别把日志写在页面卸载后,否则看不到。
我个人的最终结论是:页面关闭上报这件事,别再折磨同步XHR了。能上sendBeacon就上,要自定义和回调用fetch keepalive,两者搭配足以覆盖绝大多数埋点场景。留一个图片打点做老浏览器的最终的兜底,这套方案在性能和可靠性上能打出不错的平衡。后来我把项目里的同步请求全部替换掉,用户体验数据明显变好,页面关闭再也没出现过“卡一下才退出”的现象。这就是这两兄弟最值钱的地方——既不打扰用户,又把数据稳稳送出去。