Vue 3 使用 History 哨兵和 popstate 拦截浏览器返回

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,异步接口和自定义弹框自然也不会出现。

我们的目标是:

  1. 不改变页面原有的 location.href 进入方式;
  2. 用户点击浏览器返回时,先停留在当前页面;
  3. 调用异步资格接口;
  4. 符合条件时展示业务弹框,否则展示普通离开提示;
  5. 用户确认离开后,真正执行返回或指定路由跳转;
  6. 避免重复监听、重复请求和无限返回循环。

二、为什么仅监听 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,
);

标记名需要稳定且足够独特,不要使用 positionbackcurrentforwardreplacedscroll 等可能与路由库冲突的字段。

五、在 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> 时,页面首次展示可能同时经历 onMountedonActivated

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() 是正常的:

  1. release() 内部第一次返回用于移除同页哨兵;
  2. 回调中的第二次返回才会前往真正的上一页。

七、完整交互时序

服务端判定通过

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

服务端渲染阶段没有 windowhistory。所有浏览器 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、异步接口和自定义弹框的机会。

实现时需要牢牢记住四点:

  1. popstate 不能取消,必须提前创建同页哨兵;
  2. 用户确认离开时必须先释放哨兵,否则会循环拦截;
  3. 浏览器返回与路由守卫必须复用同一套业务判断;
  4. 做好请求锁、生命周期清理、SSR 判断和失败降级。

这套方案不是对浏览器导航机制的替代,而是在不能改变整页跳转方式时,对"离开前异步确认"需求的一种工程化补充。

相关推荐
名字还没想好☜1 小时前
Prometheus 告警实战:写 alerting rules、用 Alertmanager 做路由分组与抑制
运维·前端·javascript·docker·kubernetes·prometheus
郭邯1 小时前
从零撸了一个 HTML 实体编解码工具,顺便聊聊我和 AI 结对编程的日常
前端
樊小肆1 小时前
DeepSeeker-Code源码导读12-Hooks四引擎
前端·人工智能·agent
xiaohe06011 小时前
🤔 v-mortal 是什么?!我只知道 v-model 啊!
vue.js·vite·前端工程化
xm_xm_xm_11 小时前
TypeScript 7 个 核心特性
前端·typescript
mONESY1 小时前
手把手从零复刻「英文网页 AI 翻译」Chrome 插件(新手向)
javascript
pppiii1 小时前
前端并发请求控制:5 种实现方案完整梳理
前端
郑州光合科技余经理2 小时前
本地生活平台搭建:统一订单表与多后台切换怎么拆
java·开发语言·前端·系统架构·uni-app·php·ai编程
iFlyCai2 小时前
深入理解Flutter:StatefulWidget生命周期全解析(四)
前端·javascript·flutter