前端埋点上报:1×1 图片打点 vs sendBeacon 全面对比
Category(分类): Browser Status: 已更新
前端埋点上报是把用户行为数据(PV、点击、停留时长、来源渠道等)从浏览器发送到统计服务器的技术。本文从
45.js中的真实代码出发,详细对比新老两种主流上报方式。
一、什么是埋点上报
统计服务器和你的网站几乎总是不同域,比如:
你的页面: https://www.example.com/page.html
统计服务器: https://tongji.example.net/collect
前端要做的,就是在用户产生行为的那一刻,把数据送出去。注意埋点的核心诉求:
- 只管发,不管收 —— 不关心服务器返回什么内容
- 跨域无阻碍 —— 不需要统计服务器配合 CORS
- 不能影响页面性能 —— 不能阻塞渲染、不能抛错
- 页面关闭时也要能发出去 —— 用户关闭标签页、跳转到其他网站的那一刻,恰恰是"离开行为"最有价值的上报时机
围绕这几条诉求,先后诞生了两代方案。
二、老方式:1×1 图片打点
2.1 原理
利用 <img> 标签加载图片天然不受同源策略限制的特性,把数据全部塞进 URL 的查询参数里,让浏览器替你发起一次 GET 请求。
这正是 45.js 中函数 n(e, n, t) 的做法(简化还原):
function track(appId, path, params) {
// 1. 拼接统计 URL,数据全部放在查询参数里
let url = 'http://122.11.38.205/fastapprouter/' + Date.now() + '/'
url += '?i=' + appId // 哪个快应用
url += '&p=' + path // 哪个页面
url += '&a=' + encodeURIComponent('from=banner&channel=huawei') // 来源参数
// 2. 创建 1×1 隐藏图片发起请求
const img = document.createElement('img')
img.src = url // ← 浏览器发出 GET 请求,数据已送达
img.style.width = '1px'
img.style.height = '1px'
img.style.display = 'none'
document.body.appendChild(img)
}
2.2 为什么每个细节都长这样
| 细节 | 原因 |
|---|---|
用 <img> 而不是 XHR/fetch | img 没有 CORS 限制,跨域随便发;XHR 想读响应必须服务器配 CORS 头 |
| 1×1 像素 | 统计服务器只需返回一张 43 字节的透明 GIF(世界上体积最小的合法图片),流量成本趋近于零 |
display: none | 肉眼不可见、不参与布局 |
| 请求失败无所谓 | img 的 onerror 不处理也不会抛异常,完全不影响业务代码 |
2.3 优点
- 兼容性无敌:ES5 时代甚至 IE6 都能跑,
45.js里Object.keys、var、字符串拼接的写法就是佐证 - 零跨域烦恼:不需要任何服务器配合
- 实现简单:几行代码搞定
- 天然异步:不阻塞页面
2.4 致命缺陷:页面卸载时丢数据
这是图片打点最痛的问题。浏览器在页面卸载(unload)时,会取消一切未完成的请求:
用户点击 <a href="https://other.com"> 跳转
↓
unload 事件里创建 img,src 已赋值
↓
浏览器开始建立 TCP 连接……(请求"在路上")
↓
页面销毁 → 浏览器取消请求 → 数据永远没到服务器 ❌
请求"已发出"≠"已送达"。网络稍慢一点,卸载时刻的埋点就全丢了。而"用户离开页面"恰恰是埋点最想记录的事件。
2.5 史前补救 Hack
在 sendBeacon 出现之前,工程师们发明了各种丑陋的补丁:
// Hack 1: 同步 XHR —— 阻塞页面卸载直到请求完成(卡死体验,已被废弃)
window.addEventListener('unload', function () {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/collect', false) // false = 同步
xhr.send(data)
})
// Hack 2: 拦截跳转 —— 先发请求,等几百毫秒再手动跳转(牺牲体验换送达率)
document.querySelector('a').addEventListener('click', function (e) {
e.preventDefault()
new Image().src = '/collect?click=btn'
setTimeout(() => { location.href = this.href }, 300) // 等 img 请求发出
})
45.js 中那个 setTimeout(800ms) + new Date() - o <= 830 的判断,本质也是同一个时代的技术:用定时器误差探测"页面是否已被快应用遮挡"。
另一个固有缺陷:GET 请求 + URL 传参有长度上限(IE 约 2083 字符,现代浏览器 2MB+,但中间的代理/服务器常卡在 8KB),传不了大数据。
三、新方式:navigator.sendBeacon()
3.1 设计初衷
2014 年 W3C 在 Beacon 规范中明确提出目标:
让代码在卸载页面时,把数据异步地、可靠地交给用户代理(浏览器),即使页面已经关闭,浏览器也负责把请求真正发出去。
一句话:专门为"页面将死"场景设计的 fire-and-forget 上报 API。
3.2 用法
const url = 'https://tongji.example.net/collect'
const data = JSON.stringify({
event: 'page_leave',
page: '/product/123',
stayTime: 45000,
})
const queued = navigator.sendBeacon(url, data) // 返回 boolean
if (!queued) {
// 浏览器拒绝入队(比如超出大小限制),走降级方案
}
3.3 参数详解
navigator.sendBeacon(url, data?)
| 参数 | 说明 |
|---|---|
url | 上报地址,只能是 POST 请求,没有 GET 选项 |
data | 可选。string / Blob / TypedArray / ArrayBuffer / FormData / URLSearchParams |
data 类型决定了请求的 Content-Type:
| 传入类型 | Content-Type |
|---|---|
| 字符串 | text/plain;charset=UTF-8 |
new Blob([json], {type:'application/json'}) | application/json |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 |
FormData | multipart/form-data |
这些全是 simple request,不触发 CORS 预检,跨域直发——继承了图片打点"不用服务器配 CORS"的遗产。
3.4 核心特性:fire-and-forget
const ok = navigator.sendBeacon(url, data)
- 返回值
true只表示浏览器已接受并加入发送队列,不保证送达 - 没有成功/失败回调,读不到响应,不能自定义请求头
- 调用后立刻返回,不阻塞页面卸载
- 页面销毁后,浏览器作为"遗产执行人"继续把队列里的请求发完
对埋点来说这是完美语义:我们本来就只管发、不管收。
3.5 典型使用场景
// 用户关闭/离开页面时上报停留时长
// 注意:用 pagehide + visibilitychange,不要依赖 unload(移动端不可靠)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/collect', JSON.stringify({
event: 'page_hidden',
ts: Date.now(),
}))
}
})
window.addEventListener('pagehide', () => {
navigator.sendBeacon('/collect', JSON.stringify({
event: 'page_leave',
stayTime: performance.now(),
}))
})
3.6 限制
- 只能 POST —— 想用 GET 传 URL 参数不行了
- 大小有限制,且是"队列总量"而非单次 —— 规范本身未规定数值,由浏览器自行决定;MDN 记载 Chromium/Safari 实现为 64 KiB(65536 字节)的上报队列总上限。注意这不是单条限制:页面上多条 sendBeacon 请求未发送完的部分会共享这个额度(Chrome 实现中在途请求越多,剩余额度越小)。超限调用返回
false,Chromium 中还可能表现为请求发出后一直停在pending状态不真正传输。上报行为数据绰绰有余,传大 JSON 需要拆分 - 读不到响应 —— 需要根据服务器返回值做逻辑的场景不能用
- 不能设置任何自定义 Header —— 无法带
Authorization,鉴权只能靠 Cookie(且跨域时需credentials,配合SameSite政策越来越受限)或把 token 塞进 body
四、对比总表
| 维度 | 1×1 图片打点 | sendBeacon |
|---|---|---|
| 请求方式 | GET,数据在 URL 查询参数 | POST,数据在请求体 |
| 跨域 | 完全无限制 | 无预检、直发(也读不到响应) |
| 页面卸载时送达率 | ❌ 请求会被取消,易丢数据 | ✅ 浏览器保证发完 |
| 数据大小 | URL 长度受限(IE 2KB / 现代浏览器较大但不稳) | 队列总上限 64 KiB(Chromium/Safari) |
| 能否读响应 | 否 | 否 |
| 自定义 Header | 否 | 否 |
| 阻塞页面 | 否 | 否 |
| 兼容性 | 全部浏览器含 IE | IE 全系不支持;现代浏览器 2017 年起全支持 |
| 请求可见性 | DevTools Network 里是"图片" | Network 里 Type 为 ping |
| 适用场景 | 老项目、第三方脚本、只需 GET 的简单打点 | 页面卸载上报、SPA 离开页面、视频播放心跳 |
五、补充:第三条路 fetch keepalive
sendBeacon 功能太精简,于是 Chrome 66+ 又给 fetch 加了 keepalive 选项,能力介于两者之间:
fetch('/collect', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }, // ✅ 能自定义 Header
keepalive: true, // ✅ 页面关闭后仍继续发送
}).then(res => res.json()) // ✅ 能读响应
- 同样受 64KiB body 限制(与 sendBeacon 共享同一套引擎逻辑,Chromium/Safari 都严格执行)
- 可以设置 Header、读取响应、拿到成功/失败
- Firefox 支持极晚:Chrome 66(2018)和 Safari 13(2019)早就支持,Firefox 直到 133(2024 年 11 月) 才默认启用——在此之前 Firefox 用户等同于不支持,降级逻辑必须考虑
选型口诀:简单卸载上报用 sendBeacon;需要带 Header 或读响应的保活请求用 fetch keepalive;两者都不支持的老环境用图片打点降级。
六、实战:生产可用的通用封装
现代项目推荐 sendBeacon 优先 + 图片打点降级 的组合:
/**
* 通用埋点上报
* @param {string} url 上报地址
* @param {Object} params 数据对象
*/
function report(url, params = {}) {
const payload = JSON.stringify(params)
// 优先 sendBeacon:卸载不丢
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' })
if (navigator.sendBeacon(url, blob)) return
// 入队失败(超 64KB 等)→ 降级
}
// 降级:fetch keepalive
if (window.fetch && 'keepalive' in new Request('/')) {
fetch(url, {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {})
return
}
// 兜底:1×1 图片打点(GET)
const query = Object.keys(params)
.map(k => `${k}=${encodeURIComponent(params[k])}`)
.join('&')
const img = document.createElement('img')
img.src = `${url}?${query}`
img.style.display = 'none'
document.body.appendChild(img)
}
window.addEventListener('pagehide', () => {
report('/collect', {
event: 'page_leave',
path: location.pathname,
stayTime: Math.round(performance.now()),
})
})
Vue3 SPA 中的路由 PV 上报
SPA 不会触发真正的页面卸载,PV 要挂在路由钩子上:
// composables/useTracker.js
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
export function useTracker() {
const router = useRouter()
const start = Date.now()
const stop = router.afterEach((to) => {
report('/collect', {
event: 'pv',
path: to.fullPath,
ref: from?.fullPath,
})
})
onUnmounted(stop)
// 组件卸载/页面隐藏时上报停留时长
const onHide = () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon(
'/collect',
JSON.stringify({ event: 'stay', path: location.pathname, ms: Date.now() - start })
)
}
}
onMounted(() => document.addEventListener('visibilitychange', onHide))
onUnmounted(() => document.removeEventListener('visibilitychange', onHide))
}
七、兼容性与最佳实践
7.1 兼容性(截至 2024)
| API | Chrome | Firefox | Safari | IE |
|---|---|---|---|---|
| img 打点 | ✅ 全部 | ✅ 全部 | ✅ 全部 | ✅ 全部 |
| sendBeacon | 39+(2014.11) | 31+ | 11.1+ | ❌ |
| fetch keepalive | 66+(2018) | 133+(2024.11) | 13+ | ❌ |
fetch keepalive的 Firefox 支持是 2024 年底才落地的(Bug 1871373 → 默认启用 Bug 1923044),大量存量 Firefox 用户并不支持,这也是为什么降级链中它只能排在sendBeacon之后而不能替代它。
7.2 最佳实践清单
- 卸载上报监听
pagehide/visibilitychange,别用unload—— 移动端浏览器普遍不触发unload,且它会禁用 BFCache(后退缓存) - 上报别用
async/await链 —— 卸载时刻没有"下一帧"让你继续执行 - 数据控制在 64 KiB 内(注意是多条上报共享的队列总量,不是单条),大列表分批发
- 鉴权用 Cookie(统计服务与主站同域部署)或一次性 token 塞 body
- 上报失败静默降级,埋点永远不能影响业务
- 第三方脚本场景(像
45.js那样要嵌入别人页面)仍首选图片打点——它对宿主页面侵入最小、无兼容性风险
八、总结
- 图片打点是 CORS 出现前的伟大发明:跨域无限制、全兼容、实现仅几行,但页面卸载丢数据 + URL 长度限制是硬伤
- sendBeacon 为卸载场景而生:fire-and-forget、POST body、浏览器保证送达,但只能 POST、无 Header、无响应、64KB 上限
- fetch keepalive 是折中:可配 Header、可读响应、卸载保活
- 生产实践:sendBeacon → fetch keepalive → img 三级降级,卸载监听
pagehide/visibilitychange
从 45.js 这个化石级脚本里,能看到整整一代前端工程师在浏览器限制下与"数据丢失"搏斗的智慧;而 sendBeacon 的诞生说明:当某种 hack 成为全行业的痛,它终将变成标准 API。