Skip to content

浏览器页面关闭时自动调用接口 · 学习与实战指南

更新: 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 更强

三、核心工具:sendBeaconfetch keepalive

3.1 navigator.sendBeacon

它是浏览器为"页面卸载时发送数据"专门设计的 API。特点:

  • 不阻塞页面卸载:浏览器把请求放进队列,页面继续关闭
  • 后台异步发送:即使页面已经关闭,请求仍会被发出
  • POST 请求,单次单包:payload 限制约 64KB
  • 不能自定义复杂请求头:Content-Type 受限于 text/plainmultipart/form-dataapplication/x-www-form-urlencoded
js
const ok = navigator.sendBeacon(
  '/api/task/close',
  JSON.stringify({ taskId: '123', reason: 'page-close' })
);
// ok === true 表示进入发送队列,不代表服务端已收到

3.2 fetchkeepalive 选项

js
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 / 自定义 headerfetch + keepalive
要做兜底两者都试,谁先成功就跳过另一个

四、实战代码

4.1 最小可用版本

js
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 防重复发送的生产级版本

页面隐藏 → 重新显示 → 又隐藏 → 关闭,会触发多次。要加幂等控制:

js
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');
js
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(前进后退缓存)。这种页面"看似关闭",实际只是隐藏。pagehideevent.persisted 字段可以区分:

js
window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // 页面进入 BFCache,可能很快回来
    // 业务上判断要不要真的"关闭任务"
  } else {
    // 真的关闭/跳转,安心关
    closeTask();
  }
});

五、服务端必须做的兜底(重要)

前端再怎么做,都有触达不到的场景:

  • 浏览器进程崩溃
  • 任务管理器强杀
  • 断电、断网、关机不走正常流程
  • 移动端被系统回收且 pagehide 没来得及触发

所以服务端必须有独立兜底机制,不能完全信任前端"关闭通知"。

5.1 推荐方案:心跳续约 + 超时回收

1. 任务创建时设置 TTL(如 60s)
2. 前端每 30s 发一次心跳,刷新 TTL
3. 前端关闭通知到达 → 立刻关闭任务
4. 前端通知没到达 → TTL 过期,服务端定时任务自动关闭

前端心跳代码示例:

js
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 只表示"进入了发送队列",不代表服务端收到。验证方式:

  1. DevTools → Network:勾上 "Preserve log"(防止刷新清空),关闭页面后看请求记录
  2. Application → Background services → Background fetch:Chrome 能看到后台任务
  3. 服务端日志:直接看接口是否收到

6.2 模拟各种关闭场景

js
// 控制台快速验证
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 返回 falsepayload > 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% 的崩溃、断电、强杀场景,把孤儿任务留在你的服务端。