技术知识文章集合TECHNICAL ARCHIVE · 807 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBR

前端埋点上报:1×1 图片打点 vs sendBeacon 全面对比

所属分类
Browser
文件格式
Markdown
原始路径
Browser/40-埋点上报对比:图片打点vs-sendBeacon
本文目录8 个章节
  1. 一、什么是埋点上报
  2. 二、老方式:1×1 图片打点
  3. 三、新方式:navigator.sendBeacon()
  4. 四、对比总表
  5. 五、补充:第三条路 fetch keepalive
  6. 六、实战:生产可用的通用封装
  7. 七、兼容性与最佳实践
  8. 八、总结

前端埋点上报:1×1 图片打点 vs sendBeacon 全面对比

Category(分类): Browser Status: 已更新

前端埋点上报是把用户行为数据(PV、点击、停留时长、来源渠道等)从浏览器发送到统计服务器的技术。本文从 45.js 中的真实代码出发,详细对比新老两种主流上报方式。


一、什么是埋点上报

统计服务器和你的网站几乎总是不同域,比如:

你的页面:     https://www.example.com/page.html
统计服务器:   https://tongji.example.net/collect

前端要做的,就是在用户产生行为的那一刻,把数据送出去。注意埋点的核心诉求:

  1. 只管发,不管收 —— 不关心服务器返回什么内容
  2. 跨域无阻碍 —— 不需要统计服务器配合 CORS
  3. 不能影响页面性能 —— 不能阻塞渲染、不能抛错
  4. 页面关闭时也要能发出去 —— 用户关闭标签页、跳转到其他网站的那一刻,恰恰是"离开行为"最有价值的上报时机

围绕这几条诉求,先后诞生了两代方案。


二、老方式: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/fetchimg 没有 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
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8
FormDatamultipart/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 限制

  1. 只能 POST —— 想用 GET 传 URL 参数不行了
  2. 大小有限制,且是"队列总量"而非单次 —— 规范本身未规定数值,由浏览器自行决定;MDN 记载 Chromium/Safari 实现为 64 KiB(65536 字节)的上报队列总上限。注意这不是单条限制:页面上多条 sendBeacon 请求未发送完的部分会共享这个额度(Chrome 实现中在途请求越多,剩余额度越小)。超限调用返回 false,Chromium 中还可能表现为请求发出后一直停在 pending 状态不真正传输。上报行为数据绰绰有余,传大 JSON 需要拆分
  3. 读不到响应 —— 需要根据服务器返回值做逻辑的场景不能用
  4. 不能设置任何自定义 Header —— 无法带 Authorization,鉴权只能靠 Cookie(且跨域时需 credentials,配合 SameSite 政策越来越受限)或把 token 塞进 body

四、对比总表

维度1×1 图片打点sendBeacon
请求方式GET,数据在 URL 查询参数POST,数据在请求体
跨域完全无限制无预检、直发(也读不到响应)
页面卸载时送达率❌ 请求会被取消,易丢数据✅ 浏览器保证发完
数据大小URL 长度受限(IE 2KB / 现代浏览器较大但不稳)队列总上限 64 KiB(Chromium/Safari)
能否读响应否否
自定义 Header否否
阻塞页面否否
兼容性全部浏览器含 IEIE 全系不支持;现代浏览器 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)

APIChromeFirefoxSafariIE
img 打点✅ 全部✅ 全部✅ 全部✅ 全部
sendBeacon39+(2014.11)31+11.1+❌
fetch keepalive66+(2018)133+(2024.11)13+❌

fetch keepalive 的 Firefox 支持是 2024 年底才落地的(Bug 1871373 → 默认启用 Bug 1923044),大量存量 Firefox 用户并不支持,这也是为什么降级链中它只能排在 sendBeacon 之后而不能替代它。

7.2 最佳实践清单

  1. 卸载上报监听 pagehide / visibilitychange,别用 unload —— 移动端浏览器普遍不触发 unload,且它会禁用 BFCache(后退缓存)
  2. 上报别用 async/await 链 —— 卸载时刻没有"下一帧"让你继续执行
  3. 数据控制在 64 KiB 内(注意是多条上报共享的队列总量,不是单条),大列表分批发
  4. 鉴权用 Cookie(统计服务与主站同域部署)或一次性 token 塞 body
  5. 上报失败静默降级,埋点永远不能影响业务
  6. 第三方脚本场景(像 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。

807 DOCUMENTS · 12 COLLECTIONS
ARCHIVE SEARCH807 篇文章