NeuralStack
返回文章列表
9 min read

navigator.sendBeacon:页面卸载时可靠发送数据的利器

深入介绍 navigator.sendBeacon API 的原理、用法,以及在前端埋点场景中的实际应用

前言

在前端开发中,我们经常需要在用户离开页面时发送一些数据,比如:

  • 用户页面停留时间
  • 页面滚动深度
  • 错误日志上报
  • 会话结束通知

但传统的 fetchXMLHttpRequest 在页面卸载时可能会被浏览器取消,导致数据丢失。navigator.sendBeacon 就是为解决这个问题而生的。

什么是 sendBeacon?

navigator.sendBeacon 是浏览器提供的一个 API,用于异步地向服务器发送少量数据,特别适合在页面卸载(unload)期间使用。

核心优势

  1. 可靠性 - 浏览器保证在页面关闭/跳转期间将请求发出
  2. 非阻塞 - 不会延迟页面卸载,不影响用户体验
  3. 简单易用 - 一行代码即可发送数据

基本语法

navigator.sendBeacon(url, data)

参数

参数类型说明
urlstring请求的目标 URL
dataBodyInit (可选)要发送的数据,支持 ArrayBufferBlobFormDataURLSearchParamsstring

返回值

返回 boolean

  • true - 数据已成功加入浏览器的发送队列
  • false - 数据无法被加入队列(如页面正在卸载且队列已满)

使用示例

1. 发送 JSON 数据

const analyticsData = {
  event: 'page_close',
  page: window.location.pathname,
  timestamp: Date.now(),
  scrollDepth: getScrollPercentage()
};
 
navigator.sendBeacon('/api/analytics', JSON.stringify(analyticsData));

2. 使用 FormData

const formData = new FormData();
formData.append('action', 'logout');
formData.append('userId', '12345');
formData.append('sessionDuration', sessionTime);
 
navigator.sendBeacon('/api/log', formData);

3. 使用 URLSearchParams

const params = new URLSearchParams({
  event: 'page_view',
  page: '/home',
  referrer: document.referrer
});
 
navigator.sendBeacon('/api/track', params);

与 fetch/XMLHttpRequest 的对比

特性sendBeaconfetchXMLHttpRequest
页面卸载时可靠发送
阻塞页面卸载可能可能
请求体大小限制~64KB无明确限制无明确限制
可设置请求头❌ (仅 CORS safelist)
响应处理❌ (fire-and-forget)
取消请求✅ (AbortController)✅ (.abort())

为什么 fetch 在页面卸载时不可靠?

当页面正在卸载时,浏览器可能会中断正在执行的 JavaScript,包括未完成的 fetch 请求。而 sendBeacon 的请求会被浏览器加入专门的队列,即使页面已经关闭,浏览器仍会保证这些请求被发出。

前端埋点应用案例

案例 1:页面停留时间统计

// track-page-duration.js
class PageDurationTracker {
  constructor() {
    this.startTime = Date.now();
    this.setupListeners();
  }
 
  setupListeners() {
    // 监听页面卸载
    window.addEventListener('beforeunload', () => {
      this.track();
    });
 
    // 监听页面可见性变化(切换标签页)
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.track();
      }
    });
  }
 
  track() {
    const duration = Date.now() - this.startTime;
    const data = {
      event: 'page_duration',
      page: window.location.pathname,
      title: document.title,
      duration: duration, // 毫秒
      timestamp: Date.now()
    };
 
    navigator.sendBeacon('/api/analytics', JSON.stringify(data));
  }
}
 
// 初始化
new PageDurationTracker();

案例 2:滚动深度追踪

// track-scroll-depth.js
class ScrollDepthTracker {
  constructor() {
    this.maxScroll = 0;
    this.trackingPoints = [25, 50, 75, 100]; // 追踪的滚动百分比
    this.reachedPoints = new Set();
    this.setupListeners();
  }
 
