最近遇到了一个看起来很简单的需求:
用户关闭浏览器标签页时,弹出确认框;确认离开后退出登录。如果绑定了分机,还要先解绑分机。但刷新页面时不能退出。
一开始我觉得,这不就是监听一个关闭事件吗?
结果越研究越发现,浏览器页面关闭这件事,远比想象中复杂。
第一阶段:我想弹出自己的弹窗
最开始,我希望用户关闭标签页时,弹出项目自己的 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
第三阶段:网上的方法看起来都能用
为了区分刷新和关闭,我开始在网上搜索各种方案。
使用事件时间差
有文章通过计算 beforeunload 和 unload 的时间差判断:
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 属于较新的浏览器能力,使用前需要检查目标浏览器兼容性。对于浏览器崩溃、系统强制结束进程等异常情况,前端事件仍然无法保证执行。要求更高的系统,最好再配合后端心跳、会话过期或延迟退出机制。