前端埋点上报怎么设计?从点击事件到批量发送

很多项目一开始做埋点时,代码通常很简单:

复制代码
track('button_click', {
  buttonName: 'submit'
});

但真正上线后会发现,埋点不是"调一次接口"这么简单。

实际问题往往包括:

复制代码
页面关闭时数据没发出去
用户连续点击导致重复上报
曝光事件触发太频繁
接口请求太多影响性能
网络异常时数据丢失
字段命名混乱,后期难以分析

所以,一个可维护的前端埋点方案,至少要考虑事件规范、采集时机、批量发送、失败重试和数据去重。


一、先定义事件模型

埋点最怕字段随手写。

例如:

复制代码
track('click', {
  btn: 'start'
});

另一个页面又写成:

复制代码
track('buttonClick', {
  button_name: 'start'
});

后期分析时会非常痛苦。

更推荐先定义统一事件结构:

复制代码
{
  "eventId": "evt_001",
  "eventName": "button_click",
  "timestamp": 1718000000000,
  "page": "/dashboard",
  "userId": "u_10001",
  "sessionId": "s_abc",
  "properties": {
    "buttonName": "start_translation"
  }
}

常见基础字段包括:

字段 说明
eventId 单条事件唯一 ID
eventName 事件名称
timestamp 事件发生时间
page 当前页面
userId 用户 ID,未登录可为空
sessionId 会话 ID
properties 业务自定义参数

这样无论是点击、曝光还是功能使用事件,都可以放进统一结构里。


二、点击埋点:不要散落在业务代码里

最直接的写法是:

复制代码
button.onclick = () => {
  track('button_click', {
    buttonName: 'export'
  });

  exportFile();
};

这种方式能用,但项目变大后,埋点逻辑会散落在大量业务代码中。

更好的方式是通过自定义属性声明:

复制代码
<button
  data-track="button_click"
  data-track-name="export_order"
>
  导出订单
</button>

统一监听:

复制代码
document.addEventListener('click', event => {
  const target = event.target.closest('[data-track]');

  if (!target) return;

  track(target.dataset.track, {
    name: target.dataset.trackName
  });
});

这样做的好处是:

复制代码
业务逻辑和埋点逻辑解耦
页面结构更清晰
新增点击埋点成本较低

三、曝光埋点:使用 IntersectionObserver

曝光埋点常用于统计模块是否被用户看到。

不要使用滚动事件频繁计算元素位置。

更推荐使用 IntersectionObserver

复制代码
const observer = new IntersectionObserver(
  entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const el = entry.target;

        track('module_exposure', {
          moduleName: el.dataset.module
        });

        observer.unobserve(el);
      }
    });
  },
  {
    threshold: 0.5
  }
);

页面元素:

复制代码
<div data-module="pricing_card">
  套餐卡片
</div>

初始化:

复制代码
document
  .querySelectorAll('[data-module]')
  .forEach(el => observer.observe(el));

这里的 threshold: 0.5 表示元素至少 50% 进入视口后才算曝光。

如果一个模块只需要统计一次,触发后可以 unobserve,避免重复上报。


四、为什么需要批量上报?

如果每个事件都立即请求接口:

复制代码
点击一次 → 请求一次
曝光一次 → 请求一次
滚动一次 → 请求一次

请求数量会非常多。

更合理的方式是先放入队列,定时批量发送:

复制代码
const queue = [];

function track(eventName, properties = {}) {
  queue.push({
    eventId: crypto.randomUUID(),
    eventName,
    timestamp: Date.now(),
    page: location.pathname,
    properties
  });

  if (queue.length >= 20) {
    flush();
  }
}

批量发送:

复制代码
async function flush() {
  if (queue.length === 0) return;

  const events = queue.splice(0, queue.length);

  try {
    await fetch('/api/track', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(events)
    });
  } catch (error) {
    queue.unshift(...events);
  }
}

再加一个定时器:

复制代码
setInterval(flush, 5000);

这样可以减少请求次数,也能降低埋点对业务接口的影响。


五、页面关闭时如何减少数据丢失?

