ARTICLE DETAIL

资讯详情

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

前端跳转拦截与确认弹框实战:beforeunload、路由守卫与Promise封装

前端跳转拦截与确认弹框实战:beforeunload、路由守卫与Promise封装 1. 跳转弹框到底拦截的是什么三类跳转与两种拦截层级先说我最近真实遇到的一个需求。后台管理系统的订单编辑页运营同事填了十几分钟的表单临时去开了个会回来习惯性点了一下左侧菜单的订单列表页面瞬间切走所有改动全没了。那天下午运营群里炸了锅第二天这个需求就排到我手里所有包含未保存数据的页面跳转之前必须弹框确认。这个需求听上去一句话就能说清真正动手做才发现跳转这两个字背后藏着好几种完全不同的情况。我把它们整理成三类分不清楚这三类后面写代码就是在碰运气。第一类是浏览器级跳转。用户手动刷新页面、关闭标签页、在地址栏输入新地址回车、或者点击一个指向外部链接的普通a标签导致整页加载。这类跳转一旦发生当前页面的 JavaScript 执行环境会被直接销毁不管你的应用是 Vue 还是 React任何代码都无法在跳转完成后继续执行。你唯一能做的就是在跳转发生前的那一刻打断它。第二类是单页应用内部的路由切换。Vue Router、React Router 管理的页面互跳比如从编辑页跳到列表页、从详情页跳到首页。这类跳转实际上并没有卸载当前文档只是改了 URL 和视图渲染JavaScript 运行上下文一直活着。所以路由层的代码完全有机会拦下来先弹框等用户确认了再放行。第三类是 Hash 变化和浏览器前进后退。hash 模式路由下window.location.hash的改变会触发hashchange事件history 模式路由下浏览器工具栏的前进后退按钮会触发popstate事件。这两条路径特别容易被人忽略但恰恰是手机端用户误触发的重灾区——Android 手机的系统返回手势一划页面就退了表单一个字没存。对应地拦截手段也分两个层级。第一层是浏览器原生提供的beforeunload事件它专门对付第一类跳转第二层是路由库自带的导航守卫或阻塞机制用来处理第二类和第三类中可控的部分。这两层不能互相替代一个完整的跳转前弹框方案必须两层同时上否则总会漏掉某个入口。我承认自己一开始在这个问题上栽过跟头。当时想的是路由守卫处理内部跳转就够了浏览器刷新让用户自己负责结果上线第二天就有人在编辑页按了 F5白填半天数据骂骂咧咧来找我。反过来也一样如果你只挂了beforeunloadSPA 内部从编辑页切到列表页页面根本没卸载这个事件根本不会触发。所以动手之前先画清楚你有哪些跳转入口。我的习惯是做一张清单把刷新关闭标签页内部路由跳转浏览器返回点击外链地址栏输入全部列出来然后逐个确认这个入口由哪一层代码负责拦截拦不住时的兜底方案是什么这套清单做完实现方案基本上就是水到渠成的事了。还有一个产品层面的问题值得提前想清楚弹框不是越频繁越好。如果用户只是从列表页跳到详情页每跳一次都弹框用户会形成看到弹框就无脑点确定的条件反射真要拦截的时候反而拦不住。弹框只应该出现在当前状态可能被破坏的页面——最典型的就是表单有未保存修改、页面有正在进行的上传任务、或者用户正在播放的音频/视频需要停止。判断是否有未保存修改这件事建议用脏标记dirty flag机制而不是每次跳转都无脑弹。2. beforeunload 原生拦截的能力边界与浏览器政策限制beforeunload是一个存在了很多年的浏览器 API几乎所有 JavaScript 开发者都听过但真正用对它的人不多。原因很简单这些年浏览器厂商出于反骚扰的考虑把这个 API 的能力一砍再砍网上大量旧教程写的用法现在根本不起作用。标准的挂载方式是这样的function beforeUnloadHandler(event) { event.preventDefault(); event.returnValue ; } // 有未保存数据时挂载 window.addEventListener(beforeunload, beforeUnloadHandler); // 保存完成或确认离开后移除 window.removeEventListener(beforeunload, beforeUnloadHandler);早年间的做法是给returnValue塞一段自定义文案比如你还有未保存的内容确定要离开吗浏览器会把这行字显示在原生确认框里。但从 Chrome 51 开始桌面端不再显示自定义文案统一使用浏览器内置的默认提示Chrome 60 之后进一步调整了提示样式。现在你写什么字符串都没用浏览器只认你有没有阻止默认行为这个动作显示的永远是它自己的话术。还有一个更隐蔽的限制从 Chrome 68 开始如果页面从未发生用户交互比如用户刚打开页面就直接刷新beforeunload弹框不会出现。这是浏览器为了防广告页和恶意页面的策略——你不允许网站在用户没有操作的情况下用弹框骚扰用户。换句话说这个事件本质上是用户主动操作离开时给页面一个挽留的机会而不是页面可以随时按住的闸门。我实际测试过的主流浏览器行为是这样的浏览器自定义文案支持用户交互要求触发的典型场景Chrome 桌面端不支持固定提示需要刷新、关闭、地址栏跳转、外链跳转Edge 桌面端不支持固定提示需要同 ChromeFirefox不支持固定提示需要刷新、关闭、外链跳转Safari 桌面端支持但建议按不支持处理不同版本有差异刷新、关闭Chrome/Safari 移动端不支持固定提示需要关闭标签页、地址栏跳转这里要特别强调一个移动端的坑iOS 上 Safari 的beforeunload支持一直很不稳定。很长一段时间里iOS Safari 根本不触发这个事件用户右滑关闭或切换标签页时页面代码根本得不到执行机会。最近几个版本虽然有所改善但依然不可控。所以移动端的核心防线绝对不能押在beforeunload上路由守卫和可控弹框才是主力。更关键的是就算beforeunload正常触发你也没办法弹出自己的 React 组件或 Vue 弹窗——它只能显示浏览器原生的确认框样式完全不可控也没有任何业务逻辑的承载空间。而且beforeunload处理器内部的所有操作都必须在同步代码中完成你不能在里面发异步请求、不能 await 一个 Promise、不能等待用户在一个自定义弹框上点按钮。所以我的定位是beforeunload只做最后一道兜底不做主要交互。它能覆盖的是用户直接关标签页、刷新页面这种路由层完全无法感知的场景代价是体验粗糙、不可定制。真正精细的跳转前弹框必须放在路由层去实现。一个实操细节beforeunload监听器的挂载和移除应该跟着脏标记走。表单每次输入变化时判断数据是否和初始值一致不一致才注册监听器一致就移除。千万不要一进页面就把监听器挂上然后永远不摘否则用户在页面上什么都没干点刷新也被浏览器拦住这种体验很愚蠢。function updateDirtyState(isDirty) { if (isDirty) { window.addEventListener(beforeunload, beforeUnloadHandler); } else { window.removeEventListener(beforeunload, beforeUnloadHandler); } }3. 路由层面的真正拦截Vue Router 守卫与 React Router useBlocker如果说beforeunload是粗线条的兜底那路由守卫就是精细控制的真正主角。SPA 内部的所有页面切换都可以在路由层被拦截、挂起、等待用户决策后再放行。先看 Vue Router。最常用的方式是在全局前置守卫beforeEach里做判断发现目标页面需要离开当前表单页且表单是脏的就挂起导航。注意不是调用next(false)就完事了——你需要把这次导航的to和next保存下来弹出自定义弹框等用户点了确定再继续执行next()点了取消就丢弃这次导航。// store 或组件外部定义一个模块级变量 let pendingNavigation null; router.beforeEach((to, from, next) { const formStore useFormStore(); // 假设你的表单状态在 Pinia 里 const isLeavingFormPage from.meta.requiresConfirm; const isDirty formStore.isDirty; if (!isLeavingFormPage || !isDirty) { next(); return; } // 挂起导航记录待处理对象 pendingNavigation { to, next }; // 触发自定义弹框显示 confirmDialogStore.open({ title: 离开当前页面, content: 你有未保存的修改确定要离开吗, }); }); // 弹框确认离开 function onConfirmLeave() { if (pendingNavigation) { const { next } pendingNavigation; pendingNavigation null; confirmDialogStore.close(); next(); // 放行 } } // 弹框取消离开 function onCancelLeave() { pendingNavigation null; confirmDialogStore.close(); }这套模式的核心思路是挂起导航 暂存 next 引用 弹框确认后再决定调用还是不调用。有一个细节必须注意next只能调用一次而且不能既调用又丢弃。所以在onConfirmLeave和onCancelLeave里调用next()之后必须立即把pendingNavigation置空防止用户连点两次按钮导致next被重复调用。Vue Router 也支持组件内守卫beforeRouteLeave它的好处是能直接访问当前组件的this或 setup 里的状态不需要把表单状态提升到全局 store。但组件内守卫的缺点是逻辑比较分散如果多个页面都需要拦截你就要在每个组件里都写一遍。我的习惯是全局beforeEach处理所有需要确认的页面组件内beforeRouteLeave只在特殊业务逻辑比如某个页面离开时需要清理定时器时用。React Router 这边的情况略有不同。React Router v6.4 之前官方没有提供路由阻塞能力社区要么自己 hacksnavigate函数要么用第三方库。v6.4 之后官方正式提供了useBlocker这个 Hook前提是你必须使用数据路由器createBrowserRouter普通的BrowserRouter组件模式用不了。import { useBlocker } from react-router-dom; function EditOrderPage() { const [isDirty, setIsDirty] useState(false); // 只有跨路由且表单脏时才阻塞 const blocker useBlocker( ({ currentLocation, nextLocation }) isDirty currentLocation.pathname ! nextLocation.pathname ); useEffect(() { if (blocker.state blocked) { setShowConfirmDialog(true); } }, [blocker]); const handleConfirm () { setShowConfirmDialog(false); blocker.proceed(); // 放行 }; const handleCancel () { setShowConfirmDialog(false); blocker.reset(); // 取消这次导航 }; return ( OrderForm onDirtyChange{setIsDirty} / {showConfirmDialog ( ConfirmDialog onConfirm{handleConfirm} onCancel{handleCancel} / )} / ); }useBlocker的返回值有三种状态unblocked未阻塞、blocked已阻塞待决策、proceeding正在放行。你需要监听blocked状态来触发自定义弹框用户确认后调用blocker.proceed()取消则调用blocker.reset()。这里有两个容易踩的坑。第一useBlocker的函数参数每次渲染都会重新创建如果你不在内部用依赖状态做判断可能会导致在无关的路由切换时也触发阻塞。上面的示例代码中我在函数里判断了路径是否变化这样从编辑页跳到同一个路径的不同参数就不会误报。第二useBlocker所在的组件必须是路由组件否则 Hook 获取不到当前路由信息。如果把 Vue Router 和 React Router 的拦截方式放到一起对比逻辑是很相似的对比维度Vue RouterReact Router拦截 APIbeforeEach/beforeRouteLeaveuseBlocker阻塞方式挂起next()暂存引用状态切换为blocked放行手动调用next()blocker.proceed()取消不调用next()丢弃导航blocker.reset()前提条件无特殊要求必须使用数据路由器4. 从零封装一个可复用的跳转确认弹框状态设计与 Promise 模式路由守卫只是拦截了跳转真正跟用户直接对话的是那个确认弹框本身。很多项目直接复用组件库里的Modal.confirm图省事但做深了你就会发现跳转确认弹框和普通消息弹框有一个本质区别弹框关闭时必须把用户的选择以确定性的方式告诉路由层。推荐用 Promise 来封装整个流程。路由层那边只要等待一个 Promiseresolve 就放行reject 就取消而弹框内部负责展示 UI、监听用户点击、最终 resolve 或 reject。这样路由层和弹框层完全解耦你甚至可以无感替换弹框实现。下面是一个 Vue 3 Composition API 的思路核心是维护一个待决 Promise// useConfirmLeave.js import { ref } from vue; const isVisible ref(false); const title ref(); const content ref(); let resolver null; function openConfirmLeave(options {}) { title.value options.title || 离开当前页面; content.value options.content || 你有未保存的修改确定要离开吗; isVisible.value true; return new Promise((resolve) { resolver resolve; }); } function handleConfirm() { isVisible.value false; resolver?.(true); resolver null; } function handleCancel() { isVisible.value false; resolver?.(false); resolver null; } export function useConfirmLeave() { return { isVisible, title, content, openConfirmLeave, handleConfirm, handleCancel, }; }然后在路由守卫里这样配合import { useConfirmLeave } from /composables/useConfirmLeave; router.beforeEach(async (to, from, next) { if (!from.meta.requiresConfirm || !formStore.isDirty) { next(); return; } const confirmed await openConfirmLeave(); if (confirmed) { next(); } else { // 不调用 next导航自动取消 } });Promise 方案的好处是异步控制流非常清晰路由守卫不再需要手动维护pendingNavigation对象代码可读性提升了一个档次。坏处是你要小心事件循环的时序如果用户在弹框出现之前就触发了另一次路由跳转可能导致 Promise 永远 pending。我通常会在openConfirmLeave里加一个保护——如果上一次弹框还没关闭就先自动 cancel 上一次。弹框 UI 本身也有一些交互细节做得不好会让人觉得很业余按钮焦点策略。弹框打开时默认焦点应该放在取消按钮上而不是确定上。想一想原因用户可能是误触了导航此时他大概率是在慌乱中想赶快退出如果默认焦点在确定他随手按一下回车表单就白填了。默认焦点在取消回车等于反悔是最安全的选择。键盘事件处理。Esc键应该等同于点击取消这也是安全方向。如果你不想维护全局键盘监听可以在弹框组件挂载时监听keydown卸载时移除监听。自动聚焦问题。自定义弹框不像浏览器原生 alert 那样自动获得焦点所以需要手动把焦点移到弹框容器上同时建议设置aria-modaltrue让屏幕阅读器知道这是个模态对话框。无障碍这件事很多人不当回事但我遇到过真实用户是键盘导航使用者他反馈说弹框出现后 Tab 焦点还在背后的表单里按 Tab 会莫名奇妙地修改表单值这个问题定位了很久才发现。重复拦截问题。用户第一次点取消留在当前页第二次再点导航又弹框这是正常的。但要注意不要出现弹框还没关又弹一个的情况所以弹框的显示状态必须全局唯一。我在生产环境踩过一次一个页面既注册了全局beforeEach拦截组件内部又写了个beforeRouteLeave同时拦结果弹了两个框用户关掉一个还剩一个彻底蒙了。解决方案是在项目里约定全局守卫只负责是否需要拦截的判断弹框只渲染一次组件内不再重复拦截。5. 多标签页、浏览器返回与草稿恢复边界情况的实测复盘主流程跑通之后真正的考验在边界情况。我把自己在真实项目里踩过的坑一条条列出来每一条都有对应的解决方案。浏览器返回按钮。history 模式下用户点击浏览器返回按钮触发的是popstate事件这个事件本身无法被取消。也就是说无论你愿不愿意浏览器历史栈已经回退了。Vue Router 的方式是在路由守卫里判断当前页是编辑页且表单脏beforeEach重新把路由指回编辑页React Router 的useBlocker对前进后退同样生效blocker.reset()之后路由会回到原来的状态。但要注意这个过程会产生一条额外的历史记录用户可能发现按了返回又弹回来再按一次返回才能真正离开。这是浏览器历史栈不可撤销的物理限制没有完美的解法只能在体验上做一个权衡——我个人觉得多一次拦截比数据丢失好得多。hash 模式下的 hashchange。如果你的应用用的还是老式的 hash 路由window.location.hash的修改会触发hashchange事件而这个事件同样无法被阻止。一个绕过方案是在修改 hash 之前先检查脏标记如果脏就把 hash 改回去并弹出确认框。但如果你用的路由库比如 Vue Router已经封装了 hash 模式通常它的内部跳转还是会走路由守卫不需要你自己处理hashchange。多标签页的天然限制。用户在两个标签页同时打开同一个编辑页在标签 A 修改数据然后切到标签 B 点击导航离开。标签 B 里的表单状态是旧数据脏标记可能是 false所以不会弹框但用户脑子里的我正在编辑的内容是标签 A 的最新状态。这个场景目前无解beforeunload和路由守卫都作用在各自的标签页上下文里。我能给的实操建议是如果项目对数据一致性要求高可以考虑用BroadcastChannel或localStorage的storage事件在多个标签页之间同步脏状态。但说实话为这个场景做的投入产出比很低我一般建议产品经理接受这个限制。草稿恢复机制。比起费尽心思想着怎么拦截所有跳转更符合用户心流的做法是弹框确认只是第一道防线真正保险的是把数据自动存到本地。我现在的做法是表单数据在输入过程中节流写入localStoragekey 按页面路由 记录 ID来区分页面加载时如果发现本地有未提交的草稿先恢复再提示用户检测到未提交的草稿是否恢复。有了这层兜底就算弹框没拦住、浏览器崩溃、甚至用户手动杀进程数据都不会丢。弹框防的是误操作草稿恢复防的是任何意外两者是互补关系。异步保存的竞态。一个容易被忽略的时序问题用户点击确认离开弹框关闭此时页面可能正在执行表单数据的异步提交。如果异步提交还没返回页面已经切换到下一个路由组件被销毁异步回调里的setState就会在已卸载组件上执行轻则抛警告重则内存泄漏。我的解决办法是确认离开之前先 await 一次表单保存——如果保存失败弹框显示保存失败是否仍然离开并提供重试按钮。这样把离开动作和保存动作做成一个原子操作彻底杜绝竞态。async function handleConfirm() { confirmLoading.value true; try { await formStore.saveDraft(); // 保存成功才真正放行 isVisible.value false; resolver?.(true); resolver null; } catch (e) { saveFailed.value true; } finally { confirmLoading.value false; } }弹框按钮的连点防抖。用户快速双击确认按钮按钮的 click 事件会触发两次导致resolver(true)被调用两次。虽然第二次调用时resolver已经被置空但最好在handleConfirm里加防抖或者用一个isHandling标记防止在异步保存逻辑里出现重复请求。只有 hash 变化的监听。有些页面里用户点击一个按钮只是为了改变查询参数比如列表页的分页并不是真的想离开当前页。如果你的useBlocker回调只比较路径而忽略了查询参数的变化就会导致换个分页也弹框体验非常割裂。我建议拦截条件严格一点只比较pathname忽略 search 和 hash 的变化除非你的业务明确要求在修改查询参数时也做确认。6. 兼容性矩阵、测试清单与我的落地经验方案写完了不实测等于没写。我的测试清单和踩坑记录一次性分享出来。先看兼容性。这里说的是我在真实项目里验证过的行为测试时间大概是近两年的主流版本但浏览器迭代很快建议你上线前用自己的测试机再跑一遍测试场景ChromeEdgeFirefoxSafari (iOS)备注刷新页面触发 beforeunload正常正常正常部分版本不支持已确认移动端是重灾区关闭标签页触发 beforeunload正常正常正常不可靠iOS 上经常不触发SPA 内部路由跳转被 useBlocker 拦截正常正常正常正常可靠浏览器返回被 Vue 守卫重定向正常正常正常正常会产生额外历史记录自定义弹框样式所有环境统一所有环境统一所有环境统一所有环境统一这是用自研弹框的最大优势测试用例清单我建议按这样的思路组织表单从未修改点击导航不弹框直接跳转。表单有修改点击导航弹框点取消留在原页表单数据完整。表单有修改点击导航弹框点确定跳转成功。表单有修改点浏览器返回弹框或重定向回原页数据不丢。表单有修改刷新页面浏览器原生确认框出现取消刷新后数据仍在。表单有修改关闭标签页原生确认框出现。弹框打开状态下按 Esc弹框关闭停留在原页。弹框打开状态下回车默认焦点在取消停留原页。快速双击确认按钮只产生一次保存请求。确认离开且保存失败弹框升级为报错状态提供重试。这个清单不是一次性写完就完事了每次改了弹框组件或者路由配置都应该把主流程过一遍。我个人的习惯是把它写成一个 Playwright 的自动化测试脚本跑在 CI 里几秒钟出结果比人肉点十遍靠谱得多。再分享几个我在实际项目中得出的产品层面结论。弹框文案极其重要。一开始我写的是你确定要离开吗用户根本不知道自己为什么要被问这一句。后来改成你有 3 项修改尚未保存离开页面后这些修改将丢失用户一眼就明白利弊误操作率立刻下降。要点是说清楚离开会失去什么而不是干巴巴地问是否确定。不要对同一用户在同一页面反复弹框。用户第一次选择取消留下来了可能是在权衡要不要保存第二次又选择取消第三次你再弹他可能已经不耐烦了。有的团队会给弹框加一个记住我的选择复选框但我实测下来这个复选框的价值不大用户不知道这个记住会持续多久反而会增加认知负担。更合理的做法是把取消设计成常态同时提供明显的保存并离开按钮把选择权交还给用户。弹框不要做成浏览器默认样式的加深版。展示层次上弹框应该清晰地区分两个动作一个是安全退出放弃修改一个是保存后离开。如果两个按钮的视觉权重一样用户分不清哪个更安全。我的设计习惯是主要推荐按钮是保存并离开次要按钮是仍然离开两个按钮颜色和边框有明显区分。这样即使用户懒得思考默认也会走数据不丢失的路。配置化比硬编码好。几个月后产品经理大概率会要求订单页弹框、客户页不弹草稿箱不弹但正式提交要弹。所以弹框的触发条件千万别硬编码在组件里我用的是路由 meta 配置 store 里的脏标记判断两两组合改需求只动配置不动代码// 路由配置 { path: /order/edit/:id, component: OrderEdit, meta: { requiresConfirm: true, confirmMessage: 你有未保存的订单修改离开后将丢失。, }, }还有一个容易被忽略的细节用户从编辑页进入了一个新的编辑页如果两个页面都是requiresConfirm弹框逻辑要能处理从脏页面到另一个也会变成脏的页面的情况。我的策略是进入新页面时先把上个页面的脏状态清零再渲染避免连锁弹框。最后说一个我在实测中得到的意外发现。一开始我把弹框做得非常完善又是标题又是正文还有一个警示图标按钮文字也排得很讲究。结果 A/B 测试下来完善版弹框的取消率反而比简洁版低。分析了一下原因用户看到的是一个复杂弹框时倾向于不仔细阅读直接点最显眼的按钮快速离开而一个只有一句话和两个按钮的轻量弹框用户反而会花 0.5 秒读一下内容再决定。弹框的设计目标不是让用户多停留而是让用户在下意识操作之前多一个思考停顿。这个停顿不需要太长——人眼读一句话 300 到 500 毫秒就够了剩下的交给按钮布局去引导。如果你也在做类似的需求我最大的建议是别一上来就动手写弹框组件先把跳转入口梳理干净再用 Promise 把路由守卫和弹框解耦最后用自动化测试把关键路径锁住。这套思路跑顺之后你会发现跳转前弹框这个看似小到不值一提的需求实际上是把前端路由、浏览器事件、状态管理、异步时序串起来的综合题做完之后你对整个应用的导航架构都会有更深的理解。
返回列表