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 的结果。

对我来说,最大的认知变化是:不要把“什么时候触发”和“请求能否继续”混成一个问题。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 |
|---|---|---|
| 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。无论选哪一个,都把触发点放在更可靠的页面生命周期事件上,把体积控制和幂等放在设计里,而不是等丢数据后再补救。
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
294 收藏
-
142 收藏
-
288 收藏
-
392 收藏
-
110 收藏
-
251 收藏
-
380 收藏
-
269 收藏
-
249 收藏
-
167 收藏
-
149 收藏
-
138 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习