fetchLater API 实战指南:彻底告别页面关闭时数据丢失的顽疾

深度解析浏览器 fetchLater() API(Pending Beacon),用完整代码示例演示如何在用户关闭页面时可靠发送分析数据和表单提交,对比 sendBeacon 的优势与局限。

前端开发 2026-06-12 12 分钟

每个前端开发者都踩过这个坑:用户点击关闭标签页,你精心采集的分析数据、最后一条聊天消息、或者表单草稿,就这么无声无息地丢了。navigator.sendBeacon() 看似解决了问题,实际上它有致命缺陷——无法设置请求头、无法读取响应、无法取消。Chrome 133 起正式推出的 fetchLater() API(Pending Beacon)彻底改变了这个局面。本文将从问题根源出发,用完整代码演示如何用 fetchLater() 实现可靠的页面卸载数据发送,并深入分析它与 sendBeacon()keepalive fetch 的性能与可靠性对比。

🔍 一、为什么 sendBeacon 不够用

sendBeacon 的三大硬伤

navigator.sendBeacon() 自 2014 年推出以来,一直是处理页面卸载时数据发送的事实标准。但在实际生产中,它暴露了三个无法回避的问题:

  1. 无法设置请求头——你不能指定 Content-Type: application/json,只能发送 text/plainapplication/x-www-form-urlencoded 等有限类型
  2. 无法获取响应——sendBeacon 返回 boolean,你永远不知道服务端是否真正接收并处理了数据
  3. 无法取消或重试——一旦调用,请求就飞出去了,没有 AbortController,没有重试机制
// ❌ sendBeacon 的局限:无法设置自定义请求头,无法读取响应
const success = navigator.sendBeacon('/api/analytics', JSON.stringify({
  event: 'page_close',
  timestamp: Date.now(),
  duration: performance.now()
}));
// success 只表示浏览器"接受了这个请求",不代表服务端成功接收
console.log(success); // true — 但这毫无意义

⚠️ 警告:sendBeaconsuccess 返回值仅表示浏览器接受了该请求的入队操作,不代表网络传输成功,更不代表服务端处理成功。不要用它来做可靠性判断。

keepalive fetch 的代价

另一个常见方案是 fetch() 配合 keepalive: true,它解决了请求头的问题,但引入了新的限制:

// ❌ keepalive fetch 有 64KB 的请求体大小限制
fetch('/api/analytics', {
  method: 'POST',
  keepalive: true,
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(largePayload) // 超过 64KB 会被静默丢弃!
});
特性 sendBeacon keepalive fetch fetchLater()
自定义请求头 ❌ 不支持 ✅ 支持 ✅ 支持
读取响应 ❌ 不支持 ❌ 页面关闭后不可用 ✅ 下次访问可读
取消请求 ❌ 不支持 ✅ AbortController ✅ 可取消
请求体大小限制 ~64KB 64KB 浏览器决定,通常更大
页面关闭后发送 ✅ 浏览器托管 ✅ 浏览器托管 ✅ 浏览器托管
可延迟发送 ❌ 立即发送 ❌ 立即发送 ✅ 可指定时间
重试机制 ❌ 无 ❌ 无 ✅ 浏览器自动重试
浏览器支持 所有现代浏览器 所有现代浏览器 Chrome 133+

关键结论:fetchLater() 是目前唯一能在页面关闭后仍然可靠发送请求、且支持自定义头和响应读取的浏览器原生 API。

🚀 二、fetchLater API 核心用法

基础语法与生命周期

fetchLater() 的核心设计理念是:你告诉浏览器"这个请求很重要,请在合适的时候帮我发出去"。浏览器会智能地选择发送时机——可以立即发送,也可以延迟到页面卸载时发送。

// ✅ fetchLater 基础用法:页面关闭时自动发送
const beacon = fetchLater('/api/analytics', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    event: 'session_end',
    sessionId: crypto.randomUUID(),
    duration: Math.round(performance.now()),
    page: location.pathname
  }),
  activateAfter: 0  // 立即激活(默认值),浏览器可随时发送
});