  setupListeners() {
    // 节流处理滚动事件
    let ticking = false;
    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          this.updateScrollDepth();
          ticking = false;
        });
        ticking = true;
      }
    });
 
    window.addEventListener('beforeunload', () => {
      this.sendReport();
    });
  }
 
  updateScrollDepth() {
    const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
    const scrollTop = window.scrollY;
    const scrollPercent = Math.round((scrollTop / scrollHeight) * 100);
 
    this.maxScroll = Math.max(this.maxScroll, scrollPercent);
 
    // 检查是否达到追踪点
    this.trackingPoints.forEach(point => {
      if (scrollPercent >= point && !this.reachedPoints.has(point)) {
        this.reachedPoints.add(point);
        this.sendScrollEvent(point);
      }
    });
  }
 
  sendScrollEvent(percent) {
    navigator.sendBeacon('/api/analytics', JSON.stringify({
      event: 'scroll_depth',
      page: window.location.pathname,
      percent: percent,
      timestamp: Date.now()
    }));
  }
 
  sendReport() {
    navigator.sendBeacon('/api/analytics', JSON.stringify({
      event: 'page_scroll_summary',
      page: window.location.pathname,
      maxScrollPercent: this.maxScroll,
      reachedPoints: Array.from(this.reachedPoints),
      timestamp: Date.now()
    }));
  }
}
 
new ScrollDepthTracker();

案例 3:错误日志上报

// error-logger.js
class ErrorLogger {
  constructor() {
    this.errors = [];
    this.setupListeners();
  }
 
  setupListeners() {
    // 捕获未处理的错误
    window.addEventListener('error', (event) => {
      this.captureError({
        type: 'runtime_error',
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error?.stack
      });
    });
 
    // 捕获未处理的 Promise 错误
    window.addEventListener('unhandledrejection', (event) => {
      this.captureError({
        type: 'promise_rejection',
        message: event.reason?.message || 'Unhandled Promise Rejection',
        stack: event.reason?.stack
      });
    });
 
    // 页面卸载时上报所有错误
    window.addEventListener('beforeunload', () => {
      this.flush();
    });
  }
 
  captureError(error) {
    this.errors.push({
      ...error,
      url: window.location.href,
      userAgent: navigator.userAgent,
      timestamp: Date.now()
    });
 
    // 错误数量超过阈值时立即上报
    if (this.errors.length >= 10) {
      this.flush();
    }
  }
 
  flush() {
    if (this.errors.length === 0) return;
 
    navigator.sendBeacon('/api/errors', JSON.stringify({
      errors: this.errors,
      sessionId: this.getSessionId()
    }));
 
    this.errors = [];
  }
 
  getSessionId() {
    let sessionId = sessionStorage.getItem('error_session_id');
    if (!sessionId) {
      sessionId = crypto.randomUUID();
      sessionStorage.setItem('error_session_id', sessionId);
    }
    return sessionId;
  }
}
 
new ErrorLogger();

案例 4:综合埋点 SDK

// analytics-sdk.js
class AnalyticsSDK {
  constructor(config) {
    this.endpoint = config.endpoint;
    this.buffer = [];
    this.flushInterval = config.flushInterval || 5000;
    this.maxBufferSize = config.maxBufferSize || 20;
 
    this.init();
  }
 
