登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

Fetch keepalive 处理页面卸载前的小请求

来源:17golang原创

时间:2026-09-29 03:56:30 291浏览 收藏

我第一次处理“用户离开页面前补发一条状态”的需求时,直觉上只是给普通 fetch() 套一个 beforeunload。桌面测试看起来没问题,换到移动端、快速切换应用和浏览器返回缓存后,缺失率却很难解释。更稳妥的方向不是阻塞页面离开,而是把小请求声明为 keepalive,并在文档进入隐藏状态时尽早发起。

核心写法:监听 visibilitychange,当 document.visibilityState === "hidden" 时调用 fetch(url, { keepalive: true })。请求体必须保持很小,官方资料给出的 keepalive 请求体限制是 64 KiB;服务端还要用事件 ID 做幂等,因为页面生命周期末端的发送不适合依赖前端等待响应后再更新状态。

官方文档:https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#keepalive

趋势信号:页面末端通信从“阻塞离开”转向生命周期感知

早期方案常在 unload 中发同步 XHR,或者用循环拖延页面关闭。它们会损害下一次导航体验,而且 unload/beforeunload 在移动设备上并不可靠,还可能影响浏览器的往返缓存(bfcache)。现在更合理的做法,是在页面变为 hidden 时尽早提交小型数据,并让浏览器负责在文档销毁后继续承载 keepalive 请求。

这里的“继续承载”不是无限后台任务。它只适合小型分析、诊断、会话摘要、轻量草稿状态和一次性业务事件,不适合图片、日志包或大 JSON 上传。把 keepalive 理解成生命周期边界上的小通道,比把它当作通用后台传输更准确。

解决的问题:让请求脱离页面销毁边界

RequestInit.keepalive 默认是 false。设置为 true 后,即使发起请求的页面在请求完成前被卸载,浏览器也不会仅因为页面卸载而中止该请求。Fetch 仍然返回 Promise,并允许设置方法、请求头等属性;不过页面已经销毁时,业务代码不能假设自己还一定有机会处理 Promise 的结果。

页面生命周期、fetch keepalive、浏览器请求队列与服务端事件入口的关系图
图1:页面生命周期、Fetch 配置与请求承载边界的静态关系;这是结构说明图,不是浏览器截图。

对我来说,最大的认知变化是:不要把“什么时候触发”和“请求能否继续”混成一个问题。visibilitychange 负责提供更合适的发送时机,keepalive 负责声明页面离开后仍希望完成传输;两者缺一不可。

采用路径:visibilitychange + 一次性提交

下面是一个可直接改造的最小实现。它做了三件事:只在 hidden 状态触发;用布尔值避免同一页面生命周期重复提交;为每个事件生成稳定 ID,便于服务端去重。

let submitted = false;

function buildExitEvent() {
  return {
    event_id: crypto.randomUUID(), // 服务端用它执行幂等去重
    type: "page_hidden",
    path: location.pathname,
    occurred_at: new Date().toISOString(),
  };
}

function submitExitEvent() {
  if (submitted) return; // 防止 visibility 状态抖动造成重复提交

  const body = JSON.stringify(buildExitEvent());
  const bytes = new Blob([body]).size;

  if (bytes >= 64 * 1024) {
    console.warn("keepalive payload is too large"); // 超限数据应提前拆分或缩减
    return;
  }

  submitted = true;
  void fetch("/events", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
    keepalive: true, // 页面卸载后仍允许浏览器继续发送此小请求
    credentials: "same-origin",
  }).catch((error) => {
    console.debug("exit event was not delivered", error); // 页面仍存活时记录失败信息
  });
}

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    submitExitEvent(); // 比依赖 unload 更早地提交
  }
});

示例使用同源 /events,这样不需要额外处理跨域许可。若必须跨域,仍要满足普通 Fetch 的 CORS 和凭据规则。不要为了“页面快关了”就绕过权限模型,也不要把敏感凭据塞进请求体。

受益角色:哪些小请求适合 keepalive

场景为什么适合仍需注意
页面停留摘要数据小,离开前自然形成最终状态服务端按 event_id 去重
错误诊断尾事件可补充最后一个页面状态不能替代持续日志上报
轻量草稿标记只提交版本号或短字段真正内容应提前保存
任务完成回执请求短、语义单一关键业务必须有服务端确认机制

我不会把支付确认、权限变更、库存扣减这类关键动作只放在页面卸载时。keepalive 能降低“页面走了,请求就被中断”的概率,但页面事件本身仍可能因为进程终止、系统回收或网络断开而没有机会发出。关键业务应在用户动作发生时立即提交,并有可恢复的服务端状态。

风险一:64 KiB 是硬边界,不是推荐目标