// 查询请求状态
console.log(beacon.state); // 'pending' | 'sent' | 'aborted'

fetchLater() 返回一个 PendingBeacon 对象,它有三个状态:

  • pending——请求已入队,等待发送
  • sent——请求已成功发送
  • aborted——请求已被取消

activateAfter:精确控制发送时机

activateAfter 参数是 fetchLater() 最独特的能力——你可以告诉浏览器"在 N 毫秒后再考虑发送这个请求"。这在分析场景中极其有用:

// ✅ 场景:用户可能误触关闭,延迟 5 秒后再发送会话数据
const sessionBeacon = fetchLater('/api/session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(getSessionData()),
  activateAfter: 5000  // 5 秒后才激活,浏览器可在之后的任意时刻发送
});

// 如果用户在 5 秒内重新打开页面(比如误触恢复),可以取消
window.addEventListener('pageshow', (e) => {
  if (e.persisted && sessionBeacon.state === 'pending') {
    sessionBeacon.deactivate(); // 取消发送,会话还在继续
    console.log('用户回来了,取消会话结束上报');
  }
});

💡 提示:activateAfter 不是"在 N 毫秒后发送",而是"在 N 毫秒后允许发送"。浏览器可能在激活后才真正发送,具体时机由浏览器根据网络状况、电量等因素决定。

取消与重试机制

sendBeacon 最大的区别之一是 fetchLater() 支持取消:

// ✅ 有条件地取消 PendingBeacon
const checkoutBeacon = fetchLater('/api/checkout-abandoned', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    cart: getCartItems(),
    total: calculateTotal(),
    abandonTime: Date.now()
  }),
  activateAfter: 30000  // 30 秒后才发送"购物车放弃"事件
});

// 用户完成了结账,取消"放弃"事件
document.getElementById('checkout-btn').addEventListener('click', async () => {
  if (checkoutBeacon.state === 'pending') {
    checkoutBeacon.deactivate(); // 取消
  }
  await processCheckout();
});

💡 三、生产环境实战场景

场景一:可靠的分析数据上报

分析数据上报是最经典的 use case。传统的做法是在 beforeunload 中调用 sendBeacon,但这会导致请求丢失率在 5-15% 之间。fetchLater() 将丢失率降低到接近 0:

// ✅ 分析 SDK 核心上报逻辑
class AnalyticsSDK {
  #beacons = [];
  #sessionId = crypto.randomUUID();
  #startTime = performance.now();

  track(event, properties = {}) {
    const payload = {
      event,
      properties,
      sessionId: this.#sessionId,
      timestamp: Date.now(),
      url: location.href,
      userAgent: navigator.userAgent
    };

    // 常规分析事件用 sendBeacon 即可(快速、轻量)
    if (event !== 'session_end') {
      navigator.sendBeacon('/api/track', JSON.stringify(payload));
      return;
    }

    // 会话结束事件用 fetchLater,确保可靠送达
    const beacon = fetchLater('/api/track', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...payload,
        properties: {
          ...properties,
          sessionDuration: Math.round(performance.now() - this.#startTime)
        }
      }),
      activateAfter: 2000 // 2 秒后激活,避免误关闭
    });

    this.#beacons.push(beacon);
    return beacon;
  }

  // 获取所有待发送的 beacon 状态
  getStatus() {
    return this.#beacons.map(b => ({
      state: b.state,
      url: b.url
    }));
  }
}

// 使用
const analytics = new AnalyticsSDK();
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    analytics.track('session_end', {
      scrollDepth: getScrollDepth(),
      interacted: hasUserInteracted()
    });
  }
});

场景二:表单草稿自动保存

用户填写了一半的长表单,突然关闭了页面——这种场景用 fetchLater() 可以实现无感知的草稿保存:

// ✅ 表单草稿自动保存
class DraftSaver {
  #form;
  #beacon = null;
  #debounceTimer = null;

  constructor(formSelector) {
    this.#form = document.querySelector(formSelector);
    this.#setupListeners();
  }

