凌晨的监控告警群里,产品经理甩过来一张后台截图,语气里带着三分疑惑七分质问:“这批用户显示停留 0 秒,但明明看到他们进过页面,是不是检测代码写错了?用户真的关掉页面了吗?”
对于做过前端性能监控和用户行为分析的人来说,这种场景再熟悉不过了。页面卸载检测,听着就是个unload事件的事,可真正深入到现代浏览器的页面生命周期、移动端 WebView、后台标签页冻结机制之后,你会发现这个“小事”的水非常深。beforeunload、pagehide、visibilitychange、unload,每种方案都有自己的脾气和适用场景,用错一个,轻则数据漏报,重则上报的日志把统计接口打爆。
这篇文章我把自己这几年在页面卸载检测上踩过的坑、沉淀下来的经验完整整理一遍,拆解 4 种主流检测方法的原理和适用场景,并附上一份可以直接参考的避坑清单。
1. 为什么“用户关掉页面”这么难检测
要理解页面卸载检测的难点,首先得抛弃一个直觉认知:浏览器里并不是只有“页面活着”和“页面关闭”两种状态。现代浏览器,尤其是移动端浏览器,为了节省内存和电量,引入了一套远比想象中复杂的页面生命周期管理机制。
1.1 你以为的 unload,和真实的 unload
很多老开发者习惯性地认为unload事件是页面卸载的“万能承诺”:只要用户关掉标签页、关掉浏览器、跳转到其他页面,unload一定会触发。但实际上,这个承诺在现代浏览器环境里已经越来越靠不住。
桌面端浏览器中,unload在常规导航(比如点击链接跳转、刷新、关闭标签页)时一般能触发。但到了移动端,情况完全不同。Android 和 iOS 的浏览器为了节省资源,会在页面进入后台一段时间后直接销毁 WebView 进程或冻结页面渲染,这个销毁过程并不经过标准的unload流程。更极端的情况是,用户只是切到其他 App,隔几分钟回来,页面还活着,但根本没有产生任何“卸载”事件。
另外还有个隐蔽问题:unload事件在部分场景下会导致页面被放入bfcache(往返缓存)失效,影响用户的后退前进体验。Apple 和 Google 都明确不建议用unload来做统计分析或状态清理,原因就是它既不可靠,又会带来性能副作用。
1.2 现代浏览器的页面生命周期模型
为了规范地描述页面状态,Chrome 团队很早就提出了 Page Lifecycle API 的概念,把页面分为六个状态:active(活跃)、passive(被动)、hidden(隐藏)、frozen(冻结)、terminated(终止)、discarded(丢弃)。
- active:页面可见且有焦点。
- passive:页面可见但没有焦点,比如用户切到了其他窗口。
- hidden:页面不可见,比如切到其他标签页或最小化窗口。
- frozen:页面被浏览器冻结,定时器和回调暂停执行,这是移动端省电的核心手段。
- terminated:页面被正常销毁。
- discarded:页面被系统直接丢弃,用于回收资源。
这套状态机里最关键的信息是:真正能稳定感知的“用户离开页面”状态,往往是hidden,而不是terminated。当页面从 active 或 passive 转入 hidden 时,浏览器会触发visibilitychange事件和pagehide事件,而unload只会在极少数的 terminated 场景下触发。
1.3 为什么这个话题又火了起来
近几年前端监控领域对卸载检测的讨论热度一直没降,核心原因是前端 SDK 和数据埋点越来越普遍。直播 H5、组件库、数据分析工具都需要知道用户真实的停留时长,而不只是“HTTP 请求发起时刻”。再加上移动端多任务切换、桌面端多标签页的使用习惯,一套靠谱的卸载检测方案直接决定数据质量。
2. 四种页面卸载检测方法的原理与实操
下面把 4 种方法逐个拆开讲清楚,包含代码示例、适用场景和局限。我会直接给出结论,再讲为什么。
2.1 beforeunload:经典但“名不副实”
beforeunload可能是前端开发者最早接触的卸载相关事件,但它的主要设计目的是让网页在用户离开前弹出确认对话框,防止未保存的内容丢失,而不是给监控系统做数据上报。
let hasUnsavedChanges = true; window.addEventListener('beforeunload', (e) => { if (hasUnsavedChanges) { e.preventDefault(); // 兼容旧浏览器,设置 returnValue 为字符串才能弹窗 e.returnValue = '您有未保存的内容,确定要离开吗?'; } });实操中的注意点是:现代浏览器为了弹窗滥用,已经限制beforeunload弹窗必须由用户主动交互才能触发,而且移动端 Safari 对这个事件的支持非常挑三拣四。如果你只是想监听“用户离开”做停留时长统计,beforeunload不是好选择,经常是桌面端触发、移动端不触发,甚至同样桌面端也受 iframe 嵌套、跨域场景的影响而不稳定。
2.2 pagehide:我最推荐的主力方案
pagehide是unload的替代方案之一,它在页面即将卸载或进入bfcache时触发,是浏览器官方推荐的监听页面“消亡”的事件。最大优势是:无论页面是正常关闭、刷新、跳转,还是被放入 bfcache,都会先触发pagehide。
// 生成一个会话标识,用于后续去重 const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2); window.addEventListener('pagehide', (e) => { // e.persisted 表示页面是否被存入 bfcache const payload = { sessionId, type: e.persisted ? 'bfcache' : 'pagehide', ts: Date.now(), title: document.title, path: location.pathname + location.search, stayMs: Math.round(performance.now()), ua: navigator.userAgent, hidden: document.visibilityState === 'hidden' }; // 上报数据,后面章节会详细说 sendBeacon navigator.sendBeacon('/api/page-leave', JSON.stringify(payload)); });这段代码里有几个细节值得注意:
sessionId用来标记一次页面加载会话,在后续数据清洗里去重。e.persisted是 pagehide 事件独有的属性,值如果是true,说明页面会被放入 bfcache,并不真正死亡,数据上报时建议给这种记录打标,方便分析用户“回退后又前进”的行为。stayMs用performance.now()计算从页面加载到离开的时长,单位是毫秒,比直接用Date.now()做差值更精确,避免受系统时间跳变影响。
2.3 visibilitychange:很多人忽略的“伪卸载”
移动端场景里,大量“离开页面”根本不是真正的卸载,而是切到后台。App 切后台、锁屏、从扫码结果返回等场景下,页面会进入 hidden 状态。对产品而言,用户已经不可见了,停留时间统计就应当截止,所以visibilitychange是移动端统计的绝对主力。
let hiddenStartTime = 0; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { hiddenStartTime = Date.now(); } if (document.visibilityState === 'visible') { // 从 hidden 恢复到 visible,说明用户回来了 if (hiddenStartTime > 0) { const hiddenDuration = Date.now() - hiddenStartTime; // 如果切出时间特别长,比如超过5分钟,可以视为一次“近似卸载” if (hiddenDuration > 5 * 60 * 1000) { reportLeave({ type: 'long-hidden', durationMs: hiddenDuration }); } } hiddenStartTime = 0; } });但这里有个大坑:visibilitychange在切标签页时也会触发,不能一进 hidden 就上报离开,否则用户切个标签页再回来,统计就被刷一遍,数据直接翻倍。我建议的做法是记录进入 hidden 的时间,等用户再次回到 visible 时,再判断这段隐藏时长是否超过阈值,长时隐藏才按一次“离开”上报,短时切走忽略不计。
2.4 unload:最传统也最不可靠
其实不太想单独把unload列出来讲,因为它的缺点和beforeunload重叠,且表现更差。unload在某些 Chrome 版本上会被直接忽略,Safari 在移动端几乎不触发,桌面端在 iframe 内加载的页面也容易漏报。
window.addEventListener('unload', () => { // 不推荐:移动端不可靠,PC端也有兼容性问题 });非要用的话,通常就是拿它配合sendBeacon做最后的数据补发兜底,但这里有一个非常反直觉的事实:unload事件中如果用异步 AJAX 请求上报,请求大概率发不出去,因为页面上下文已经销毁,网络请求被浏览器取消。XMLHttpRequest同步模式在unload里能发出去(牺牲主线程阻塞),但现代浏览器对同步 XHR 在卸载事件中的支持也在收紧,而且对用户体验是极大的伤害。
2.5 补充方案:Page Lifecycle API 的 statechange 事件
Chrome 系浏览器对 Page Lifecycle API 封装了一个statechange事件,可以统一监听页面状态切换,示例代码:
const states = { active: () => console.log('页面活跃'), passive: () => console.log('页面被动'), hidden: () => console.log('页面隐藏'), frozen: () => console.log('页面冻结'), terminated: () => console.log('页面终止') }; document.addEventListener('statechange', (e) => { const state = document.visibilityState === 'hidden' ? 'hidden' : 'active'; const handler = states[state]; if (handler) handler(); });实际工作中,我建议用组合方案:以pagehide为主,配合visibilitychange兜底,statechange只在需要更细粒度状态(比如页面冻结)的专项分析时启用。
3. 检测到卸载之后,数据怎么送出去
检测到事件只是第一步,更关键的是,在页面真的要“死”的那一刻,怎么把数据稳定送到服务端。这一步选错通道,前面所有监听都白做。
3.1 navigator.sendBeacon:设计出来就是干这个的
sendBeacon是专门为“一次性、可靠地发送数据”设计的 API,它最大的特点是不阻塞页面卸载,浏览器会把数据放在独立的维护队列里,用户代理在合适时机发送,而不是依赖当前页面的主线程活多久。
const data = { type: 'pagehide', path: location.pathname, stayMs: 12345 }; // 第二个参数可以是 Blob、FormData、URLSearchParams 等 const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); const sent = navigator.sendBeacon('/api/page-leave', blob); if (!sent) { // 发送失败时,可以降级到 fetch keepalive fetch('/api/page-leave', { method: 'POST', body: blob, keepalive: true }); }这一小段代码里藏着一个大家容易踩的坑:sendBeacon返回false表示投递失败,这时候要准备回退方案。另外,sendBeacon有数据体大小限制,通常和平常的 POST 限制相当(Chrome 大约是 64KB),如果 payload 太大,会直接返回false,所以上报字段要精简,别把整个window对象序列化传过去。
3.2 fetch keepalive:更现代的补充方案
fetch在较新浏览器里支持keepalive: true选项,可以让请求在页面卸载后继续发送,效果和sendBeacon类似,但可以自由控制 headers,并发连接数限制也宽松一些。适合上报结构化 JSON 的场景。
window.addEventListener('pagehide', (e) => { const payload = { persisted: e.persisted, path: location.pathname }; fetch('/api/analytics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true }).catch((err) => { // keepalive 请求理论上不抛错,但为了稳妥仍加上兜底 navigator.sendBeacon('/api/analytics', JSON.stringify(payload)); }); });用fetch keepalive的时候有一个细节:它和sendBeacon一样受数据大小限制(Chrome 下大约也是 64KB 量级),同时keepalive的请求不能调用未完成的 ReadableStream,所以别想着用流式上传上报日志。
3.3 同步 XHR 的“历史包袱”
老一代前端监控 SDK 喜欢在unload里用同步 XHR 发数据,因为只有同步请求才能保证在页面卸载时把数据发完。但代价是阻塞主线程,尤其在慢网络下,页面关闭时可能会导致卡顿几十毫秒甚至上百毫秒,而且数据量一多,用户体验会明显下降。
现代浏览器中,同步 XHR 在主线程的 Deprecation 警告已经存在多年,在卸载事件中也越来越不推荐。新项目建议直接上sendBeacon或fetch keepalive,老项目如果因为兼容性必须保留同步 XHR,至少用 try-catch 包裹,避免极端情况下抛异常影响后续业务。
3.4 上报内容的设计:什么字段必须有
监听方式选对了,上报内容也别乱来。基于项目实践,一套最小可用的卸载上报 payload 至少包含以下几类信息:
{ "appId": "live-h5-demo", "v": "1.2.0", "sessionId": "m3f8...", "userId": "u_10086", "type": "pagehide", "bfcache": false, "url": "https://example.com/live/room/1024", "referrer": document.referrer, "stayMs": 82341, "hiddenMs": 12, "visibilityState": "hidden", "network": { "effectiveType": "4g", "downlink": 3.2, "rtt": 120 }, "ua": "Mozilla/5.0 ...", "screen": { "w": 390, "h": 844, "dpr": 3 }, "memory": 2356789 }stayMs和hiddenMs是统计停留时长的核心,effectiveType等网络信息则帮助分析“用户为什么秒退”这类问题,比如弱网环境下首屏加载慢,用户可能在页面上只停留了几秒就退出。bfcache字段用于区分真实卸载和退缓存,分析回访行为时特别有价值。
4. 避坑指南:这些坑我全踩过
方法和通道都讲完了,接下来是最有价值的部分:实战中那些反直觉的坑。这部分内容都是我从线上事故和数据异常里一点一点磨出来的。
4.1 坑1:统计翻倍的“幽灵上报”
第一次给公司做停留时长统计时,我同时监听了pagehide和beforeunload,想着双保险。结果上线当天统计数字直接翻倍了。排查后发现问题链路非常明显:用户正常关闭页面时,pagehide触发一次,紧接着beforeunload又触发一次,两条日志因为网络延迟先后到达服务端,后端没做去重,数据直接翻倍。
解决方案有三层:
- 前端只选一个主事件上报,我最终保留
pagehide,去掉beforeunload。 - 无法完全避免多事件上报时,利用
sessionId加pageLoadTime做幂等键,后端做唯一性过滤。 - 上报时带
sendBeacon的投递序号,比如第几次发送,辅助排查。
4.2 坑2:SPA 路由切换“背锅”
短视频 App 内嵌 H5、单页应用里,用户从列表页跳到详情页,页面根本不会卸载,只是路由发生了变化。但有些检测方案会在popstate或hashchange时误报“离开”,因为路由变化往往伴随着页面内容的大量替换,一些 SDK 会把这种场景错认为一次新的页面生命周期。
解决思路:监听history.pushState、history.replaceState和popstate,在路由变化时主动重置计时器并标记一次“正常路由切换”,不触发离开上报。同时在卸载上报的 payload 里增加routeChange字段,把“路由跳转后的离开”和“从落地页直接退出”区分开。
const originPushState = history.pushState; history.pushState = function (...args) { // 路由变化,重置会话心智 markRouteChanged(); return originPushState.apply(this, args); };4.3 坑3:移动端 App 里 WebView 的“假死”
在微信、抖音、支付宝这类 App 的 WebView 中运行 H5 时,卸载检测的可靠性会进一步下降。Android WebView 在页面切到后台一段时间后可能触发onPause和onStop,但pagehide和visibilitychange并不总是能在这个时机触发。iOS 的 WKWebView 相对好一些,但同样存在被系统级内存回收直接“杀掉”的情况。
这种环境下最实用的兜底方案是给 WebView 注入原生桥接,在 App 自带的前后台切换回调里调用 JS 方法,补上 JS 侧丢失的事件。如果没有原生桥接权限,就只能用定时心跳法:每隔 10 秒发一个带sessionId的心跳包,服务端根据心跳中断时间推算页面离开时间。虽然不那么优雅,但确实是最后一道防线。
4.4 坑4:单张图片跨域上报的坑
老一代埋点方案喜欢用new Image().src = '/xxx.png'做打点,但在卸载上报场景下,这个老办法有两个致命问题:一是 URL 会有长度限制,一旦参数过多请求会被截断;二是跨域场景下部分浏览器会阻止图片加载,导致完全无法上报。
如果既不想用sendBeacon(有些老浏览器不支持),又不想用同步 XHR,那至少要在图片地址里用压缩编码,只上报最核心的字段,把参数控制在 2KB 以内。我的真实建议是,新项目直接检测navigator.sendBeacon是否存在,不支持再降级到图片打点。
4.5 坑5:用户根本没走,只是锁屏了
这是最容易误报的一类场景。桌面端锁屏、手机端锁屏、甚至只是合上了笔记本盖子,都可能触发visibilitychange进入 hidden,但页面并没有卸载,用户回来后立刻又变成 visible。如果一进 hidden 就上报“离开”,停留时长数据就会被拦腰截断。
处理逻辑在 2.3 节已经提过,核心是延迟判断:记录 hidden 开始时间,等到用户回到页面时再综合判断隐藏时长。如果用户回来后隐藏时长小于 5 分钟,这条日志根本不该上送。超过 10 分钟或 30 分钟,才按“长时隐藏(近似离开)”上报,并单独打标,方便后端把这部分数据与真实pagehide区分。
5. 常见问题与排查技巧实录
把实战中反复被问到的问题整理成一个速查表,方便直接对照排查。
5.1 常见问题速查表
| 现象 | 根因 | 解决策略 |
|---|---|---|
| 用户停留时长统计明显偏少 | 移动端进程被回收,不触发任何卸载事件 | visibilitychange + 定时心跳兜底 |
| 统计数据翻倍 | pagehide 和 beforeunload 同时监听并上报 | 只保留一个主事件,后端按 sessionId 去重 |
| 上报请求凭空丢失 | 异步 XHR 被页面卸载打断 | 换 sendBeacon 或 fetch keepalive |
| Safari 上 beforeunload 不触发 | iOS Safari 兼容性策略 | 改用 pagehide 替代 |
| 切个标签页回来统计多了一条 | visibilitychange 立即上报 | 阈值延迟 + 回来再确认 |
| SPA 路由跳转后被误报离开 | 路由变化未与真实卸载区分 | 监听 history API,打路由标记 |
| 页面数据上报成功但后端没收到 | sendBeacon 有大小限制或跨域问题 | 精简字段,配置 CORS 并限制 payload 大小 |
| WebView 切后台后事件完全消失 | 原生层回收了页面 | 桥接原生前后台回调或用心跳 |
5.2 实战排查流程分享
如果你接入了一套现成的埋点 SDK,发现卸载数据对不上,我建议按下面的顺序排查:
第一步,先确定用户处在什么环境。用navigator.userAgent和navigator.platform筛出 PC 端、移动端、App WebView 三类环境,分别看数据差异,这样能快速定位问题集中在哪类终端。
第二步,打开浏览器的开发者工具,在 Console 里手动触发一次pagehide,确认监听器是否挂载、sendBeacon是否成功投递。Network 面板里勾选“Preserve log”,关闭页面前看一眼请求是否发出。
第三步,如果是移动端,在真实手机上用远程调试工具观察 WebView 页面从后台切换回来时 Console 里有没有visibilitychange的日志,确定原生层是否影响事件触发。
第四步,看服务端接收日志的时间戳和前端上报时间戳的差值,如果普遍有几秒甚至几十秒延迟,说明sendBeacon是异步批量发送的,属于正常现象,但会影响实时看板,需要考虑减小上报体积或用 fetch keepalive 提升实时性。
5.3 几点心得
线上跑了一年多的页面卸载监控,我最大的体会是:不要试图用一种方法包打所有场景。PC 端的数据以pagehide为主,移动端 H5 以visibilitychange加长时隐藏判断为主,App 内 WebView 必须叠加心跳兜底,这才是接近完整的数据采集闭环。
另外,sendBeacon在有些浏览器上并不像文档里写得那么美好。我自己遇到过 Android Chrome 低版本下sendBeacon静默失败的情况,返回true但服务端收不到数据,后来排查发现是 localhost 环境下测试接口没有正确设置 CORS 响应头,生产环境也有类似的跨域问题。所以接入后第一件事不是看统计报表,而是找一台测试机反复开关页面,确认请求真的送达。
还有一个小技巧:上报数据里带上visibilityState和e.persisted这两个字段,排查问题时能省大量时间。前者告诉你离开那一刻页面到底是可见还是不可见,后者告诉你页面有没有进 bfcache,结合这两个字段能很快判断出一次上报到底是真卸载还是伪卸载。
我现在的团队在做统一前端监控 SDK 时,卸载上报模块锁定三个核心事件:pagehide负责真正的页面离开上报,visibilitychange辅助捕捉移动端后台切换,heartbeat心跳逻辑作为 App WebView 环境的最强兜底。这套组合上线后,用户停留时长的数据完整度从原来的 62% 提升到了 91%,虽然永远做不到 100%,但已经足够给产品和运营提供可靠的决策依据。
如果你正在做类似的行为分析、直播 H5 在线时长统计,或者 web 版 IM 的离线检测,这里面的方法和坑都可以直接参考。监听方案不必多,重点是选对主方案,把兜底和去重做好。