MDN 明确写出 keepalive 请求体限制为 64 KiB。实际工程不应该把 63.9 KiB 当成安全设计,因为序列化内容可能增长,同一时刻还可能存在其他待发送请求。更好的策略是只保留必要字段,将详细日志在页面存活阶段分批发送。

function compactSession(session) {
  return {
    event_id: session.eventId,
    duration_ms: Math.round(session.durationMs),
    last_action: session.lastAction,
    error_count: session.errors.length, // 只发送计数,不携带完整错误数组
  };
}

function canUseKeepalive(payload, softLimit = 48 * 1024) {
  const body = JSON.stringify(payload);
  return new Blob([body]).size 

也不要给 keepalive 请求使用流式 ReadableStream 请求体。页面末端的小数据应先完成序列化,得到可计算长度的字符串、Blob 或其他定长 BodyInit。

风险二:页面事件和网络传输都不是业务事务

一个容易忽略的代价是重复。页面可能先进入 hidden,随后又恢复;路由和组件也可能各自注册监听器。服务端如果直接累加,就会把一次会话算成多次。我的做法是让每条事件携带 event_id,数据库以唯一键或幂等表拒绝重复。

async function handleEvent(request, store) {
  const event = await request.json();

  if (!event.event_id) {
    return new Response("missing event_id", { status: 400 }); // 拒绝不可去重的事件
  }

  const inserted = await store.insertOnce(event.event_id, event);
  return new Response(null, {
    status: inserted ? 202 : 204, // 重复事件返回成功语义,避免客户端无效重试
  });
}

这是示意性的服务端接口,insertOnce 需要由数据库唯一约束或原子写实现。不要用“先查询、再插入”的非原子组合冒充幂等。

选择边界:什么时候用 keepalive,什么时候用 sendBeacon

两者都面向小数据,但能力并不相同。sendBeacon() 天生面向异步 POST,不提供读取响应的 Fetch Promise;当需求只是“把一小段分析数据交给服务端”时,它通常更简单。需要其他 HTTP 方法、自定义请求属性,或者页面仍存活时希望读取响应时,fetch(..., { keepalive: true }) 更灵活。

fetch keepalive 与 sendBeacon 在方法、请求属性、响应和体积限制上的关系图
图2:Fetch keepalive 与 sendBeacon 的能力和限制对照;这是静态说明图,不是运行结果。
判断项fetch keepalivesendBeacon
HTTP 方法可按 Fetch 规则设置POST
请求属性可设置 headers、credentials 等接口更简化
响应处理返回 Promise;页面仍存活时可处理不提供响应读取
典型用途需要更多请求控制的小事件简单分析与诊断数据
数据规模keepalive 请求体受 64 KiB 限制排队数据同样只适合小体积

观察指标:上线后不要只看前端 Promise

页面销毁后,前端未必还能记录 Promise 最终状态,因此效果评估要以后端为准。我会至少观察:

  • 客户端生成事件数与服务端接收事件数的差值;
  • 按 event_id 统计的重复率;
  • 请求体超过软上限而被前端放弃的次数;
  • 不同浏览器、移动端与桌面端的接收率差异;
  • 接口状态码、处理延迟和服务端幂等冲突次数。

这里要保持中立:keepalive 解决的是“页面卸载不应自动中止请求”这一层问题,并不保证任意环境下百分之百送达。对允许少量丢失、可去重、数据很小的尾事件,它很合适;对必须确认成功的关键业务,它只能是辅助通道。

相关问题

keepalive 和 HTTP Keep-Alive 是一回事吗?

不是。Fetch 的 keepalive 描述请求是否允许在发起页面卸载后继续;HTTP Keep-Alive 讨论的是连接复用和持续连接,它们处于不同层次。

为什么不直接监听 unload?

unload/beforeunload 在移动端等场景可能不触发,还可能影响 bfcache。更推荐在 visibilitychange 进入 hidden 时提交,必要时把 pagehide 作为兼容补充。

keepalive 能上传文件吗?

不适合。请求体受 64 KiB 限制,设计目标就是小型尾请求。文件上传应在页面存活阶段使用正常上传、分片或可恢复传输。

页面关闭后还能依赖 fetch 的响应吗?

Fetch 本身返回 Promise,但页面销毁后不能假设脚本环境仍能处理结果。需要强确认的业务应在用户操作时完成,并由服务端状态驱动恢复。

我的最终取舍很简单:普通小型 POST 且不关心响应,就先看 sendBeacon;需要 Fetch 的方法、请求属性或响应能力,就用 keepalive。无论选哪一个,都把触发点放在更可靠的页面生命周期事件上,把体积控制和幂等放在设计里,而不是等丢数据后再补救。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>