  #setupListeners() {
    // 用户输入时防抖保存到 localStorage
    this.#form.addEventListener('input', () => {
      clearTimeout(this.#debounceTimer);
      this.#debounceTimer = setTimeout(() => {
        localStorage.setItem('form_draft', this.#getFormData());
      }, 1000);
    });

    // 页面关闭时用 fetchLater 发送到服务端
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.#saveDraftToServer();
      }
    });
  }

  #getFormData() {
    return JSON.stringify(Object.fromEntries(new FormData(this.#form)));
  }

  #saveDraftToServer() {
    const draft = this.#getFormData();
    if (!draft || draft === '{}') return;

    // 取消之前的 beacon(如果有)
    if (this.#beacon?.state === 'pending') {
      this.#beacon.deactivate();
    }

    this.#beacon = fetchLater('/api/drafts', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Draft-Id': crypto.randomUUID()
      },
      body: draft,
      activateAfter: 1000
    });
  }

  // 用户回来时恢复草稿
  async restoreDraft() {
    try {
      const res = await fetch('/api/drafts/latest');
      if (res.ok) {
        const draft = await res.json();
        Object.entries(draft).forEach(([name, value]) => {
          const el = this.#form.querySelector(`[name="${name}"]`);
          if (el) el.value = value;
        });
      }
    } catch {
      // fallback to localStorage
      const local = localStorage.getItem('form_draft');
      if (local) {
        Object.entries(JSON.parse(local)).forEach(([name, value]) => {
          const el = this.#form.querySelector(`[name="${name}"]`);
          if (el) el.value = value;
        });
      }
    }
  }
}

场景三:实时协作文档的最终快照

在使用 CRDT 的实时协作编辑器中,用户关闭页面时需要发送最终的文档快照。这个请求必须可靠,否则会丢失最后一次编辑:

// ✅ 协作文档最终快照
class CollabDocument {
  #docId;
  #beacon = null;

  constructor(docId) {
    this.#docId = docId;
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.#sendFinalSnapshot();
      }
    });
  }

  #sendFinalSnapshot() {
    const snapshot = this.#getDocumentState();
    if (!snapshot.dirty) return; // 没有未保存的修改

    this.#beacon = fetchLater(`/api/docs/${this.#docId}/snapshot`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'X-Snapshot-Version': String(snapshot.version)
      },
      body: JSON.stringify({
        content: snapshot.content,
        cursors: snapshot.cursors,
        timestamp: Date.now()
      }),
      activateAfter: 3000 // 3 秒后激活,给网络波动留缓冲
    });

    // 同时写入 IndexedDB 作为双保险
    this.#saveToLocal(snapshot);
  }

  #getDocumentState() {
    // 返回当前文档状态
    return { content: '', cursors: [], version: 0, dirty: false };
  }

  #saveToLocal(snapshot) {
    const tx = indexedDB.transaction('docs', 'readwrite');
    tx.objectStore('docs').put({
      id: this.#docId,
      ...snapshot,
      savedAt: Date.now()
    });
  }
}

📌 **记住:**对于关键数据(如支付、文档保存),永远不要只依赖 fetchLater()。应该同时写入 IndexedDB 作为本地备份,下次页面加载时检查并恢复。

⚠️ 四、浏览器兼容性与降级方案

fetchLater() 目前(2026 年 6 月)在 Chrome 133+ 和 Edge 133+ 中可用,Firefox 和 Safari 尚未实现。在生产环境中必须提供降级方案:

// ✅ 通用可靠发送工具函数:自动降级
function reliableSend(url, data, options = {}) {
  const { activateAfter = 0, headers = {} } = options;
  const body = typeof data === 'string' ? data : JSON.stringify(data);

  // 优先使用 fetchLater
  if ('fetchLater' in globalThis) {
    return {
      type: 'fetchLater',
      beacon: fetchLater(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...headers
        },
        body,
        activateAfter
      })
    };
  }

  // 降级:keepalive fetch
  if (typeof globalThis.fetch === 'function') {
    return {
      type: 'keepalive',
      promise: fetch(url, {
        method: 'POST',
        keepalive: true,
        headers: {
          'Content-Type': 'application/json',
          ...headers
        },
        body
      })
    };
  }

  // 最终降级:sendBeacon
  return {
    type: 'beacon',
    success: navigator.sendBeacon(url, new Blob([body], {
      type: 'application/json'
    }))
  };
}

