从“浏览器根本区分不了”到真正实现:刷新不退出,关闭标签页自动退出,前端判断浏览器关闭和刷新

最近遇到了一个看起来很简单的需求:

用户关闭浏览器标签页时,弹出确认框;确认离开后退出登录。如果绑定了分机,还要先解绑分机。但刷新页面时不能退出。

一开始我觉得,这不就是监听一个关闭事件吗?

结果越研究越发现,浏览器页面关闭这件事,远比想象中复杂。

第一阶段:我想弹出自己的弹窗

最开始,我希望用户关闭标签页时,弹出项目自己的 Element UI 弹窗:

kotlin 复制代码
this.$confirm(
  "确定退出系统吗?",
  "提示"
);

但很快就发现,这条路走不通。

关闭标签页、刷新页面属于浏览器级操作。页面一旦进入销毁流程,浏览器不会等待一个自定义 HTML 弹窗,也不会等待 Promise、setTimeout 或异步回调。

浏览器唯一允许的是 beforeunload 原生确认框:

ini 复制代码
window.addEventListener("beforeunload", event => {
  event.preventDefault();
  event.returnValue = "";
});

而且这个原生框:

  • 不能自定义样式;
  • 不能修改提示文字;
  • 不能直接监听"确定"按钮;
  • 刷新、关闭标签页和关闭浏览器都会出现。

所以第一步只能接受现实:自定义弹窗做不到,只能使用浏览器原生弹窗。

第二阶段:点击确定后执行退出

虽然拿不到原生框"确定"按钮的回调,但可以换一个思路。

如果用户点击取消,页面会继续停留,不会触发 pagehide;如果用户点击确认,页面继续离开,随后触发 pagehide

于是有了第一个版本:

ini 复制代码
handleBeforeUnload(event) {
  event.preventDefault();
  event.returnValue = "";
},

handlePageHide() {
  this.logout();
}

看起来很合理:

复制代码
beforeunload 弹框
→ 用户确认
→ pagehide
→ 执行退出

但测试后马上出现了新问题:刷新页面也退出了。

因为刷新和关闭都会经历:

复制代码
beforeunload → pagehide

第三阶段:网上的方法看起来都能用

为了区分刷新和关闭,我开始在网上搜索各种方案。

使用事件时间差

有文章通过计算 beforeunloadunload 的时间差判断:

scss 复制代码
if (unloadTime - beforeunloadTime <= 1) {
  // 当成关闭
}

但事件间隔会受到浏览器、电脑性能和页面负载影响。1ms 没有任何标准依据,刷新和关闭都有可能被误判。

使用鼠标坐标

还有文章根据鼠标是否位于浏览器右上角判断:

javascript 复制代码
const n =
  window.event.screenX - window.screenLeft;

if (
  n > document.documentElement.scrollWidth - 20
) {
  // 猜测用户点击了关闭按钮
}

这个方法更加不可靠。

浏览器标签栏并不属于网页 DOM,网页拿不到浏览器关闭按钮的准确坐标。而且快捷键、触摸屏、系统缩放、侧边标签栏都无法覆盖。

使用 PerformanceNavigationTiming

这是看起来最合理的一种:

go 复制代码
const entry =
  performance.getEntriesByType("navigation")[0];

if (entry.type !== "reload") {
  // 当成关闭处理
}

但仔细分析后发现,entry.type 表示的是:

当前页面当初是怎么加载进来的。

它并不表示:

用户这一次准备刷新还是关闭。

例如页面最初通过链接打开,类型是 navigate。用户现在点击刷新,旧页面在销毁前读到的仍然是 navigate。只有刷新完成后的新页面,类型才是 reload

但这个时候旧页面已经执行退出了。

所以这些方法都只能"猜",无法稳定用于退出登录这种重要操作。

第四阶段:我一度认为真的无法区分

研究到这里,我的结论已经变成:

浏览器内部可能知道用户是刷新还是关闭,但没有把这个信息暴露给网页,所以前端无法可靠区分。

直到测试时,我注意到了一个细节:

  • 刷新时,Chrome 原生框显示"重新加载此网站";
  • 关闭时,Chrome 原生框显示"离开此网站"。

既然浏览器显示的文字不一样,就说明浏览器内部肯定知道这次操作的类型。

问题只剩下一个:有没有新的 Web API 可以拿到这个信息?

第五阶段:发现 pageswap

继续查询浏览器新 API 后,我发现了 pageswap 事件。

它的 activation.navigationType 可以返回:

arduino 复制代码
"push"
"replace"
"reload"
"traverse"

其中:

ini 复制代码
event.activation.navigationType === "reload"

就表示本次页面切换是刷新。

于是我以为问题解决了:

ini 复制代码
handlePageSwap(event) {
  this.isReload =
    event.activation.navigationType === "reload";
}

但第一次实现后,刷新还是退出了。

第六阶段:思路对了,事件顺序却错了

最初我以为事件顺序是:

复制代码
pageswap
→ beforeunload
→ pagehide

所以在 beforeunload 中读取了刷新标记。

实际在本地 Chrome 150 中加入日志后,发现顺序是:

复制代码
beforeunload
→ 用户确认
→ pageswap
→ pagehide