  init() {
    // 定期上报
    setInterval(() => this.flush(), this.flushInterval);
 
    // 页面卸载时上报
    window.addEventListener('beforeunload', () => this.flush());
 
    // 页面隐藏时上报(移动端切换应用)
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });
 
    // 网络恢复时上报
    window.addEventListener('online', () => this.flush());
  }
 
  track(event, properties = {}) {
    const data = {
      event,
      properties: {
        ...properties,
        url: window.location.href,
        referrer: document.referrer,
        timestamp: Date.now(),
        screenWidth: window.innerWidth,
        screenHeight: window.innerHeight
      }
    };
 
    this.buffer.push(data);
 
    // 缓冲区满时立即上报
    if (this.buffer.length >= this.maxBufferSize) {
      this.flush();
    }
  }
 
  flush() {
    if (this.buffer.length === 0) return;
 
    const data = {
      events: [...this.buffer],
      sessionId: this.getSessionId(),
      userId: this.getUserId()
    };
 
    // 使用 sendBeacon 发送
    const success = navigator.sendBeacon(
      this.endpoint,
      JSON.stringify(data)
    );
 
    if (success) {
      this.buffer = [];
    }
  }
 
  getSessionId() {
    let id = sessionStorage.getItem('analytics_session');
    if (!id) {
      id = crypto.randomUUID();
      sessionStorage.setItem('analytics_session', id);
    }
    return id;
  }
 
  getUserId() {
    let id = localStorage.getItem('analytics_user_id');
    if (!id) {
      id = crypto.randomUUID();
      localStorage.setItem('analytics_user_id', id);
    }
    return id;
  }
}
 
// 使用示例
const analytics = new AnalyticsSDK({
  endpoint: '/api/analytics',
  flushInterval: 10000,
  maxBufferSize: 50
});
 
// 页面加载时追踪
analytics.track('page_view', {
  title: document.title,
  path: window.location.pathname
});
 
// 用户行为追踪
document.querySelector('#buy-button').addEventListener('click', () => {
  analytics.track('button_click', {
    buttonId: 'buy-button',
    buttonText: '立即购买'
  });
});

服务端配合示例

Express.js 接收端

const express = require('express');
const app = express();
 
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
app.post('/api/analytics', (req, res) => {
  // sendBeacon 发送的数据在 req.body 中
  console.log('收到埋点数据:', req.body);
 
  // 存储到数据库
  saveToDatabase(req.body);
 
  // 返回 204 No Content(sendBeacon 不需要响应体)
  res.status(204).end();
});
 
app.listen(3000);

注意事项与最佳实践

1. 数据大小限制

sendBeacon 的请求体大小限制约为 64KB。如果数据量较大,建议:

// 方案 1:压缩数据
const compressed = compressData(largeData);
navigator.sendBeacon('/api/analytics', compressed);
 
// 方案 2:只发送关键字段
const summary = {
  event: 'page_close',
  page: url,
  duration: time,
  // 不发送完整的历史记录
};
navigator.sendBeacon('/api/analytics', JSON.stringify(summary));

2. Content-Type 限制

sendBeacon 只支持以下 Content-Type(通过数据类型自动设置):

  • text/plain(字符串)
  • application/x-www-form-urlencoded(URLSearchParams)
  • multipart/form-data(FormData)
  • application/octet-stream(ArrayBuffer/Blob)

如果需要发送 JSON,建议使用 Blob 指定 Content-Type:

const data = JSON.stringify({ event: 'test' });
const blob = new Blob([data], { type: 'application/json' });
navigator.sendBeacon('/api/analytics', blob);

3. 错误处理

sendBeacon 是 fire-and-forget 模式,无法获取响应结果。如果需要确认数据是否成功接收,需要在服务端实现单独的确认机制。

4. 兼容性

sendBeacon 在现代浏览器中支持良好:

  • Chrome 39+
  • Firefox 31+
  • Safari 11.1+
  • Edge 14+

对于不支持的浏览器,可以降级使用 fetch

function sendData(url, data) {
  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, JSON.stringify(data));
  } else {
    // 降级方案:使用 fetch with keepalive
    fetch(url, {
      method: 'POST',
      body: JSON.stringify(data),
      keepalive: true,
      headers: {
        'Content-Type': 'application/json'
      }
    }).catch(() => {});
  }
}

总结

navigator.sendBeacon 是前端埋点场景中的利器,特别适合在页面卸载时可靠地发送数据。它的主要优势:

  1. 可靠 - 浏览器保证请求发出
  2. 高效 - 不阻塞页面卸载
  3. 简单 - API 设计简洁易用

在实际项目中,建议将 sendBeacon 与其他上报机制(定时上报、批量上报)结合使用,构建完整的前端监控体系。

参考资料