页面关闭统计数据
在现代 Web 应用中,收集用户行为数据对于产品优化至关重要。其中一个常见需求是在用户关闭页面时发送统计数据,比如页面停留时间、用户操作记录等。然而,这个看似简单的需求却有不少技术陷阱。
传统方案的问题
unload 和 beforeunload 事件的不可靠性
许多开发者首先想到的是使用 unload 或 beforeunload 事件:
javascript
// ❌ 不推荐的做法
window.addEventListener('beforeunload', function () {
// 发送统计数据
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(analyticsData),
})
})这种方案存在以下问题:
- 事件触发不可靠:在移动设备上,这些事件可能不会触发
- 异步请求被中断:页面卸载时,异步请求往往来不及完成就被浏览器取消
- 用户体验差:如果使用同步请求,会阻塞页面关闭过程
同步请求的弊端
为了确保数据发送成功,有些开发者会使用同步的 XMLHttpRequest:
javascript
// ❌ 会阻塞浏览器的做法
window.addEventListener('beforeunload', function () {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/analytics', false) // false 表示同步
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(JSON.stringify(analyticsData))
})虽然这样可以确保数据发送,但会导致:
- 页面关闭时出现明显延迟
- 浏览器界面冻结
- 用户体验极差
最佳解决方案:Navigator.sendBeacon()
Navigator.sendBeacon() 是专门为解决这个问题而设计的 API。它具有以下优势:
- 异步发送:不会阻塞页面卸载过程
- 可靠性高:浏览器会确保数据发送完成,即使页面已经关闭
- 简单易用:API 设计简洁,使用方便
基本语法
javascript
navigator.sendBeacon(url, data)url:接收数据的服务器端点data:要发送的数据(可选)- 返回值:
boolean,表示是否成功加入发送队列
支持的数据类型
sendBeacon() 支持多种数据格式:
1. 普通字符串 (text/plain)
javascript
const analyticsData = JSON.stringify({
userId: '12345',
pageUrl: window.location.href,
duration: Date.now() - pageStartTime,
})
navigator.sendBeacon('/api/analytics', analyticsData)2. FormData 对象
javascript
const formData = new FormData()
formData.append('userId', '12345')
formData.append('pageUrl', window.location.href)
formData.append('duration', Date.now() - pageStartTime)
navigator.sendBeacon('/api/analytics', formData)3. URLSearchParams (application/x-www-form-urlencoded)
javascript
const params = new URLSearchParams()
params.append('userId', '12345')
params.append('pageUrl', window.location.href)
params.append('duration', Date.now() - pageStartTime)
navigator.sendBeacon('/api/analytics', params)4. Blob 对象
javascript
const data = {
userId: '12345',
pageUrl: window.location.href,
duration: Date.now() - pageStartTime,
}
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json',
})
navigator.sendBeacon('/api/analytics', blob)实际应用示例
页面停留时间统计
javascript
// 页面分析功能
const createPageAnalytics = () => {
const startTime = Date.now()
const fallbackSend = data => {
// 对于不支持 sendBeacon 的浏览器
try {
fetch('/api/page-analytics', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
keepalive: true, // 重要:保持连接活跃
})
} catch (error) {
console.error('Analytics fallback failed:', error)
}
}
const sendAnalytics = () => {
const duration = Date.now() - startTime
const data = {
url: window.location.href,
duration: duration,
timestamp: Date.now(),
userAgent: navigator.userAgent,
}
// 使用 sendBeacon 发送数据
if (navigator.sendBeacon) {
const success = navigator.sendBeacon('/api/page-analytics', JSON.stringify(data))
if (!success) {
console.warn('Failed to queue analytics data')
}
} else {
// 降级方案
fallbackSend(data)
}
}
const setupBeaconTracking = () => {
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendAnalytics()
}
})
// 备用方案:监听 beforeunload
window.addEventListener('beforeunload', () => {
sendAnalytics()
})
}
// 初始化追踪
setupBeaconTracking()
return {
sendAnalytics,
}
}
// 初始化页面分析
const analytics = createPageAnalytics()用户行为追踪
javascript
// 用户行为追踪功能
const createUserBehaviorTracker = () => {
let actions = []
const getSessionId = () => {
// 简单的会话 ID 生成
return (
sessionStorage.getItem('sessionId') ||
(sessionStorage.setItem('sessionId', Date.now().toString(36)), sessionStorage.getItem('sessionId'))
)
}
const recordAction = (type, data) => {
actions.push({ type, ...data })
// 限制数组大小,避免内存溢出
if (actions.length > 100) {
actions.shift()
}
}
const sendBehaviorData = () => {
if (actions.length === 0) return
const payload = {
sessionId: getSessionId(),
url: window.location.href,
actions: actions,
timestamp: Date.now(),
}
// 使用 FormData 发送
const formData = new FormData()
formData.append('data', JSON.stringify(payload))
navigator.sendBeacon('/api/user-behavior', formData)
// 清空已发送的数据
actions = []
}
const setupTracking = () => {
// 追踪点击事件
document.addEventListener('click', e => {
recordAction('click', {
element: e.target.tagName,
className: e.target.className,
id: e.target.id,
timestamp: Date.now(),
})
})
// 追踪滚动事件(节流处理)
let scrollTimer
document.addEventListener('scroll', () => {
clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
recordAction('scroll', {
scrollY: window.scrollY,
timestamp: Date.now(),
})
}, 100)
})
// 页面关闭时发送数据
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendBehaviorData()
}
})
}
// 初始化追踪
setupTracking()
return {
recordAction,
sendBehaviorData,
}
}
// 初始化行为追踪
const behaviorTracker = createUserBehaviorTracker()服务端处理
服务端需要正确处理 sendBeacon 发送的数据:
javascript
// Express.js 示例
app.post('/api/analytics', (req, res) => {
// sendBeacon 发送的数据在 req.body 中
const analyticsData = req.body
// 处理数据(存储到数据库等)
console.log('Received analytics:', analyticsData)
// 重要:快速响应,避免超时
res.status(204).send() // 204 No Content
})
// 处理不同的 Content-Type
app.use(express.json()) // application/json
app.use(express.urlencoded({ extended: true })) // application/x-www-form-urlencoded
app.use(express.raw({ type: 'text/plain' })) // text/plain兼容性和降级方案

浏览器兼容性检查
javascript
function sendAnalyticsData(url, data) {
if ('sendBeacon' in navigator) {
// 使用 sendBeacon
return navigator.sendBeacon(url, data)
} else if ('fetch' in window) {
// 降级到 fetch with keepalive
return fetch(url, {
method: 'POST',
body: data,
keepalive: true,
})
.then(() => true)
.catch(() => false)
} else {
// 最后的降级方案:同步 XHR(不推荐)
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', url, false)
xhr.send(data)
return xhr.status >= 200 && xhr.status < 300
} catch (error) {
return false
}
}
}完整的兼容性方案
javascript
// 分析数据发送工具
const createAnalyticsBeacon = () => {
const syncSend = (url, data) => {
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', url, false) // 同步请求
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(data)
return xhr.status >= 200 && xhr.status < 300
} catch (error) {
console.error('Sync analytics send failed:', error)
return false
}
}
const send = (url, data) => {
// 优先使用 sendBeacon
if (navigator.sendBeacon) {
return navigator.sendBeacon(url, data)
}
// 降级到 fetch with keepalive
if (window.fetch) {
fetch(url, {
method: 'POST',
body: data,
keepalive: true,
headers: {
'Content-Type': 'application/json',
},
}).catch(error => {
console.warn('Analytics fetch failed:', error)
})
return true
}
// 最后的降级方案
return syncSend(url, data)
}
return {
send,
}
}
// 使用示例
const analyticsBeacon = createAnalyticsBeacon()
analyticsBeacon.send('/api/analytics', JSON.stringify(data))最佳实践总结
- 优先使用
visibilitychange事件:比beforeunload更可靠 - 数据格式选择:JSON 字符串简单直接,FormData 适合复杂数据
- 错误处理:检查
sendBeacon返回值,提供降级方案 - 数据大小限制:
sendBeacon有大小限制(通常 64KB),注意控制数据量 - 服务端优化:快速响应,避免超时
- 隐私考虑:遵循数据保护法规,获得用户同意
注意事项
sendBeacon只支持 POST 请求- 数据大小有限制(浏览器实现不同)
- 不支持自定义请求头
- 无法获取响应内容
- 在某些情况下仍可能失败(如网络断开)
通过合理使用 Navigator.sendBeacon(),我们可以在不影响用户体验的前提下,可靠地收集页面关闭时的统计数据,为产品优化提供有价值的数据支持。

📌 评论规则
需要 GitHub 账号登录 禁止发布广告、无关内容 请保持友善讨论