9 min read
navigator.sendBeacon:页面卸载时可靠发送数据的利器
深入介绍 navigator.sendBeacon API 的原理、用法,以及在前端埋点场景中的实际应用
前言
在前端开发中,我们经常需要在用户离开页面时发送一些数据,比如:
- 用户页面停留时间
- 页面滚动深度
- 错误日志上报
- 会话结束通知
但传统的 fetch 或 XMLHttpRequest 在页面卸载时可能会被浏览器取消,导致数据丢失。navigator.sendBeacon 就是为解决这个问题而生的。
什么是 sendBeacon?
navigator.sendBeacon 是浏览器提供的一个 API,用于异步地向服务器发送少量数据,特别适合在页面卸载(unload)期间使用。
核心优势
- 可靠性 - 浏览器保证在页面关闭/跳转期间将请求发出
- 非阻塞 - 不会延迟页面卸载,不影响用户体验
- 简单易用 - 一行代码即可发送数据
基本语法
navigator.sendBeacon(url, data)参数
| 参数 | 类型 | 说明 |
|---|---|---|
url | string | 请求的目标 URL |
data | BodyInit (可选) | 要发送的数据,支持 ArrayBuffer、Blob、FormData、URLSearchParams、string |
返回值
返回 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 的对比
| 特性 | sendBeacon | fetch | XMLHttpRequest |
|---|---|---|---|
| 页面卸载时可靠发送 | ✅ | ❌ | ❌ |
| 阻塞页面卸载 | ❌ | 可能 | 可能 |
| 请求体大小限制 | ~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 是前端埋点场景中的利器,特别适合在页面卸载时可靠地发送数据。它的主要优势:
- 可靠 - 浏览器保证请求发出
- 高效 - 不阻塞页面卸载
- 简单 - API 设计简洁易用
在实际项目中,建议将 sendBeacon 与其他上报机制(定时上报、批量上报)结合使用,构建完整的前端监控体系。