用户关闭页面时,普通 fetch 请求可能还没发完,页面就已经卸载。

这时可以使用 navigator.sendBeacon

复制代码
function flushByBeacon() {
  if (queue.length === 0) return;

  const events = queue.splice(0, queue.length);

  const blob = new Blob(
    [JSON.stringify(events)],
    {
      type: 'application/json'
    }
  );

  navigator.sendBeacon('/api/track', blob);
}

监听页面隐藏:

复制代码
window.addEventListener('pagehide', () => {
  flushByBeacon();
});

相比 beforeunloadpagehide 在移动端和浏览器缓存场景中通常更适合。

但要注意,sendBeacon 适合发送少量数据,不要在页面关闭时塞入过大的埋点队列。


六、失败重试与本地缓存

如果网络异常,埋点请求失败,可以临时保存到 localStorage

复制代码
function saveFailedEvents(events) {
  const oldEvents = JSON.parse(
    localStorage.getItem('track_failed') || '[]'
  );

  const merged = oldEvents.concat(events).slice(-200);

  localStorage.setItem(
    'track_failed',
    JSON.stringify(merged)
  );
}

下次页面加载时再尝试发送:

复制代码
function restoreFailedEvents() {
  const events = JSON.parse(
    localStorage.getItem('track_failed') || '[]'
  );

  if (events.length > 0) {
    queue.push(...events);
    localStorage.removeItem('track_failed');
    flush();
  }
}

这里要限制最大数量,例如只保留最近 200 条,避免本地存储无限增长。


七、后端也要做去重

前端失败重试可能导致同一批事件被重复发送。

所以每条事件最好有唯一 eventId

后端可以基于:

复制代码
eventId
userId
eventName
timestamp

做去重处理。

如果数据先进入消息队列,也可以在消费者侧做幂等判断。

不要假设前端只会上报一次。

真实环境中,刷新、重试、网络抖动和多标签页都可能造成重复数据。


八、业务埋点不要只看按钮点击

很多团队只统计点击量,但真正有价值的往往是完整路径。

例如一个实时翻译产品,可以统计:

复制代码
开始翻译
选择语言
开启悬浮字幕
开启语音播报
生成会议总结
导出会议记录
任务异常结束

以**同言翻译(Transync AI)**这类产品为例,如果只看"开始翻译"按钮点击量,很难判断用户实际是否完成了跨语言会议流程。

更有价值的事件链路可能是:

复制代码
start_translation
→ subtitle_enabled
→ voice_playback_enabled
→ meeting_summary_generated

这样才能分析用户在哪一步流失,以及哪些功能真正被使用。


九、埋点字段要避免记录敏感信息

埋点不是日志,也不应该成为隐私数据收集入口。

不要上报:

复制代码
密码
Token
完整手机号
身份证号
会议原文内容
用户输入的完整文本

可以上报结构化状态:

复制代码
{
  "eventName": "meeting_summary_generated",
  "properties": {
    "duration": 1800,
    "languagePair": "en-zh",
    "success": true
  }
}

而不是上报完整会议内容。

埋点的目标是分析行为,不是保存用户数据。


十、埋点检查清单

上线前可以检查:

复制代码
1. 是否有统一事件命名规范
2. 是否包含 eventId
3. 点击埋点是否与业务逻辑解耦
4. 曝光埋点是否避免重复触发
5. 是否支持批量上报
6. 页面关闭时是否使用 sendBeacon
7. 失败事件是否有临时缓存
8. 本地缓存是否有数量上限
9. 后端是否做幂等去重
10. 是否避免采集敏感内容

总结

前端埋点的核心不是"多打几个 track",而是建立一套稳定的数据采集链路。

一个基础方案至少应该包含:

复制代码
统一事件模型
点击和曝光采集
批量发送
页面关闭补发
失败重试
后端去重
敏感信息控制

当埋点体系足够清晰后,数据分析才能真正回答问题:

复制代码
用户用了什么?
在哪一步流失?
哪个功能值得继续优化?

这比单纯统计 PV、UV 更接近产品和工程决策的真实需求。