资讯详情

资讯详情

页面关闭数据上报最佳实践:sendBeacon与fetch keepalive详解

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/plainBlob就是你指定的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.sendBeaconfetch keepalive底层机制浏览器独立队列托管请求脱离页面生命周期由浏览器网络层接管请求方法仅POSTGET/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两者搭配足以覆盖绝大多数埋点场景。留一个图片打点做老浏览器的最终的兜底这套方案在性能和可靠性上能打出不错的平衡。后来我把项目里的同步请求全部替换掉用户体验数据明显变好页面关闭再也没出现过“卡一下才退出”的现象。这就是这两兄弟最值钱的地方——既不打扰用户又把数据稳稳送出去。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →