浏览器页面关闭时自动调用接口 · 学习与实战指南
更新: 5/23/2026 字数: 0 字 时长: 0 分钟
主题:在标签页关闭、浏览器整体关闭、刷新、跳走等"页面终结"场景下,如何可靠地把一个"关闭任务"请求送到服务端。
一、先把结论说清楚
能实现,但不能 100% 保证。 这是浏览器的安全/性能设计带来的硬限制,不是 API 选错的问题。
| 关闭场景 | 是否能触发 | 可靠性 |
|---|---|---|
| 标签页关闭(点 ×) | ✅ | 高 |
| 浏览器整体关闭 | ✅ | 中(部分浏览器/系统会缩短回调时间) |
| 页面刷新 / 跳转走 | ✅ | 高 |
| 浏览器进程被杀(任务管理器、崩溃、断电) | ❌ | 0 |
| 移动端切后台后被系统回收 | ⚠️ | 低(pagehide 可能触发,也可能不触发) |
| 笔记本盖上盖子睡眠 | ⚠️ | 视情况 |
所以正确的工程目标是:在所有"浏览器愿意给我们机会"的场景下都把请求发出去,并且在浏览器没机会时由服务端兜底(心跳超时 / 续约机制)。只靠前端事件做关闭任务,永远会有边角漏网。
二、关键 API 与事件全景
2.1 三个候选事件
| 事件 | 触发时机 | 推荐度 |
|---|---|---|
beforeunload | 页面即将卸载(关闭/刷新/跳走) | ⚠️ 不推荐用来发请求 |
unload | 页面正在卸载 | ❌ 已被现代浏览器废弃化 |
pagehide | 页面被隐藏(包括卸载、进入 BFCache) | ✅ 首选 |
visibilitychange(state=hidden) | 页面变为不可见(切标签、最小化、关闭) | ✅ 移动端必备 |
为什么不用 beforeunload 发请求?
- Chrome / Safari 已不允许
beforeunload中发起异步请求 - 它的本意是"询问用户要不要离开",弹确认框
- 在 BFCache 场景下不一定触发
为什么不用 unload?
- iOS Safari 几乎从不触发
- 触发了也会阻止 BFCache 命中,伤害用户回退体验
- Chrome 团队公开建议迁移到
pagehide+visibilitychange
为什么 pagehide + visibilitychange 是黄金组合?
visibilitychange在移动端"切后台"时是唯一稳定可用的信号——这恰好是大多数移动端"用完就走"的场景pagehide在 PC 端关闭/刷新时稳定触发,且不影响 BFCache
2.2 三个候选发请求方式
| 方式 | 关闭场景能否成功 | 说明 |
|---|---|---|
fetch() | ❌ 大概率被中断 | 页面卸载时浏览器会取消进行中的 fetch |
XMLHttpRequest(同步) | ⚠️ 部分场景可用 | 已废弃,会卡 UI,强烈不推荐 |
navigator.sendBeacon() | ✅ 首选 | 专为此场景设计 |
fetch(..., { keepalive: true }) | ✅ 备选 | 现代替代方案,能力比 sendBeacon 更强 |
三、核心工具:sendBeacon 与 fetch keepalive
3.1 navigator.sendBeacon
它是浏览器为"页面卸载时发送数据"专门设计的 API。特点:
- 不阻塞页面卸载:浏览器把请求放进队列,页面继续关闭
- 后台异步发送:即使页面已经关闭,请求仍会被发出
- POST 请求,单次单包:payload 限制约 64KB
- 不能自定义复杂请求头:Content-Type 受限于
text/plain、multipart/form-data、application/x-www-form-urlencoded
const ok = navigator.sendBeacon(
'/api/task/close',
JSON.stringify({ taskId: '123', reason: 'page-close' })
);
// ok === true 表示进入发送队列,不代表服务端已收到3.2 fetch 的 keepalive 选项
fetch('/api/task/close', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: '123', reason: 'page-close' })
});相比 sendBeacon:
- ✅ 能自定义任意请求头(包括认证、
Content-Type: application/json) - ✅ 能拿到响应(如果页面还活着的话)
- ⚠️ payload 同样有 64KB 限制(所有 keepalive 请求总和)
- ⚠️ Safari 早期版本支持不全,需做能力检测
3.3 选型建议
| 需求 | 推荐 |
|---|---|
| 简单"通知一下" | sendBeacon |
| 要带 JWT / 自定义 header | fetch + keepalive |
| 要做兜底 | 两者都试,谁先成功就跳过另一个 |
四、实战代码
4.1 最小可用版本
function closeTask() {
const url = '/api/task/close';
const payload = JSON.stringify({
taskId: window.currentTaskId,
timestamp: Date.now()
});
// 优先 sendBeacon
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(url, blob);
return;
}
// 兜底:fetch keepalive
fetch(url, {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload
}).catch(() => {});
}
// 关键:同时监听 pagehide 和 visibilitychange
window.addEventListener('pagehide', closeTask);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
closeTask();
}
});4.2 防重复发送的生产级版本
页面隐藏 → 重新显示 → 又隐藏 → 关闭,会触发多次。要加幂等控制:
class TaskCloseReporter {
constructor(taskId) {
this.taskId = taskId;
this.url = '/api/task/close';
this.sent = false;
this.bind();
}
bind() {
// PC 端关闭/刷新/跳走主路径
window.addEventListener('pagehide', () => this.report('pagehide'));
// 移动端切后台 + PC 端切标签
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.report('visibility-hidden');
}
});
// 备份:beforeunload 仅做最后一道兜底(不发请求,仅触发)
// 现代浏览器里 pagehide 已经覆盖,这里可省略
}
report(reason) {
if (this.sent) return;
this.sent = true;
const payload = JSON.stringify({
taskId: this.taskId,
reason,
timestamp: Date.now()
});
// 双保险:sendBeacon 优先,失败再 fetch keepalive
let beaconOk = false;
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
beaconOk = navigator.sendBeacon(this.url, blob);
}
if (!beaconOk) {
try {
fetch(this.url, {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload
}).catch(() => {});
} catch (_) {}
}
}
// 用户可能在隐藏后又回来,需要重置(业务自行决定是否启用)
reset() {
this.sent = false;
}
}
// 使用
const reporter = new TaskCloseReporter('task-123');4.3 携带认证 Header(JWT/Cookie 之外的场景)
function closeWithAuth(taskId, token) {
const payload = JSON.stringify({ taskId });
// sendBeacon 不能加 Authorization header
// 这种场景必须用 fetch keepalive
return fetch('/api/task/close', {
method: 'POST',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: payload
}).catch(() => {});
}4.4 移动端额外补强:BFCache 感知
iOS Safari / Chrome Android 会把页面放进 BFCache(前进后退缓存)。这种页面"看似关闭",实际只是隐藏。pagehide 的 event.persisted 字段可以区分:
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
// 页面进入 BFCache,可能很快回来
// 业务上判断要不要真的"关闭任务"
} else {
// 真的关闭/跳转,安心关
closeTask();
}
});五、服务端必须做的兜底(重要)
前端再怎么做,都有触达不到的场景:
- 浏览器进程崩溃
- 任务管理器强杀
- 断电、断网、关机不走正常流程
- 移动端被系统回收且
pagehide没来得及触发
所以服务端必须有独立兜底机制,不能完全信任前端"关闭通知"。
5.1 推荐方案:心跳续约 + 超时回收
1. 任务创建时设置 TTL(如 60s)
2. 前端每 30s 发一次心跳,刷新 TTL
3. 前端关闭通知到达 → 立刻关闭任务
4. 前端通知没到达 → TTL 过期,服务端定时任务自动关闭前端心跳代码示例:
const heartbeat = setInterval(() => {
fetch('/api/task/heartbeat', {
method: 'POST',
body: JSON.stringify({ taskId })
});
}, 30_000);
// 关闭时清掉
window.addEventListener('pagehide', () => clearInterval(heartbeat));5.2 双保险架构图
[前端]
├─ 30s 心跳 ────────► [服务端]
└─ pagehide │
└─ sendBeacon ────────►│
│
├─ 收到关闭通知 → 立即关闭任务
└─ 60s 内未收到心跳 → 自动关闭任务这样前端事件到达就秒级关闭,没到达也能在一个 TTL 周期内回收,资源不会泄漏。
六、调试与验证
6.1 检测 sendBeacon 是否真的发出去了
navigator.sendBeacon() 返回 true 只表示"进入了发送队列",不代表服务端收到。验证方式:
- DevTools → Network:勾上 "Preserve log"(防止刷新清空),关闭页面后看请求记录
- Application → Background services → Background fetch:Chrome 能看到后台任务
- 服务端日志:直接看接口是否收到
6.2 模拟各种关闭场景
// 控制台快速验证
window.dispatchEvent(new Event('pagehide'));
document.dispatchEvent(new Event('visibilitychange'));或者在 DevTools 里手动触发:
- Application → Service Workers → Update on reload:模拟刷新场景
- Application → Frames → top → 选中后右键 unload
6.3 常见 bug 排查
| 现象 | 可能原因 |
|---|---|
| sendBeacon 返回 false | payload > 64KB,或者总队列已满 |
| 接口收不到请求 | Content-Type 不被允许(sendBeacon 限制) |
| 偶尔收不到 | 用户触发了浏览器进程被杀的场景,无解,需服务端兜底 |
| 一次关闭收到多个请求 | 没做幂等,pagehide + visibilitychange 都触发了 |
| 跨域接口失败 | sendBeacon 走 CORS,需要服务端配置 Access-Control-Allow-Origin |
七、决策清单(开工前对一遍)
- [ ] 用
pagehide+visibilitychange双事件监听,不用beforeunload/unload - [ ] 用
sendBeacon作为主发送方式,fetch keepalive作为兜底 - [ ] 加
sent标志位防重复发送 - [ ] 移动端检查
event.persisted区分 BFCache - [ ] 需要自定义 header → 直接用
fetch keepalive - [ ] 服务端配套心跳 + TTL,不依赖前端 100% 触达
- [ ] 接口做幂等设计(同一 taskId 多次"关闭"应等价于一次)
- [ ] 跨域接口配好 CORS
- [ ] payload 控制在 64KB 以内
- [ ] 真实关闭场景下做端到端验证(不只看 sendBeacon 返回值)
八、最终心智模型
把"页面关闭时调用接口"这个需求拆成两层去想:
前端层:尽力而为
- 用对事件(
pagehide+visibilitychange) - 用对 API(
sendBeacon/fetch keepalive) - 做好幂等和防重
服务端层:兜底为王
- 心跳续约
- TTL 过期自动回收
- 关闭接口幂等
只有这两层一起到位,"关闭任务"这件事才算真的可靠。任何只在前端做、希望前端 100% 触达的方案都是在埋雷——总会有那 1% 的崩溃、断电、强杀场景,把孤儿任务留在你的服务端。