ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

浏览器报错unload被禁?迁移pagehide与visibilitychange实战

浏览器报错unload被禁?迁移pagehide与visibilitychange实战 1. 控制台里那条黄色警告到底在说什么第一次看到Permissions policy violation: unload is not allowed in this document这条报错的人大概率会愣一下。它不像Uncaught TypeError那样直接崩掉页面也不像 404 那样一眼看出资源丢了它只是安安静静躺在 Console 里黄色小三角语气还挺客气——violation违规但页面照跑不误。很多人第一反应是刷新一下发现没了然后继续干活也有人会心里发毛毕竟带 violation 这个词的报错总感觉像是自己哪里写错了。先把结论摆前面这条报错不是你的代码写崩了而是浏览器在告诉你——你正在使用的unload事件已经被 Permissions Policy权限策略限制在当前文档里不再被允许触发。换句话说浏览器主动拦截了unload事件的注册或执行并且用一条控制台警告通知你。它属于弃用预警性质不是致命错误但如果你不处理未来某天它就会从警告变成彻底失效。那unload到底做错了什么要被浏览器这样针对简单讲unload是页面生命周期里最晚的一个事件它在文档即将被卸载、资源即将被释放的那一刻触发。早年大家喜欢用它来做用户离开页面前保存数据上报埋点清理定时器这类收尾工作。但问题在于unload的触发时机太晚、太不可靠——浏览器为了性能越来越多地采用往返缓存Back/Forward Cache简称 bfcache也就是把你离开的页面整个冻结在内存里用户点后退时瞬间恢复。而unload事件的存在会直接破坏 bfcache因为浏览器不敢缓存一个随时可能执行卸载逻辑的页面。所以 Chrome 从 115 版本左右开始逐步收紧对unload的限制把它纳入 Permissions Policy 管控默认策略下直接禁止。你看到的这条报错本质上是浏览器在推动整个 Web 生态从unload迁移到更现代、更可靠的pagehide和visibilitychange。这篇文章适合谁看如果你是前端开发尤其是做过埋点上报、表单草稿保存、页面离开拦截的人这条报错你迟早会撞上如果你是测试或运维看到控制台一堆黄色警告想搞清楚要不要修这篇也能给你答案。我会把这条报错的来龙去脉、底层机制、迁移方案、踩坑经验一次讲透代码可以直接抄。2. unload 被拉黑的底层逻辑bfcache 与生命周期演进2.1 unload 的黄金年代与它的原罪要理解为什么浏览器要封杀unload得先回到它被广泛使用的年代。大概在 2010 年前后前端还处于 jQuery 统治时期页面交互简单SPA 还没普及。那时候大家做离开页面前保存的需求标准做法就是window.addEventListener(unload, function() { // 同步上报埋点 navigator.sendBeacon(/log, data); // 或者清理资源 clearInterval(timer); });这段代码看起来天经地义但它埋了两个雷。第一个雷是同步阻塞unload里如果做同步的 XHR 请求浏览器必须等请求完成才能卸载页面用户会感觉点了关闭但页面卡住不动。第二个雷更隐蔽——它让浏览器无法使用 bfcache。你可以把 bfcache 想象成浏览器的暂停键。用户从 A 页面跳到 B 页面如果 A 页面没有注册unload浏览器就把 A 页面整个冻结包括 JS 堆、DOM 状态、滚动位置用户点后退时直接解冻速度接近瞬时。但如果 A 页面注册了unload浏览器就不敢冻结它——因为一旦冻结unload就没机会执行了页面作者预期的清理逻辑会丢失。于是浏览器只能选择真正销毁 A 页面后退时重新加载用户体验从瞬间恢复退化成重新加载。这就是unload的原罪它用一点点收尾能力换走了整个页面的缓存加速能力。在移动端和弱网环境下这个代价尤其昂贵。2.2 Permissions Policy 是怎么把 unload 管起来的Permissions Policy早期叫 Feature Policy是浏览器提供的一套功能开关机制允许开发者通过 HTTP 响应头或 iframe 的allow属性控制某些浏览器特性在页面或子框架里是否可用。比如你可以禁止页面使用摄像头、麦克风、地理位置等。Chrome 把unload纳入这套机制逻辑是这样的默认情况下unload事件的权限被设为deny拒绝。当你的代码尝试注册unload监听器时浏览器检查当前文档的 Permissions Policy发现unload不被允许于是拒绝注册并在控制台抛出Permissions policy violation: unload is not allowed in this document。这里有个关键细节很多人会误解这条报错并不意味着你的unload回调一定没执行。在过渡期Chrome 的行为是警告但可能仍执行具体取决于版本和策略配置。但趋势非常明确——最终会彻底不执行。所以你不能抱着反正还能跑的心态忽略它。如果你想显式允许unload不推荐只是让你理解机制可以通过响应头这样配置Permissions-Policy: unload*或者在 iframe 上iframe src... allowunload/iframe但我要泼盆冷水即使你显式允许也只是延缓死亡。Chrome 团队已经明确表示unload是正在被淘汰的 API显式开启只是给老项目一个缓冲期不是长期方案。而且显式允许unload会让你的页面失去 bfcache 资格性能上得不偿失。2.3 为什么是 pagehide 和 visibilitychange 接棒浏览器给出的替代方案是两个事件pagehide和visibilitychange。它们不是随便选的而是各自解决了unload的一个痛点。pagehide的触发时机比unload早它在页面即将被卸载或存入 bfcache 时都会触发。关键区别在于pagehide不会阻止 bfcache。浏览器可以放心地冻结页面因为pagehide已经在冻结前执行过了。pagehide事件对象里有个persisted属性如果为true说明页面被存入了 bfcache为false说明页面真的要被销毁了。visibilitychange则更早、更频繁。当用户切换标签页、最小化窗口、锁屏时document.visibilityState会从visible变成hidden触发这个事件。它的优势是覆盖了用户没离开但页面不可见的场景比如移动端用户按了 Home 键此时页面没卸载但你应该保存状态。把这两个事件组合起来就能覆盖unload原本的所有使用场景而且不破坏 bfcache。下面这张表能帮你快速看清差异事件触发时机是否破坏 bfcache推荐用途unload文档卸载前最晚是已弃用不要再用pagehide卸载或存入 bfcache 前否上报、清理、保存visibilitychange页面可见性变化时否暂停媒体、保存草稿beforeunload卸载前可弹确认框是有条件仅用于确认离开提示注意beforeunload也在被限制但它和unload不同——beforeunload只在需要弹出你确定要离开吗确认框时才有意义而且现代浏览器要求必须有用户交互才能触发弹窗。它同样会影响 bfcache所以非必要不用。3. 从 unload 迁移到 pagehide 的完整改造路径3.1 先判断你的 unload 到底在干什么迁移不是无脑把unload换成pagehide就完事因为两者的触发时机和可靠性不同。第一步是盘点你现有的unload监听器按用途分类。常见的用途有这么几类埋点上报用户离开时发送停留时长、页面行为数据草稿保存把表单内容存到 localStorage 或发到服务端资源清理清除定时器、断开 WebSocket、取消订阅状态同步通知服务端用户已离线不同用途的迁移策略不一样。埋点上报和草稿保存适合用pagehidevisibilitychange双保险资源清理其实大部分情况下不需要在卸载时做因为页面销毁后浏览器会自动回收状态同步则要考虑用sendBeacon保证请求能发出去。我见过最典型的错误是有人直接把unload改成pagehide结果发现埋点数据丢了一半。原因是pagehide在某些场景下比如用户直接关闭标签页触发时机也很紧张异步请求可能来不及发。正确做法是配合navigator.sendBeacon它是专门为页面卸载时发送数据设计的 API浏览器会保证请求在后台发出不阻塞卸载。3.2 埋点上报的标准迁移写法先看改造前的代码这是很多老项目里的样子// 改造前使用 unload会触发 Permissions Policy 警告 window.addEventListener(unload, function() { const data JSON.stringify({ duration: Date.now() - startTime, path: location.pathname }); // 同步 XHR阻塞卸载 const xhr new XMLHttpRequest(); xhr.open(POST, /api/log, false); xhr.send(data); });这段代码有两个问题unload被限制同步 XHR 阻塞。改造后应该长这样// 改造后pagehide visibilitychange sendBeacon let hasReported false; function reportLeaveData() { if (hasReported) return; hasReported true; const data JSON.stringify({ duration: Date.now() - startTime, path: location.pathname, persisted: false }); // sendBeacon 异步发送不阻塞卸载 navigator.sendBeacon(/api/log, data); } // pagehide 覆盖真正的卸载和 bfcache 场景 window.addEventListener(pagehide, function(event) { // event.persisted 为 true 表示进入 bfcache页面可能还会回来 // 此时上报要谨慎因为用户可能后退回来继续操作 if (!event.persisted) { reportLeaveData(); } }); // visibilitychange 覆盖切标签、锁屏等场景 document.addEventListener(visibilitychange, function() { if (document.visibilityState hidden) { reportLeaveData(); } });这里有几个设计决策值得展开讲。第一为什么用hasReported标志位因为pagehide和visibilitychange可能都触发如果不做去重同一次离开会上报两次数据就脏了。第二为什么pagehide里判断event.persisted因为如果页面进入 bfcache用户很可能后退回来这时候上报离开是不准确的应该等真正销毁时再报。第三sendBeacon的数据大小有限制一般 64KB大 payload 要考虑分片或改用fetch的keepalive选项。提示sendBeacon发送的请求默认是 POSTContent-Type 取决于你传的数据类型。传字符串是text/plain传FormData是multipart/form-data传Blob可以自定义类型。服务端要相应处理。3.3 草稿保存场景的差异化处理草稿保存和埋点上报不一样它要求用户输入的内容不能丢。这时候光靠pagehide不够因为用户可能在输入过程中就切走了等不到pagehide。更稳的方案是输入时防抖保存 离开时兜底保存。const DRAFT_KEY form_draft; // 输入时防抖保存每 2 秒最多存一次 const saveDraft debounce(function(formData) { localStorage.setItem(DRAFT_KEY, JSON.stringify(formData)); }, 2000); form.addEventListener(input, function() { saveDraft(collectFormData()); }); // 离开时兜底保存用 pagehide 而非 unload window.addEventListener(pagehide, function() { localStorage.setItem(DRAFT_KEY, JSON.stringify(collectFormData())); }); // 切标签时也保存一次 document.addEventListener(visibilitychange, function() { if (document.visibilityState hidden) { localStorage.setItem(DRAFT_KEY, JSON.stringify(collectFormData())); } });这套组合拳的逻辑是防抖保存保证常规输入不丢pagehide和visibilitychange做最后兜底。localStorage 是同步 API在pagehide里写没问题不会像网络请求那样来不及。如果你要存到服务端那就得用sendBeacon并且接受可能丢最后一次的现实——这是 Web 平台的固有限制没有完美方案。3.4 资源清理其实大多可以不做很多人用unload是为了清理定时器、解绑事件、关闭连接。但这里有个认知误区页面真正卸载后浏览器会自动回收该页面的所有 JS 对象、定时器、事件监听器。你手动清理在页面销毁场景下是多余的。真正需要清理的是页面没销毁但不可见的场景比如视频/音频在切标签时暂停省电省流量轮询请求在页面隐藏时降频或暂停WebSocket 在长时间隐藏时考虑断开重连这些场景用visibilitychange处理更合适document.addEventListener(visibilitychange, function() { if (document.visibilityState hidden) { pausePolling(); pauseMedia(); } else { resumePolling(); resumeMedia(); } });注意这里不要用pagehide因为切标签时pagehide不触发。visibilitychange才是页面不可见的正确信号。4. 排查链路当报错不止一条时怎么定位4.1 先确认报错来源是自家代码还是第三方控制台里出现Permissions policy violation: unload is not allowed第一件事不是改代码而是搞清楚是谁注册了unload。因为很多时候这个监听器根本不在你的业务代码里而是某个第三方 SDK、统计脚本、广告脚本偷偷加的。排查方法很直接在控制台里打断点或者用猴子补丁拦截addEventListener。下面这段代码贴到 Console 里执行然后刷新页面就能看到所有unload注册的调用栈const originalAdd EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener function(type, listener, options) { if (type unload) { console.warn(检测到 unload 监听器注册, listener); console.trace(调用栈); } return originalAdd.call(this, type, listener, options); };执行后刷新控制台会打印出注册unload的完整调用栈。如果栈顶指向某个vendor.js或sdk.js那就是第三方的问题如果指向你自己的业务代码那就按第 3 节的方案改造。我实际排查过一个案例某电商页面控制台一直报这个错业务代码翻遍了没有unload最后用上面的方法定位到是一个老版本的客服 SDK 在注册。这种第三方问题你改不了源码只能升级 SDK 版本、联系厂商、或者用Permissions-Policy响应头显式允许unload作为临时缓解。4.2 区分警告和功能失效这条报错有个迷惑性它出现时页面功能可能完全正常。于是很多人判断不用管。但你要区分两种情况第一种浏览器只是警告unload回调仍然执行。这种情况下功能正常但你在技术债上又欠了一笔未来某次浏览器升级就会突然失效。第二种浏览器已经拒绝注册unload回调根本不执行。这种情况下如果你的埋点、草稿保存依赖它数据已经在悄悄丢失了只是你没发现。怎么判断当前是哪种在unload回调里打个日志然后手动触发页面卸载比如关闭标签页日志会输出到保留日志里需要提前勾选 Console 的 Preserve logwindow.addEventListener(unload, function() { console.log(unload 执行了, Date.now()); });如果关闭页面后日志没出现说明回调没执行功能已经失效。如果出现了说明还在过渡期但别高兴太早。4.3 用 Performance API 验证 bfcache 是否生效迁移到pagehide之后你怎么确认 bfcache 真的生效了Chrome DevTools 的 Application 面板里有 Back/forward cache 测试工具但更通用的方法是用PerformanceNavigationTiming的type属性window.addEventListener(pageshow, function(event) { if (event.persisted) { console.log(页面从 bfcache 恢复速度飞快); } else { console.log(页面正常加载); } });pageshow事件在页面显示时触发event.persisted为true表示来自 bfcache。如果你迁移后看到这个日志在后退时打印true说明 bfcache 生效了迁移成功。反之如果一直是false说明还有东西在阻止 bfcache需要继续排查——常见元凶包括unload监听器、beforeunload监听器、打开的 IndexedDB 连接、未关闭的 WebSocket 等。注意bfcache 的阻止因素很多unload只是其中之一。如果你迁移后 bfcache 仍不生效可以用 Chrome DevTools 的 Back/forward cache 面板查看具体原因它会明确告诉你哪个 API 或资源阻止了缓存。5. 那些年我踩过的坑与实战经验5.1 sendBeacon 不是万能的数据大小和跨域都有坑sendBeacon看起来很美但实际用起来有几个坑。第一个是数据大小限制。规范建议不超过 64KB超过后浏览器可能直接丢弃请求而且不报错。我遇到过埋点数据里带了完整 DOM 快照结果超过限制数据静默丢失排查了半天。第二个是跨域问题。sendBeacon发跨域请求时如果服务端没配 CORS请求会失败。而且因为它是异步的失败了你也不知道。解决方案是确保服务端返回正确的 CORS 头或者用同域的中转接口。第三个是Content-Type 的坑。如果你传字符串默认是text/plain有些服务端框架不认这个类型解析不出 body。稳妥做法是传Blob并指定类型const blob new Blob([JSON.stringify(data)], { type: application/json }); navigator.sendBeacon(/api/log, blob);这样服务端就能按 JSON 正常解析了。5.2 visibilitychange 触发太频繁别在里面做重活visibilitychange的触发频率比你想的高。用户切个标签、点一下地址栏、甚至某些系统弹窗都可能触发hidden。如果你在里面做重活——比如发大请求、做复杂计算——会明显影响性能。我的经验是visibilitychange里只做轻量操作比如记录时间戳、设置标志位、暂停定时器。真正的数据上报放到pagehide里做或者用防抖控制频率。另外要注意visibilitychange在页面加载时也可能触发一次从hidden到visible你的逻辑要能处理这种初始状态。5.3 别用 beforeunload 做数据保存有些人为了确保数据保存在beforeunload里也加保存逻辑。这是大忌。beforeunload的语义是询问用户是否确认离开如果你在里面做数据保存会拖慢弹窗出现速度用户体验很差。而且现代浏览器对beforeunload的限制越来越严没有用户交互的页面根本不会触发它。正确分工是beforeunload只用于有未保存内容时提示用户数据保存交给pagehide和visibilitychange。两者不要混用。5.4 迁移后记得回归测试这些场景迁移完成后别只测关闭标签页一种场景。下面这些场景都要覆盖场景预期行为验证方法关闭标签页数据上报成功看服务端日志切换标签页草稿保存成功切回来检查输入框点击后退bfcache 生效页面秒开看 pageshow 的 persisted移动端按 Home状态保存回到页面检查刷新页面数据不重复上报检查去重逻辑强杀进程数据可能丢失可接受无最后一行强杀进程要特别说明移动端用户上滑杀掉 App或者系统内存回收这种情况下任何离开事件都可能不触发数据丢失是平台限制无法完全避免。产品需求上要接受这个现实不要为了100% 不丢去做各种 hack得不偿失。5.5 关于 Permissions-Policy 响应头的取舍如果你的项目里第三方 SDK 暂时无法升级短期内又必须让unload工作可以用响应头临时放行Permissions-Policy: unload*但我强烈建议把这个当作临时方案并且设置一个明确的移除时间点。因为放行unload意味着放弃 bfcache页面后退速度会明显变慢在移动端尤其影响体验。而且这个头一旦加上很容易被遗忘变成永久技术债。更好的做法是用响应头放行的同时在代码里加 TODO 注释和监控追踪unload的使用情况推动第三方升级。等第三方修复后立即移除响应头。6. 面向未来的页面生命周期处理思路6.1 把生命周期逻辑收敛到统一模块散落各处的addEventListener(unload/pagehide/visibilitychange)是维护噩梦。我的做法是封装一个统一的生命周期管理模块所有离开相关的逻辑都走它class PageLifecycle { constructor() { this.handlers { leave: [], hide: [], show: [] }; this.bindEvents(); } bindEvents() { window.addEventListener(pagehide, (e) { if (!e.persisted) this.emit(leave); }); document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { this.emit(hide); } else { this.emit(show); } }); } on(type, handler) { this.handlers[type].push(handler); } emit(type) { this.handlers[type].forEach(fn { try { fn(); } catch (e) { console.error(e); } }); } } // 使用 const lifecycle new PageLifecycle(); lifecycle.on(leave, () reportData()); lifecycle.on(hide, () saveDraft());这样业务代码不直接碰原生事件迁移和测试都方便。而且统一模块里可以做去重、错误隔离、日志埋点比散落各处强太多。6.2 关注 Page Lifecycle API 的完整体系pagehide和visibilitychange只是 Page Lifecycle API 的一部分。完整的体系还包括freeze、resume、pageshow等事件它们共同描述了页面从活跃到冻结到销毁的完整状态机。对于大多数业务掌握pagehidevisibilitychangepageshow三件套就够了。但如果你做的是 PWA、离线应用、或者对状态恢复要求高的场景建议把freeze/resume也纳入处理。freeze在页面被浏览器冻结时触发比如长时间不可见此时你应该释放占用的资源resume在解冻时触发重新初始化。这套 API 的设计哲学是页面生命周期不是生或死的二元状态而是一个连续的状态谱。理解这一点你就能写出更健壮、更省电、更符合浏览器预期的代码。6.3 建立监控别等用户投诉才发现数据丢了最后分享一个工程实践给生命周期相关的上报加监控。具体做法是在pagehide触发时记录一个本地标记下次页面加载时检查这个标记——如果标记存在但服务端没收到对应数据说明上报失败了。// 离开时打标记 window.addEventListener(pagehide, function() { localStorage.setItem(pending_report, JSON.stringify({ id: reportId, time: Date.now() })); navigator.sendBeacon(/api/log, data); }); // 下次加载时检查 window.addEventListener(load, function() { const pending localStorage.getItem(pending_report); if (pending) { // 说明上次上报可能失败补报或记录 console.warn(检测到未完成的上报, pending); localStorage.removeItem(pending_report); } });这个机制能帮你发现静默失败——那些用户没感知、但数据确实丢了的场景。上线后我靠它抓到过好几次sendBeacon被拦截的问题比等数据分析师来问为什么某天数据掉了要主动得多。页面生命周期这块浏览器厂商的推进方向很明确unload会死pagehide和visibilitychange是未来。与其等报错变成故障不如现在就动手迁移。改造量通常不大但收益是实打实的——更快的后退速度、更省电的移动端体验、更可靠的数据上报。
返回列表