// 使用示例
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    const result = reliableSend('/api/track', {
      event: 'page_hide',
      timestamp: Date.now()
    }, { activateAfter: 2000 });

    console.log(`使用 ${result.type} 方式发送`);
  }
});
方案 请求头自定义 响应读取 可取消 可延迟 兼容性
fetchLater() Chrome 133+
keepalive fetch 所有现代浏览器
sendBeacon 所有现代浏览器
beforeunload fetch 所有,但不可靠

⚠️ **警告:**永远不要在 beforeunload 事件处理函数中发起同步 XMLHttpRequest。虽然它曾经是可靠发送数据的方式,但现代浏览器已经限制或完全阻止这种行为,因为它会阻塞页面关闭,严重影响用户体验。

💡 五、最佳实践与避坑指南

✅ 推荐做法

  • 会话结束事件用 fetchLater,普通事件用 sendBeacon——fetchLater 有额外的内存开销,不需要对每个分析事件都使用
  • 设置合理的 activateAfter——2-5 秒可以过滤误关闭,但不要设置太长,否则用户已经离开很久了数据还没发出去
  • 配合 IndexedDB 做双保险——fetchLater 只有在网络可用时才能发送,离线场景下需要本地存储兜底
  • pageshow 事件中检查并取消——如果用户通过浏览器"恢复标签页"功能回来,应该取消待发送的 beacon

❌ 避免做法

  • 不要为每个用户交互都创建 fetchLater——每个 beacon 都会占用内存,大量 beacon 可能导致内存压力
  • 不要依赖 fetchLater 传输大数据——虽然没有像 keepalive 那样明确的 64KB 限制,但浏览器仍可能拒绝过大的请求体
  • 不要假设 fetchLater 一定成功——网络断开、浏览器崩溃等极端情况下数据仍可能丢失
  • 不要在 Service Worker 中使用——fetchLater() 是 window 上下文的 API,Service Worker 中不可用

性能注意事项

// ❌ 错误:为每个事件都创建 beacon
events.forEach(event => {
  fetchLater('/api/track', {
    method: 'POST',
    body: JSON.stringify(event)
  });
});

// ✅ 正确:批量合并后创建单个 beacon
const batchedEvents = events.map(e => JSON.stringify(e)).join('\n');
fetchLater('/api/track/batch', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-ndjson' },
  body: batchedEvents
});

📊 总结

fetchLater() API 是浏览器原生的数据可靠性方案的一次重要升级。它不是要完全替代 sendBeacon(),而是在关键场景(会话结束、表单草稿、最终快照)中提供更可靠的保障。

对于 jsjson.com 这类纯前端工具站来说,fetchLater() 最直接的应用场景是用户行为分析——确保用户关闭页面前的最后一次操作(比如使用了哪个工具、生成了什么数据)能被可靠记录,从而优化工具的使用体验。

推荐的发送策略分层:

数据类型 推荐方案 原因
普通事件(点击、浏览) sendBeacon 轻量、快速、足够可靠
关键事件(会话结束、放弃购物车) fetchLater 可靠性最高,支持延迟
需要重试的数据 keepalive fetch + 本地存储 可编程重试逻辑
大批量历史数据 IndexedDB + 后台同步 离线可用

⚡ **关键结论:**在 2026 年的前端架构中,fetchLater() 应该成为你处理页面卸载数据发送的首选方案。配合 sendBeacon 做日常事件上报、IndexedDB 做离线兜底,你就拥有了一个从"尽力而为"到"可靠送达"的完整数据上报体系。

📚 相关文章