ARTICLE DETAIL

资讯详情

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

onbeforeunload 离开拦截边界与未保存数据保存方案

onbeforeunload 离开拦截边界与未保存数据保存方案 后台编辑页填了四十多分钟的东西手一抖点了刷新白屏回来全没了。这种事故我在三个不同的项目里都遇到过每次复盘都会绕回同一个话题onbeforeunload到底能不能可靠地把用户拦下来。答案是有条件能——onbeforeunload是浏览器提供给页面的、唯一一个能在刷新、关闭标签页、跳转离开这些动作发生前插入一次确认的接口但它同时也是被规范收得最紧、在不同浏览器上表现差异最大的接口之一。这篇文章面向的是做管理后台、在线编辑器、问卷系统、填报类页面的前端同学也会照顾到刚接触这个 API 的新手。我会从拦截边界讲到能直接抄的代码再到后退键的处理、离开瞬间保存数据、移动端现实情况以及我自己踩过的几个坑尽量把能拦什么、为什么拦不住、怎么绕讲透。1. onbeforeunload 的拦截边界哪些动作能拦哪些根本拦不住很多人对onbeforeunload的第一印象是能阻止用户离开页面于是下意识觉得它就等于一个万能的离开确认。真实情况要窄得多它只在文档即将被卸载的那一刻生效而且浏览器给了用户最终决定权你的代码只能请求确认不能强制留下。1.1 不同触发场景的实际表现差异把常见操作列一张表你就能直观看到它的覆盖范围用户操作是否触发 onbeforeunload说明F5 / CtrlR / 地址栏回车触发文档会重新加载属于卸载行为关闭当前标签页触发前提是该标签页有过用户交互点击站内或站外链接跳转触发跨文档导航浏览器后退/前进跨文档触发目标页是另一个文档时成立单页应用内部路由跳转不触发文档没有卸载只是 JS 换了视图移动端侧滑返回跨文档多数不弹系统级手势优先级更高手机端切到后台、锁屏不触发页面还在只是不可见这张表里最容易让人误解的是第五行。如果你用的是前端路由history 模式或 hash 模式用户在应用内部点菜单跳走文档根本没有卸载onbeforeunload完全不会被调用。想要拦这类跳转得走路由守卫onbeforeunload帮不上忙——这两套机制必须分开设计不能指望一个顶两个。1.2 后退按钮为什么是最麻烦的那一个跨文档的后退确实会触发onbeforeunload用户在确认框里点留在此页之后浏览器会取消这次后退地址栏和历史记录都保持在原位置这一点是符合直觉的。真正麻烦的是另外两种情况。第一种是浏览器缓存机制。现代浏览器会尽可能把上一个页面整体存进缓存bfcache再后退时直接恢复这个快照而不是重新加载文档。页面被恢复时走的是pageshow事件persisted字段为true你之前在内存里维护的脏数据变量其实还在但如果你的判断逻辑依赖DOMContentLoaded去重算一遍就可能算错。历史上桌面端 Chrome 对带beforeunload监听的页面是否允许进入这个缓存策略调整过好几轮所以同一个按钮在不同版本上测出来的行为可能不一致。判断方法很简单在pageshow里打印event.persisted就行别靠猜。第二种是单页应用里的后退。用户在编辑页点了浏览器后退前端路由把视图切回列表页这个过程不触发beforeunload你的方案会静默失效。要拦它只能靠popstate后面第 4 节会展开讲完整链路。1.3 自定义文案早就被浏览器收走了但触发方式还有讲究如果你翻十年前的博客会看到有人写return 确定要离开吗未保存的内容将丢失然后期待浏览器原样显示这句话。现在不行了主流浏览器会统一弹出自己的文案你返回什么字符串都被忽略。所以别再把精力花在措辞上那部分文案不受你控制。但这里有个反直觉的细节虽然文案被忽略你还是必须给事件赋值弹窗才会出现。只写一个空的监听函数是不管用的。规范里推荐的做法是两步都做调用event.preventDefault()同时给event.returnValue赋一个非空字符串老版本 Chrome 依赖这个。两项都写兼容性最稳。另外还要记住浏览器对自定义弹窗有节流策略Chrome 在弹过几次之后会在框里给一个阻止此页面创建更多对话框的勾选项用户勾上之后你的拦截就彻底失效了。这个勾选状态是用户侧的你无法重置只能接受。2. 一份能直接抄的实现监听注册、触发前提与解绑时机边界搞清楚了接着看代码。这个 API 的写法本身只有几行但踩坑点几乎全在什么时候加、什么时候摘上面。2.1 标准写法与老式写法的取舍我通常用addEventListener注册避免和第三方库抢window.onbeforeunload这个属性位// 保存监听器引用方便后续解绑 const handler (event) { if (!hasUnsavedChanges()) return; // 没有脏数据就放行不弹窗 event.preventDefault(); // 标准做法 event.returnValue 您有未保存的内容; // 兼容老版本 Chrome / Edge }; function enableLeaveGuard() { window.addEventListener(beforeunload, handler); } function disableLeaveGuard() { window.removeEventListener(beforeunload, handler); }有些人图省事写window.onbeforeunload handler然后清理时写window.onbeforeunload null。问题在于如果同页面里别的模块比如某个富文本编辑器插件、某个埋点 SDK也用属性方式注册过你这一句null会把别人的监听一起干掉而且这种问题在联调阶段特别难查因为表现是另一个功能的监控莫名其妙没了。所以我的原则是只要页面上存在多个可能关心离开事件的模块一律用addEventListener。2.2 必须先有用户交互弹窗才会真的出现这是新手最常反馈的问题我代码明明写对了为什么刷新的时候什么都没弹原因是 Chrome 和 Firefox 都对beforeunload弹窗加了用户激活user activation的前置条件。简单说用户必须在这个页面上有过真实的交互动作——点击、输入、滚动之外的按键等等——之后弹窗才会被允许显示。用户刚打开页面、什么都没碰就按 F5浏览器会直接放行你的监听函数会执行但不会有任何视觉反馈。这个设计的出发点是防止恶意页面用弹窗劫持用户。理解了它就不会再以为是自己的代码写错了。反过来说如果你的业务场景是用户一进页面就可能有未提交数据比如自动带出草稿那就要意识到在用户第一次交互之前这段保护是形同虚设的只能在业务层再补一道自动保存。2.3 解绑比绑定更容易出问题我在真实项目里遇到过的三类事故都出在解绑上第一类是保存成功后忘记摘监听。用户点了保存数据已经落库然后点关闭标签页还是弹有未保存内容用户会认为系统在骗人。正确顺序永远是请求成功回调里先把脏标记清掉或摘掉监听再去执行路由跳转或提示成功。第二类是单页应用里监听被重复注册。编辑页挂载时注册一次用户没保存就切到另一个编辑页又注册一次此时同一个handler被加了两次浏览器只弹一个框事件监听不会重复触发同名函数但如果你用的是内联箭头函数而不是具名引用removeEventListener根本摘不掉监听会一直挂在 window 上页面越用越卡。所以监听器一定要存引用别图省事写内联。第三类是组件卸载时无条件摘监听。用一个全局开关控制是否处于编辑态的组件如果在卸载时把监听摘掉而用户此时是从编辑页跳到预览页预览页也允许编辑保护就断了。这里的判断依据应该是业务状态而不是组件生命周期。3. 别做无脑弹窗用脏数据标记决定要不要拦我见过最糟糕的实现是编辑页一挂载就无条件注册监听于是用户什么都没改、只是想关掉页面也要被弹一次您有未保存的内容。多来几次用户就会去勾那个阻止此页面创建更多对话框从此你的保护彻底失效。这是典型的为了安全牺牲体验最后连安全也丢了。3.1 脏标记放在哪里最合适脏标记只需要一个布尔值放在内存里就行不要在beforeunload里读localStorage或做 JSON 解析——这个回调执行时间极短任何同步的重活儿都会让关闭动作明显卡顿。我的做法是维护一个极轻量的状态模块const dirtyState { count: 0, // 有几个来源标记为脏 markDirty() { this.count 1; return () this.markClean(); }, markClean() { this.count Math.max(0, this.count - 1); }, isDirty() { return this.count 0; }, };用计数器而不是布尔值是为了支持多个独立表单区块的场景。一个页面里可能有基础信息、附件、富文本三块内容任意一块被改过都应该拦截而每一块保存成功后只应该让自己的那一份计数减一。用布尔值的话A 块保存成功把标记清了B 块的改动就丢了保护。触发时机我一般绑在输入框的input事件和富文本编辑器的变更回调上第一次触发时标记为脏后续重复触发直接短路返回避免高频写状态。3.2 保存成功后的清理顺序最容易写错看下面这段伪代码你能发现它的问题吗async function save() { await api.save(formData); router.push(/list); // 先跳走 dirtyState.markClean(); // 再清标记 }问题在于router.push是同步执行的路由切换会立刻触发组件的卸载流程如果卸载逻辑里做了清理或者别的组件此时读取脏标记就会读到旧值。更稳妥的顺序是先把状态收拾干净再触发导航async function save() { await api.save(formData); dirtyState.markClean(); // 先清标记监听器自然就不再拦截 router.push(/list); }这里之所以不需要真的去removeEventListener是因为监听函数里第一行就判断了isDirty()不脏就直接return效果等价而且省掉了引用管理的麻烦。这也是我推荐的一种少写代码少出错思路。3.3 哪些场景该弹、哪些场景不该弹脏标记的判定规则最好在团队里形成共识否则不同人实现出来的编辑页行为不一致用户会觉得系统时灵时不灵。我整理的判断表大概是这样场景是否拦截理由只读了内容一个字没改不拦拦了纯属骚扰改了一个字段又手动改回原值可以拦也可以不拦精确比对成本高能做到就做做不到不必强求填写了草稿但已自动保存到本地不拦数据没丢弹窗没意义提交请求正在飞行中不拦拦了用户也不知道该选什么上传了大附件且未提交拦这类数据丢失代价最高这段规则里我想强调最后一行。文件上传是表单体验里最脆弱的一环用户选了几十个文件、等了半分钟误点一下刷新就全白费了。这种情况下即使编辑框本身没有输入变更事件可监听也应该主动把脏标记置上。4. 拦住后退history 补位与 popstate 的完整链路单页应用里最让人头疼的就是后退键。用户习惯性地按后退视图切回列表页未保存的编辑内容还在内存里但已经看不见了用户只会觉得刚填的东西没了。这一节讲怎么在纯前端范围内把它拦住以及要付出什么代价。4.1 为什么 beforeunload 对内部路由完全无效前面说过onbeforeunload只关心文档卸载。前端路由切换时文档没动只是 React/Vue 换了要渲染的子树所以这个事件连触发的机会都没有。要拦它必须监听popstate。但popstate有个致命特性它在历史记录已经发生变化之后才触发。也就是说当你收到事件回调时浏览器的历史指针已经退到上一条记录了地址栏也变了。你能做的只是在回调里把指针推回去而不是阻止后退发生。这就是为什么所有拦截后退的方案看起来都带着点补救的味道——因为本质上它确实是补救。4.2 pushState 补一条记录的实现与代价主流做法是在进入编辑页时往历史里多压一条同样的记录同时塞一个标记字段然后用popstate监听用户的返回动作const GUARD_KEY leaveGuard; function enterEditPage() { // 补一条用户按后退时会退到这条上从而触发 popstate history.pushState({ [GUARD_KEY]: true }, , location.href); window.addEventListener(popstate, onPopState); } function onPopState(event) { if (!isDirty()) { window.removeEventListener(popstate, onPopState); history.back(); // 没脏数据把多压的那条还回去 return; } const leave window.confirm(内容还没保存确定要离开吗); if (leave) { window.removeEventListener(popstate, onPopState); history.back(); } else { // 用户选择留下把历史指针推回编辑态 history.pushState({ [GUARD_KEY]: true }, , location.href); } }这段代码能跑但你必须清楚它带来的副作用这些副作用在产品评审时最好提前跟产品同学说清楚一是历史记录里会多出一条重复项。用户长按浏览器后退按钮展开历史列表时会看到两个一模一样的编辑页条目。这在移动端尤其明显。二是前进按钮的行为会变得奇怪。用户被拦下来之后按前进可能会在两条同样的记录之间来回跳而不是正常前进到别的页面。三是 Safari 对 pushState 有频率限制。短时间内高频调用大约 30 秒内 100 次左右会直接抛SecurityError页面脚本报错。用户如果反复按后退、每次都取消是有可能撞到这个上限的。所以取消分支里不要做额外的pushState操作一次就够。四是入口 URL 携带的锚点或查询参数可能被替换掉。第三个参数我传的是location.href如果原始 URL 上有 hash 锚点、且页面内滚动依赖它最好显式保留。我一般用location.pathname location.search location.hash拼出来避免某些浏览器对相对路径的处理差异。4.3 原生 confirm 与业务弹窗的冲突处理上面用了window.confirm理由是它同步阻塞而popstate的处理窗口非常短用异步的自定义弹窗组件很容易出现弹窗还没渲染出来用户又按了一次后退的竞态。同步 confirm 至少保证了逻辑的原子性。但confirm的观感确实粗糙和现在后台系统的设计语言不搭。如果你的产品坚持要自定义弹窗那必须加一道状态锁let confirming false; function onPopState() { if (confirming) return; if (!isDirty()) { history.back(); return; } confirming true; openCustomDialog({ onConfirm: () { confirming false; history.back(); }, onCancel: () { confirming false; history.pushState({}, , location.href); }, }); }这段代码的关键是confirming这个变量。没有它用户连续快速按两次后退就会弹出两个模态框第二个框关闭时又会执行一次history.back()结果直接退出两层跳到意料之外的页面。另外一个细节在beforeunload的处理函数里是不能调用alert或confirm的浏览器会静默忽略。这两个场景的弹窗方式必须分开设计别想着复用同一套弹窗组件。5. 离开瞬间保存数据sendBeacon、visibilitychange 的正确组合拦截是为了让用户做选择但更高级的体验是用户根本不需要选择——在离开的瞬间把数据悄悄存下来。这条路能走但坑比拦截更多。5.1 为什么在 beforeunload 里发普通请求经常丢最直觉的写法是在beforeunload里调一次fetch或axios.post把表单数据发出去。实测下来这个请求的到达率相当不稳定。原因是异步请求依赖事件循环和网络线程调度而文档卸载会销毁当前的执行环境请求还没握手完就被掐断了。数据量小、网络好的时候看起来能成功一旦遇到弱网或者请求体较大就静默丢失而且你没有任何办法在客户端感知到这次丢失。如果你试过用同步 XHR 来兜底会发现它确实更可靠但代价巨大同步请求会阻塞主线程用户点关闭之后页面卡住几百毫秒甚至更久体验非常糟而且浏览器已经在逐步限制这种用法。我不推荐这条路除非是极小体量的关键标记比如这个会话已提交这种一个字节的信息。5.2 visibilitychange、pagehide、beforeunload 三者的分工正确的做法是把保存这件事从beforeunload里挪出来交给更早、更可靠的时机事件触发时机适合做什么visibilitychangehidden切标签、锁屏、最小化、关闭前保存草稿、暂停计时器pagehide文档卸载或进入缓存上报会话时长、清理资源beforeunload卸载前一刻只做拦截图别做重活其中visibilitychange是最值得信赖的。因为移动端用户关闭页面的真实方式往往是切到后台然后被系统回收这个过程会稳定触发hidden而beforeunload在移动端基本指望不上。把保存逻辑放在hidden里等于同时覆盖了桌面端的切标签和移动端的切后台两种场景。document.addEventListener(visibilitychange, () { if (document.visibilityState hidden isDirty()) { navigator.sendBeacon(/api/draft, buildDraftPayload()); } }); window.addEventListener(pagehide, (event) { // event.persisted 为 true 表示页面进了缓存之后可能被恢复 reportSessionEnd({ cached: event.persisted }); });pagehide的参数persisted很值得利用。它是true的时候说明用户这次离开是可能还会回来的比如按了后退、或者跳到了新页面但旧页面被缓存你就可以选择不清空本地草稿是false的时候说明页面真的要被销毁了可以做一些更彻底的清理。5.3 sendBeacon 与 fetch keepalive 的参数细节sendBeacon的优点是它把请求交给浏览器底层去发即使页面已经关闭浏览器也会尽力把它送出去。但有几个限制必须记住总负载上限 64KB。超过这个大小会返回false请求根本不会发出。所以不能拿它传整份表单只能传关键字段或者本地存储的索引。只能用 POST。想用 PUT 或者带自定义语义的方法它做不到。默认不携带自定义请求头。如果你后端依赖Content-Type: application/json用Blob构造时要把类型写对否则后端会按表单解析。稳妥的写法是new Blob([JSON.stringify(data)], { type: application/json })。同源策略照旧。跨域时依然受 CORS 约束需要服务端配合。如果不想引入sendBeaconfetch的keepalive: true也是一个选择语义和限制类似同样有 64KB 的量级约束而且在同一时间窗内多个 keepalive 请求的总大小是合并计算的。两条路我都试过结论是小于 60KB 的轻量上报用sendBeacon更省心需要读响应体的场景用fetch keepalive。但无论哪条路都只能作为尽力而为的兜底真正的草稿保存还是要靠定时自动保存。6. 移动端与 iframe兼容性账本上的现实情况桌面端调通了不等于事情结束。真实项目里这两个场景会吃掉你不少时间。6.1 各端表现对照环境beforeunload 弹窗备注桌面 Chrome / Edge支持文案不可控需要有用户交互弹多次后可被用户永久关闭桌面 Firefox支持文案不可控同样需要用户交互桌面 Safari支持但有历史遗留差异弹窗行为和版本相关建议实测iOS Safari基本忽略手势返回不弹切后台不弹Android Chrome基本忽略关闭场景依赖visibilitychange兜底微信内置浏览器不弹需要引导用户手动保存这张表的意思是移动端不要把离开确认当成产品方案的一部分。如果产品经理要求用户返回时提示未保存正确做法是在页面内做一个离开前的二次确认路由比如编辑页左上角的自定义返回按钮接管返回逻辑而不是依赖浏览器弹窗。6.2 iframe 场景下监听到底挂在哪一层后台系统里经常用 iframe 嵌入其他系统的页面。这时beforeunload的行为会变得比较微妙卸载整个标签页时浏览器会考虑顶层文档和子框架的拦截意图实际表现和浏览器的实现策略有关跨域 iframe 里的监听你既看不到也控制不了。我的经验是跨域 iframe不要指望能拦。能做的只有在父页面统一拦截或者在 iframe 内的业务代码里自建对话框。父页面拿不到跨域子文档的脏状态这是同源策略决定的没有绕过的必要。同源 iframe可以在子页面里注册监听也可以用contentWindow从父页面访问但要小心父页面和子页面同时弹窗导致的双重确认。统一在一层处理另一层只负责上报状态。组件卸载时要同步摘掉监听特别是那种用iframe承载打印预览、地图选点、在线文档的场景切来切去很容易攒下一堆监听。顺带说一句有些人会想着用window.open打开的新窗口然后用window.opener反向操作父页面这在现代浏览器里受到很严格的限制浏览器会把它当成潜在的滥用行为。跨窗口通信老老实实用postMessage。6.3 与打印控件、上传控件这类第三方库共存后台项目里通常还会引入打印控件、上传控件、扫码控件这类东西。它们各自的实现方式不同有的会自己注册onbeforeunload有的会劫持window.onbeforeunload属性。如果你发现我们代码里明明没绑为什么还会弹框第一件事就是用下面的代码把当前所有监听打出来看看部分浏览器支持// 仅用于排查不要留在生产代码里 const original window.addEventListener; window.addEventListener function (type, listener, options) { if (type beforeunload) { console.trace(beforeunload 被注册来源, listener); } return original.call(this, type, listener, options); };把这段放在所有业务代码之前就能在控制台看到调用栈直接定位是哪个库干的。这类排查思路比一行行读第三方源码快得多。7. 调试与排错几个我反复踩到的坑前面讲的是怎么把功能做对这一节讲的是做完了之后怎么验证。7.1 弹窗不出现时按这个顺序自查我把排查顺序整理成了固定流程每次都能在两分钟内定位先确认有没有用户交互。打开页面什么都不点直接按 F5不弹是正常的。确认脏标记是不是真的为真。在处理函数第一行打一个日志看有没有进来。很多情况下问题出在监听根本没生效而不是脏标记判断错了。确认有没有被阻止更多对话框的勾选干掉。换一个隐身窗口重新测这一步能排除掉一半的灵异现象。确认是不是被别的库覆盖了。用 6.3 节的方法打印监听来源。确认是不是没走到卸载流程。单页应用内部跳转不触发这是设计如此不是 bug。7.2 监听器泄漏与重复弹窗监听器泄漏的症状是用了半小时之后页面越来越卡或者关页面的时候要弹两次框。原因是每次进入编辑页都注册了一个新的匿名函数旧的没被摘掉。我在一个项目里见过有人把注册逻辑写在computed里每次输入变化都会重新执行一遍半小时攒了几百个监听。判断方式很简单在处理函数里打一个自增计数器正常应该每次离开只加一。修复方式就是前面强调过的具名函数 存引用 在明确的时机保存成功、组件真正卸载、路由确认离开解绑三件事缺一不可。7.3 自动化测试里的干扰如果你们有端到端测试beforeunload弹窗会把测试跑挂。Playwright 和 Puppeteer 这类工具默认会阻止或自动处理对话框但需要在测试代码里显式配置否则表现为测试卡住不动。我的做法是在测试环境通过环境变量关闭离开拦截让测试跑通同时单独写一条针对拦截逻辑的用例专门断言有脏数据时对话框出现、点取消后仍停留在原页。手动测试时也有个小技巧不要只测 F5要把三种离开方式都过一遍——刷新、点关闭标签页、点一个站外链接。我遇到过只在关闭标签页时失效的情况原因是那段代码用了pagehide而不是beforeunload刷新时表现正常关闭时静默失败。最后分享一个我在生产环境用过、效果不错的小做法给beforeunload弹窗加一次埋点上报统计被拦截次数和最终选择离开的比例。这两个数字很有意思——如果拦截次数远高于实际有脏数据的次数说明脏标记判定太宽用户在被无意义地打扰如果选择离开的比例极高说明用户已经对弹窗免疫了这时候该考虑的不是怎么把弹窗做得更醒目而是把自动保存做扎实。数据比直觉可靠这一条我在两个项目里都验证过。
返回列表