也就是说,beforeunload 触发时,pageswap 还没有告诉页面这是一次刷新。

如果在 beforeunload 中判断,拿到的必然是旧值。

这也是整个问题最关键的地方:

beforeunload 只负责弹框,不能负责决定是否退出。最终决定必须放到 pagehide

最终实现

kotlin 复制代码
<script>
import { getToken } from "@/utils/auth";

export default {
  data() {
    return {
      browserLeaveRequested: false,
      browserLeaveIsReload: false,
      browserLogoutStarted: false
    };
  },

  mounted() {
    window.addEventListener(
      "beforeunload",
      this.handleBeforeUnload
    );

    window.addEventListener(
      "pageswap",
      this.handlePageSwap
    );

    window.addEventListener(
      "pagehide",
      this.handlePageHide
    );
  },

  methods: {
    // 只负责触发浏览器原生确认框
    handleBeforeUnload(event) {
      if (!getToken()) return;

      this.browserLeaveRequested = true;
      this.browserLeaveIsReload = false;

      event.preventDefault();
      event.returnValue = "";
    },

    // 用户确认后,识别本次操作是不是刷新
    handlePageSwap(event) {
      const activation = event.activation;

      this.browserLeaveIsReload =
        !!activation &&
        activation.navigationType === "reload";
    },

    // 最后决定是否退出
    handlePageHide() {
      // 刷新页面,不退出
      if (this.browserLeaveIsReload) return;

      if (
        !this.browserLeaveRequested ||
        this.browserLogoutStarted
      ) {
        return;
      }

      this.browserLogoutStarted = true;
      this.logoutWhenBrowserLeaves();
    },

    logoutWhenBrowserLeaves() {
      const token = getToken();

      if (!token) return;

      fetch("/api/auth/logout", {
        method: "POST",
        headers: {
          Authorization: token
        },
        credentials: "include",
        keepalive: true
      }).catch(() => {});

      this.$store.dispatch("FedLogOut");
    }
  },

  beforeDestroy() {
    window.removeEventListener(
      "beforeunload",
      this.handleBeforeUnload
    );

    window.removeEventListener(
      "pageswap",
      this.handlePageSwap
    );

    window.removeEventListener(
      "pagehide",
      this.handlePageHide
    );
  }
};
</script>

最终流程如下。

刷新页面:

复制代码
beforeunload 弹出"重新加载此网站"
→ 用户确认
→ pageswap 得到 reload
→ pagehide 检测到刷新
→ 跳过退出

关闭标签页:

复制代码
beforeunload 弹出"离开此网站"
→ 用户确认
→ 没有 reload 标记
→ pagehide
→ 解绑分机并退出登录

点击取消:

复制代码
beforeunload 弹框
→ 用户取消
→ 页面继续运行
→ 不触发最终退出

这次排查给我的启发

这个问题最有意思的地方,不是最后用了哪个 API,而是整个认知变化:

复制代码
以为一个 beforeunload 就够了
→ 想使用自定义弹窗
→ 发现只能使用原生框
→ 尝试在 beforeunload 中退出
→ 发现刷新也会退出
→ 尝试时间差、鼠标坐标、Performance API
→ 发现这些方案都在猜
→ 一度认为浏览器完全不开放区分能力
→ 从原生框文案差异找到突破口
→ 发现 pageswap
→ 最后又被事件执行顺序坑了一次
→ 通过真实日志确认最终方案

很多前端问题并不是"有没有 API",而是:

  • API表达的到底是过去状态还是当前意图;
  • 多个事件的执行顺序是什么;
  • 用户确认前和确认后分别能做什么;
  • 页面销毁阶段哪些异步操作还能继续。

只有把这些时机真正弄清楚,代码才能从"偶尔有效"变成"逻辑正确"。

最后还要提醒:pageswap 属于较新的浏览器能力,使用前需要检查目标浏览器兼容性。对于浏览器崩溃、系统强制结束进程等异常情况,前端事件仍然无法保证执行。要求更高的系统,最好再配合后端心跳、会话过期或延迟退出机制。

相关推荐
qetfw1 小时前
MWU:Vue 3 + FastAPI 的 MaaFramework 跨平台 WebUI 源码
前端·vue.js·python·fastapi·开源项目·效率工具
mfxcyh1 小时前
Vue3+Ant Design Vue 实现手动异步文件上传(含大小校验、弹窗上传)
前端·javascript·vue.js
wordbaby1 小时前
Web端热敏标签打印完整解决方案(QZ Tray + 译维A42)
前端
swipe1 小时前
03|Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
前端·后端·全栈
meilindehuzi_a1 小时前
Vue3进阶基础:指令修饰符、v-model表单绑定、动态样式、计算属性与侦听器实战
前端·javascript·vue.js
csdn2015_1 小时前
vue 前端运行命令
前端·javascript·vue.js
swipe1 小时前
02|从 `pnpm dev` 到 Spring Boot 启动:后端服务到底怎么跑起来?
前端·后端·全栈
swipe2 小时前
01|前端人第一次打开 Spring Boot 项目,应该先看哪里?
前端·后端·全栈
Cobyte2 小时前
根据浏览器解析 HTML 的规范实现 HTML 解析器
前端·javascript·vue.js