Vue 3 使用 History 哨兵和 popstate 拦截浏览器返回
本文介绍一种适用于 Vue 3、Vue Router 4 和整页跳转场景的浏览器返回拦截方案。示例已经脱敏,不包含真实项目名、域名、接口地址、埋点事件或业务数据。
一、问题背景
在单页应用中,我们通常使用 Vue Router 的组件内守卫拦截页面离开:
ts
onBeforeRouteLeave((to, from, next) => {
// 判断是否允许离开
});
这对 router.push()、router.replace() 以及由 Vue Router 管理的浏览器前进、后退通常有效。
但是,有些页面必须通过整页跳转进入:
ts
window.location.href = "/pay";
常见原因包括:
- 页面需要独立的 CSP 或第三方脚本策略;
- 进入页面时必须重新执行服务端渲染;
- 页面需要彻底清理上一页的运行时状态;
- 历史代码暂时无法改为 SPA 路由跳转。
这时,从上一页进入当前页已经发生了一次完整文档加载。用户点击浏览器返回按钮时,浏览器准备直接恢复前一个文档,当前 Vue 应用可能来不及执行 onBeforeRouteLeave,异步接口和自定义弹框自然也不会出现。
我们的目标是:
- 不改变页面原有的
location.href进入方式; - 用户点击浏览器返回时,先停留在当前页面;
- 调用异步资格接口;
- 符合条件时展示业务弹框,否则展示普通离开提示;
- 用户确认离开后,真正执行返回或指定路由跳转;
- 避免重复监听、重复请求和无限返回循环。
二、为什么仅监听 popstate 不够
很多人首先会写出下面的代码:
ts
window.addEventListener("popstate", () => {
showDialog.value = true;
});
问题是,popstate 是历史记录已经发生切换后才触发的通知事件,它不是一个可取消事件:
ts
window.addEventListener("popstate", (event) => {
event.preventDefault(); // 无法阻止浏览器历史切换
});
如果上一条历史记录属于另一个文档,浏览器可能直接离开当前页面。即使监听函数执行了,也无法可靠等待异步接口返回。
因此需要提前在当前页面上方放置一条"同 URL 历史记录"。本文把这条记录称为 History 哨兵。
三、History 哨兵的工作原理
假设用户从列表页通过整页跳转进入结算页,初始历史记录为:
text
[列表页] -> [结算页]
结算页加载完成后,通过 history.pushState() 添加一条 URL 完全相同的记录:
text
[列表页] -> [结算页原始记录] -> [结算页哨兵记录]
^ 当前记录
用户第一次点击浏览器返回时,只会从"哨兵记录"回到"原始记录":
text
[列表页] -> [结算页原始记录] -> [结算页哨兵记录]
^ 当前记录
由于前后 URL 相同,页面不会整页卸载。此时 popstate 可以在当前 Vue 应用中执行。
监听函数立即重新添加哨兵,把浏览器留在当前页面,然后开始异步判定:
text
浏览器返回
↓
命中同 URL 哨兵
↓
popstate 触发
↓
重新压入哨兵
↓
调用资格接口
↓
显示业务弹框或普通离开提示
关键点是:我们不是取消浏览器返回,而是让第一次返回只消耗一条人为增加的同页历史记录。
四、封装一个可复用的 History 哨兵
下面给出一个与业务无关的 TypeScript 实现。
ts
export interface HistorySentinelOptions {
/** 用户尝试浏览器返回时执行,可以包含异步逻辑 */
onBackAttempt: () => void | Promise<void>;
/** 稳定且页面内唯一的 history.state 标记 */
markerKey?: string;
/** 拦截流程自身发生异常时调用 */
onError?: (error: unknown) => void;
}
export interface HistorySentinelController {
activate: () => void;
stopListening: () => void;
release: (action: () => void) => void;
destroy: () => void;
}
export function createHistorySentinel(options: HistorySentinelOptions): HistorySentinelController {
const markerKey = options.markerKey || "__page_leave_sentinel__";
const isClient = typeof window !== "undefined";
let listening = false;
let active = false;
let releaseAction: (() => void) | null = null;
const hasSentinel = () => isClient && Boolean(window.history.state?.[markerKey]);
const pushSentinel = () => {
if (!isClient) return;
window.history.pushState(
{
...(window.history.state || {}),
[markerKey]: true,
},
"",
window.location.href,
);
};
const stopListening = () => {
if (!isClient || !listening) return;
window.removeEventListener("popstate", handlePopState);
listening = false;
};
function handlePopState() {
// release() 主动退掉哨兵时,不再触发业务拦截。
if (releaseAction) {
const action = releaseAction;
releaseAction = null;
active = false;
stopListening();
// 等当前 popstate 及路由内部监听处理完毕后再真正导航。
window.setTimeout(action, 0);
return;
}
if (!active) return;
// 用户正常点击浏览器返回:立即恢复哨兵并执行业务判定。
pushSentinel();
Promise.resolve(options.onBackAttempt()).catch((error) => {
options.onError?.(error);
});
}
const activate = () => {
if (!isClient || active) return;
active = true;
if (!listening) {
window.addEventListener("popstate", handlePopState);
listening = true;
}
// onMounted 和 onActivated 可能连续执行,因此必须避免重复压栈。
if (!hasSentinel()) pushSentinel();
};
const release = (action: () => void) => {
if (!isClient || !hasSentinel()) {
active = false;
stopListening();
action();
return;
}
// 先退掉人为增加的哨兵;handlePopState 再执行真正导航。
releaseAction = action;
window.history.back();
};
const destroy = () => {
active = false;
releaseAction = null;
stopListening();
};
return {
activate,
stopListening,
release,
destroy,
};
}
为什么要复制 history.state
Vue Router 会在 history.state 中保存自己的导航信息。如果直接写成:
ts
history.pushState({ guarded: true }, "", location.href);
可能覆盖路由已有状态。更稳妥的方式是保留原数据,只增加自己的标记:
ts
history.pushState(
{
...(history.state || {}),
guarded: true,
},
"",
location.href,
);
标记名需要稳定且足够独特,不要使用 position、back、current、forward、replaced、scroll 等可能与路由库冲突的字段。
五、在 Vue 3 页面中接入
下面以"离开前查询服务端资格,并显示不同弹框"为例。
1. 统一异步判定入口
浏览器返回和 Vue Router 路由跳转应该复用同一个函数,避免出现两套行为。
ts
import { ref } from "vue";
const showRetentionDialog = ref(false);
const showDefaultLeaveDialog = ref(false);
let evaluating = false;
async function requestLeaveEvaluation() {
if (evaluating || showRetentionDialog.value || showDefaultLeaveDialog.value) {
return;
}
evaluating = true;
try {
const response = await checkLeaveEligibility({
pageType: "CURRENT_PAGE",
triggerType: "BACK",
sessionId: getStablePageSessionId(),
});
if (response.success && response.data?.shouldShowDialog === true) {
showRetentionDialog.value = true;
} else {
showDefaultLeaveDialog.value = true;
}
} catch (error) {
// 网络错误不能把用户锁死在页面中,应回退普通提示。
showDefaultLeaveDialog.value = true;
} finally {
evaluating = false;
}
}
这里有三个重要约束:
- 使用
evaluating防止连续点击返回造成并发请求; - 弹框已经展示时不再重复触发;
- 接口异常必须有可继续操作的降级路径。
2. 创建哨兵控制器
ts
const historySentinel = createHistorySentinel({
markerKey: "__pay_leave_sentinel__",
onBackAttempt: requestLeaveEvaluation,
onError: () => {
showDefaultLeaveDialog.value = true;
},
});
3. 接入组件生命周期
如果页面被 <KeepAlive> 缓存,需要同时处理挂载、激活、停用和销毁。
ts
import { onActivated, onDeactivated, onMounted, onUnmounted } from "vue";
onMounted(() => {
historySentinel.activate();
});
onActivated(() => {
historySentinel.activate();
});
onDeactivated(() => {
historySentinel.stopListening();
});
onUnmounted(() => {
historySentinel.destroy();
});
activate() 必须幂等,因为使用 <KeepAlive> 时,页面首次展示可能同时经历 onMounted 和 onActivated。
4. 保留 Vue Router 路由守卫
History 哨兵解决的是浏览器原生返回。页面内的菜单、链接和 router.push() 仍应由路由守卫负责。
ts
import { onBeforeRouteLeave } from "vue-router";
const allowLeave = ref(false);
const routeWhitelist = new Set(["successPage", "failurePage", "addressEditor", "loginPage"]);
onBeforeRouteLeave((to, from, next) => {
if (allowLeave.value || routeWhitelist.has(String(to.name))) {
historySentinel.stopListening();
next(true);
return;
}
next(false);
void requestLeaveEvaluation();
});
至此,两种离开入口会进入同一个业务函数:
text
浏览器返回 ──> popstate ─────┐
├──> requestLeaveEvaluation()
站内路由跳转 -> 路由守卫 ────┘
六、确认离开时必须先释放哨兵
这是整套方案最容易写错的地方。
如果用户确认离开后直接调用:
ts
window.history.back();
这个返回仍会被哨兵监听器捕获,于是再次展示弹框,形成循环。
正确做法是调用 release():先把人为添加的哨兵记录退掉,随后再执行真正导航。
跳转到指定页面
ts
function confirmLeaveToList() {
allowLeave.value = true;
showRetentionDialog.value = false;
showDefaultLeaveDialog.value = false;
historySentinel.release(() => {
router.replace({ name: "listPage" });
});
}
继续返回原始上一页
ts
function confirmNativeBack() {
allowLeave.value = true;
showRetentionDialog.value = false;
showDefaultLeaveDialog.value = false;
historySentinel.release(() => {
window.history.back();
});
}
第二段代码中出现两次 history.back() 是正常的:
release()内部第一次返回用于移除同页哨兵;- 回调中的第二次返回才会前往真正的上一页。
七、完整交互时序
服务端判定通过
text
进入页面
↓
pushState 添加同 URL 哨兵
↓
用户点击浏览器返回
↓
popstate:退到原始页面记录
↓
立即 pushState 恢复哨兵
↓
调用资格接口
↓
返回 shouldShowDialog=true
↓
显示业务挽留弹框
↓
用户选择继续停留
↓
关闭弹框,页面与哨兵保持不变
服务端判定不通过或接口失败
text
浏览器返回
↓
恢复哨兵
↓
接口返回空数据、false 或异常
↓
显示普通离开提示
用户确认离开
text
点击"离开"
↓
设置 allowLeave=true
↓
release() 调用第一次 history.back()
↓
popstate 识别为主动释放
↓
移除监听器
↓
执行 router.replace() 或第二次 history.back()
八、常见错误与处理方式
1. 在每次渲染或 watch 中执行 pushState
错误示例:
ts
watchEffect(() => {
history.pushState({}, "", location.href);
});
这会不断增加历史记录,导致用户需要点击很多次返回才能离开。
正确做法是在页面激活时执行一次,并通过 history.state 标记判断是否已经存在哨兵。
2. 只监听 beforeunload
beforeunload 适合刷新、关闭标签页或离开站点时显示浏览器原生确认框,但它不允许执行可靠的异步接口,也不能展示 Vue 自定义弹框。
ts
window.addEventListener("beforeunload", (event) => {
event.preventDefault();
event.returnValue = "";
});
History 哨兵和 beforeunload 解决的是不同问题:
| 场景 | History 哨兵 | beforeunload |
|---|---|---|
| 浏览器返回 | 支持自定义异步弹框 | 通常不适合 |
| SPA 路由跳转 | 配合路由守卫 | 不触发 |
| 刷新页面 | 不保证 | 浏览器原生提示 |
| 关闭标签页 | 不支持自定义异步弹框 | 浏览器原生提示 |
| 输入新网址 | 不保证 | 浏览器原生提示 |
3. 接口失败后什么都不做
如果失败分支不显示提示,也不允许导航,用户会感觉浏览器返回按钮失效。
推荐策略:
ts
try {
await checkLeaveEligibility();
} catch {
showDefaultLeaveDialog.value = true;
}
4. 没有请求锁
用户可能快速点击多次返回,或者同时触发路由离开。没有锁会产生多个接口请求和多个弹框状态更新。
至少需要:
ts
if (evaluating || dialogVisible.value) return;
服务端也应对同一页面会话和触发类型做幂等处理。
5. 组件销毁后仍更新状态
异步请求可能在页面销毁后返回。除了移除 popstate 监听,还应让业务请求层忽略失效响应。
ts
let generation = 0;
async function evaluate() {
const token = generation;
const response = await request();
if (token !== generation) return;
// 更新页面状态
}
onUnmounted(() => {
generation += 1;
});
6. 忘记处理 SSR
服务端渲染阶段没有 window 和 history。所有浏览器 API 必须延迟到客户端生命周期,或者先做环境判断:
ts
if (typeof window === "undefined") return;
7. 误以为可以拦截所有离开方式
History 哨兵主要解决浏览器后退。以下操作仍需要单独设计:
- 刷新页面;
- 关闭标签页或浏览器;
- 地址栏输入新网址;
- JavaScript 直接执行
location.replace(); - 浏览器或系统强制结束页面进程。
不要在这些场景中依赖异步接口完成后才允许离开,因为浏览器不会保证等待。
九、与 Vue Router 共存时的注意事项
保留原路由守卫
不要因为增加了 popstate 就删除 onBeforeRouteLeave。两者职责不同:
popstate + History 哨兵:处理整页进入后浏览器返回;onBeforeRouteLeave:处理 Vue Router 管理的站内导航。
使用同一个离开状态
用户确认离开后,需要设置统一的放行状态,否则 release() 回调中的 router.replace() 可能再次被路由守卫阻止。
ts
allowLeave.value = true;
不要修改路由库内部字段
添加哨兵时只追加自定义字段,不要手动计算或修改 Vue Router 的 position 等内部值。路由库升级后,这些内部结构可能变化。
白名单页面
支付成功、失败、登录和地址编辑等内部流程通常应该直接放行,避免业务完成后仍出现挽留弹框。白名单应使用稳定的路由 name,不要依赖易变化的展示 URL。
十、测试清单
上线前至少验证以下场景。
浏览器返回
- 从上一页整页进入目标页,第一次点击返回仍停留在目标页;
- 资格通过时显示业务弹框;
- 资格不通过时显示普通离开提示;
- 接口返回空数据时可以继续操作;
- 网络超时或 5xx 时不会把用户锁死;
- 快速连续点击返回只发起一次资格请求。
弹框操作
- 点击"继续停留"只关闭弹框;
- 再次点击返回不会产生无限历史记录;
- 点击"离开"后只执行一次导航;
- 确认离开不会再次触发同一个弹框;
- 遮罩关闭与明确离开的业务含义保持区分。
路由与生命周期
router.push()离开时仍经过路由守卫;- 白名单页面可以直接进入;
- KeepAlive 页面反复激活不会重复添加监听;
- 页面卸载后
popstate监听已经移除; - 从子页面返回目标页后可以重新建立哨兵;
- SSR 构建不会出现
window is not defined。
浏览器兼容
- Chrome、Edge、Firefox 浏览器返回;
- Safari 返回及页面缓存恢复;
- 触控板返回手势;
- 浏览器长按返回按钮选择历史记录;
- 移动端 WebView 的物理返回键。
其中"长按返回选择任意历史记录"和部分 WebView 行为可能绕过单步哨兵,需要根据目标环境单独验证,不能只依赖桌面 Chrome 的结果。
十一、方案边界与取舍
History 哨兵本质上会增加一条浏览器历史记录。它适合以下场景:
- 进入页面必须整页加载;
- 只需要拦截浏览器单步返回;
- 离开前必须执行一次异步业务判定;
- 产品允许当前页面临时增加一条同 URL 历史记录。
如果项目可以安全地全部改为 Vue Router SPA 导航,优先使用路由守卫会更简单。如果只需要提醒未保存内容,并不需要自定义异步业务弹框,则优先考虑 beforeunload。
对于支付、表单、编辑器等高价值页面,推荐采用分层方案:
text
浏览器返回 -> History 哨兵 + popstate
Vue Router 跳转 -> onBeforeRouteLeave
刷新或关闭标签页 -> beforeunload(仅在确有必要时)
服务端判定 -> 请求锁 + 页面会话幂等
异常处理 -> 普通离开提示或明确的降级路径
十二、总结
当页面通过 location.href 整页进入时,仅依赖 Vue Router 路由守卫无法可靠拦截浏览器返回。History 哨兵通过预先插入一条同 URL 记录,让第一次返回仍发生在当前文档内,从而获得执行 popstate、异步接口和自定义弹框的机会。
实现时需要牢牢记住四点:
popstate不能取消,必须提前创建同页哨兵;- 用户确认离开时必须先释放哨兵,否则会循环拦截;
- 浏览器返回与路由守卫必须复用同一套业务判断;
- 做好请求锁、生命周期清理、SSR 判断和失败降级。
这套方案不是对浏览器导航机制的替代,而是在不能改变整页跳转方式时,对"离开前异步确认"需求的一种工程化补充。