跳到主要内容
预计阅读 77 分钟

可观测性篇 | 业务埋点

前言

前两章我们分别聊了错误监控(页面有没有 bug)和性能监控(页面快不快)。这一章我们进入可观测性的第三个维度——业务埋点(页面有没有人用、怎么用的)。

性能监控和错误监控解决的是”技术质量”问题,而业务埋点解决的是”产品价值”问题。你的新功能上线了,有多少人用?用户走到转化漏斗的哪一步流失了?A 方案和 B 方案哪个点击率更高?这些问题的答案,全靠埋点数据。

在很多公司,埋点工作由前端承担。你可能觉得”埋点不就是发个请求吗?有什么难的?“——但当你面对一个百万日活的产品、几千个埋点事件、多端多平台的数据一致性要求、以及产品经理不断变化的数据需求时,你会发现埋点是一个需要体系化思考的工程问题

面试中,埋点相关的问题通常出现在”项目经验”环节。面试官想知道的不是”你会不会写 sendBeacon”,而是你对埋点方案设计、数据上报优化、埋点治理有没有系统性的思考。

本章,我们从埋点类型、实现方式、曝光检测、数据结构设计、上报优化、A/B 实验六个维度,把业务埋点的知识体系讲清楚。


诊断自测

Q1:代码埋点、可视化埋点和无痕埋点分别是什么?各自的优缺点是什么?

点击查看答案

代码埋点:开发者在代码中手动调用埋点 SDK,明确指定事件名和参数。优点是灵活、精确、可以携带丰富的业务参数;缺点是需要开发介入,迭代成本高,遗漏和错误难以避免。

可视化埋点:通过可视化界面(类似 Chrome 插件),运营或产品人员在页面上”圈选”元素来配置埋点,不需要改代码。优点是非技术人员也能操作,上线快;缺点是只能采集点击等简单行为,无法携带复杂的业务参数,元素定位可能因页面改版而失效。

无痕埋点(全埋点):自动采集页面上所有用户行为(每次点击、每次曝光、每次滚动),不需要手动配置。优点是数据最全、不会遗漏;缺点是数据量巨大、很多数据没有业务含义、带宽和存储成本高、数据清洗困难。

实际项目中通常是代码埋点为主、无痕埋点兜底、可视化埋点补充的组合策略。

Q2:前端怎么检测一个元素是否进入了用户的可视区域(即”曝光”)?用什么 API?

点击查看答案

使用 IntersectionObserver API。它可以异步观察目标元素与视口(或指定祖先元素)的交叉状态。当元素进入或离开视口时,会触发回调。相比传统的 scroll 事件 + getBoundingClientRect() 方案,IntersectionObserver 的优势在于:(1)由浏览器原生实现,性能好,不在主线程执行几何计算;(2)可以设置阈值(比如元素 50% 可见才算曝光);(3)支持批量观察多个元素。曝光埋点的标准做法就是用 IntersectionObserver 监听目标元素,当交叉比例超过阈值时触发埋点上报。

Q3:为什么埋点数据不能每触发一次就立即发送一个请求?有什么优化策略?

点击查看答案

立即发送会导致大量的网络请求,影响页面性能和用户体验,也会给服务端带来巨大压力。优化策略包括:(1)批量上报——把多个埋点事件攒在缓冲区,达到一定数量或间隔后一次性发送;(2)采样——对高频事件(如滚动、曝光)进行采样,只上报一部分;(3)sendBeacon——在页面卸载时用 sendBeacon 发送剩余的缓冲数据,确保不丢失;(4)空闲上报——利用 requestIdleCallback 在浏览器空闲时上报,避免影响用户交互。


一、埋点类型:三种流派

1.1 代码埋点

代码埋点是最传统、也是最灵活的方式。开发者在业务代码中手动调用埋点函数,精确控制在什么时机、上报什么数据。

// 典型的代码埋点
tracker.track('button_click', {
  button_name: 'submit_order',
  page: 'checkout',
  order_amount: 299.00,
  sku_count: 3
});

优点:

  • 精确:你完全控制埋点时机和上报参数
  • 灵活:可以携带任意复杂的业务数据(金额、商品 ID、用户状态等)
  • 可回溯:埋点代码在版本控制中,可以追踪什么时候加的、谁加的

缺点:

  • 开发成本高:每个埋点都需要写代码、提测、上线
  • 容易遗漏:需求评审时没提到的埋点就不会有
  • 维护困难:随着产品迭代,旧埋点可能失效但无人清理

1.2 可视化埋点

可视化埋点的核心思路是:把埋点配置从代码中抽离出来,通过可视化界面配置。

工作流程大致是这样的:

  1. 提供一个类似 Chrome 插件的”圈选工具”
  2. 产品或运营人员在页面上选中一个按钮,配置事件名
  3. 圈选工具生成该元素的唯一标识(通常是 CSS 选择器路径或 XPath)
  4. 配置信息存储在服务端
  5. 前端 SDK 在页面加载时拉取配置,根据选择器自动监听对应元素的事件
// 可视化埋点的 SDK 内部逻辑(简化版)
async function initVisualTracker() {
  // 1. 从服务端拉取当前页面的埋点配置
  const configs = await fetch('/api/tracker/configs?page=' + location.pathname);

  // 2. 根据配置自动绑定事件
  for (const config of configs) {
    const elements = document.querySelectorAll(config.selector);
    elements.forEach(el => {
      el.addEventListener(config.eventType, () => {
        tracker.track(config.eventName, {
          element: config.selector,
          page: location.pathname
        });
      });
    });
  }
}

优点:

  • 非技术人员可以自助配置,不需要等排期
  • 上线快,不需要发版

缺点:

  • 元素定位脆弱:如果页面结构变了(比如套了一层 div),选择器可能失效
  • 只能采集简单行为:点击、聚焦等。无法获取输入内容、业务参数
  • 无法追踪动态内容:列表项、弹窗内的元素等
  • 配置难以版本管理:谁改了什么配置,很难追溯

1.3 无痕埋点(全埋点)

无痕埋点的理念是”先采集一切,后续再分析”——SDK 自动捕获页面上所有的用户行为。

// 无痕埋点 SDK 的核心逻辑(简化版)
function initAutoTracker() {
  // 采集所有点击
  document.addEventListener('click', (event) => {
    const target = event.target;
    tracker.track('auto_click', {
      tag: target.tagName,
      id: target.id,
      className: target.className?.toString(),
      text: target.textContent?.substring(0, 50),
      xpath: getXPath(target),
      page: location.pathname,
      position: { x: event.clientX, y: event.clientY }
    });
  }, true);

  // 采集页面浏览
  trackPageView();

  // 采集所有表单提交
  document.addEventListener('submit', (event) => {
    tracker.track('auto_form_submit', {
      formId: event.target.id,
      formAction: event.target.action
    });
  }, true);
}

function getXPath(element) {
  const parts = [];
  while (element && element.nodeType === Node.ELEMENT_NODE) {
    let index = 0;
    let sibling = element.previousElementSibling;
    while (sibling) {
      if (sibling.tagName === element.tagName) index++;
      sibling = sibling.previousElementSibling;
    }
    parts.unshift(`${element.tagName}[${index + 1}]`);
    element = element.parentElement;
  }
  return '/' + parts.join('/');
}

优点:

  • 不会遗漏:所有行为都被记录
  • 回溯分析:上线后才发现需要某个数据,也能从历史数据中找到

缺点:

  • 数据量巨大:带宽和存储成本高
  • 噪音多:大量无意义的点击(比如用户误触)混在有价值的数据中
  • 缺乏业务语义:只知道”用户点了某个 div”,不知道”用户加入了购物车”
  • 隐私风险:可能采集到敏感信息(输入框内容等)

实际策略:组合使用

在真实项目中,三种方式通常是混合使用的:

方式使用场景占比
代码埋点核心业务事件(下单、支付、注册)主力
无痕埋点兜底,保证基础的 PV/UV 和点击数据补充
可视化埋点运营活动页、临时的数据需求补充

二、代码埋点的实现:声明式 vs 命令式

代码埋点是最常用的方式,但”怎么写”也有讲究。主要有两种风格。

2.1 命令式埋点

直接在事件处理函数中调用埋点方法:

function ProductCard({ product }) {
  const handleClick = () => {
    // 业务逻辑
    navigateToDetail(product.id);

    // 埋点
    tracker.track('product_click', {
      product_id: product.id,
      product_name: product.name,
      price: product.price,
      position: product.position
    });
  };

  return <div onClick={handleClick}>{product.name}</div>;
}

问题: 业务逻辑和埋点逻辑耦合在一起。当组件中有大量埋点时,代码会变得混乱。修改业务逻辑时可能不小心影响到埋点,反之亦然。

2.2 声明式埋点

把埋点信息”声明”在元素上,由统一的逻辑来处理上报:

// 方式一:自定义属性 + 全局事件代理
function ProductCard({ product }) {
  return (
    <div
      data-track-event="product_click"
      data-track-product-id={product.id}
      data-track-product-name={product.name}
      data-track-price={product.price}
      onClick={() => navigateToDetail(product.id)}
    >
      {product.name}
    </div>
  );
}

// 全局事件代理:统一处理所有带 data-track-event 的元素
document.addEventListener('click', (e) => {
  const target = e.target.closest('[data-track-event]');
  if (!target) return;

  const dataset = target.dataset;
  const eventName = dataset.trackEvent;

  // 收集所有 data-track-* 属性作为参数
  const params = {};
  for (const key in dataset) {
    if (key.startsWith('track') && key !== 'trackEvent') {
      const paramName = key.replace('track', '').replace(/^[A-Z]/, c => c.toLowerCase());
      params[paramName] = dataset[key];
    }
  }

  tracker.track(eventName, params);
}, true);
// 方式二:React 中用自定义 Hook 或高阶组件
function useTrack(eventName, params) {
  return useCallback(() => {
    tracker.track(eventName, params);
  }, [eventName, params]);
}

function ProductCard({ product }) {
  const trackClick = useTrack('product_click', {
    product_id: product.id,
    product_name: product.name,
    price: product.price
  });

  return (
    <div onClick={() => {
      trackClick();
      navigateToDetail(product.id);
    }}>
      {product.name}
    </div>
  );
}
// 方式三:React 指令式组件
function Tracker({ event, params, children }) {
  const handleClick = () => {
    tracker.track(event, params);
  };

  return React.cloneElement(children, {
    onClick: (...args) => {
      handleClick();
      children.props.onClick?.(...args);
    }
  });
}

// 使用
<Tracker event="product_click" params={{ product_id: 123 }}>
  <ProductCard product={product} />
</Tracker>

声明式 vs 命令式的对比:

维度命令式声明式
灵活性高(任意逻辑)中(受限于声明方式)
业务耦合高(混在业务代码中)低(分离)
维护性低(散落在各处)高(集中或结构化)
适用场景复杂的条件埋点简单的点击/曝光埋点

实际项目中,简单的点击/曝光用声明式,复杂的业务逻辑埋点用命令式——两者结合是最常见的做法。


三、曝光检测:IntersectionObserver

“曝光”是指一个内容元素进入用户的可视区域。曝光埋点是电商、信息流、广告等场景的核心数据需求——“这个商品卡片有多少人看到了”。

3.1 传统方案的问题

在 IntersectionObserver 出现之前,曝光检测通常用 scroll 事件 + getBoundingClientRect()

// ❌ 性能差的旧方案
window.addEventListener('scroll', () => {
  const elements = document.querySelectorAll('[data-exposure]');
  elements.forEach(el => {
    const rect = el.getBoundingClientRect();
    const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
    if (isVisible) {
      trackExposure(el);
    }
  });
});

问题:

  • scroll 事件触发频率极高(每秒可能几十次),需要节流
  • getBoundingClientRect() 会触发强制同步布局(Forced Reflow),在 scroll handler 中大量调用会严重影响滚动流畅度
  • 需要手动管理”已曝光”状态,避免重复上报

3.2 IntersectionObserver 方案

class ExposureTracker {
  constructor(options = {}) {
    this.threshold = options.threshold || 0.5; // 元素 50% 可见才算曝光
    this.minDuration = options.minDuration || 500; // 至少停留 500ms
    this.exposedSet = new WeakSet(); // 已曝光的元素
    this.timers = new WeakMap(); // 元素的停留计时器

    this.observer = new IntersectionObserver(
      (entries) => this._handleIntersection(entries),
      {
        threshold: this.threshold,
        // rootMargin 可以提前/延后触发
        // 比如 '100px' 表示元素还没进入视口 100px 就开始观测
      }
    );
  }

  // 开始观测一个元素
  observe(element) {
    if (this.exposedSet.has(element)) return; // 已曝光的不再观测
    this.observer.observe(element);
  }

  // 停止观测
  unobserve(element) {
    this.observer.unobserve(element);
    const timer = this.timers.get(element);
    if (timer) clearTimeout(timer);
  }

  _handleIntersection(entries) {
    for (const entry of entries) {
      const el = entry.target;

      if (entry.isIntersecting && entry.intersectionRatio >= this.threshold) {
        // 元素进入视口,开始计时
        const timer = setTimeout(() => {
          if (!this.exposedSet.has(el)) {
            this.exposedSet.add(el);
            this._reportExposure(el);
            this.observer.unobserve(el); // 曝光后停止观测
          }
        }, this.minDuration);
        this.timers.set(el, timer);
      } else {
        // 元素离开视口,取消计时
        const timer = this.timers.get(el);
        if (timer) {
          clearTimeout(timer);
          this.timers.delete(el);
        }
      }
    }
  }

  _reportExposure(element) {
    const data = element.dataset;
    tracker.track('exposure', {
      item_id: data.itemId,
      item_type: data.itemType,
      position: data.position,
      page: location.pathname
    });
  }

  // 销毁
  destroy() {
    this.observer.disconnect();
  }
}

在 React 中使用:

function useExposure(ref, trackData) {
  const hasExposed = useRef(false);

  useEffect(() => {
    const element = ref.current;
    if (!element || hasExposed.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
          hasExposed.current = true;
          tracker.track('exposure', trackData);
          observer.disconnect();
        }
      },
      { threshold: 0.5 }
    );

    observer.observe(element);

    return () => observer.disconnect();
  }, [ref, trackData]);
}

// 使用
function ProductCard({ product, position }) {
  const cardRef = useRef(null);

  useExposure(cardRef, {
    item_id: product.id,
    item_type: 'product',
    position
  });

  return <div ref={cardRef}>{product.name}</div>;
}

3.3 曝光的有效性判定

什么算”有效曝光”?不同业务有不同标准:

  • 面积阈值:元素至少 50% 面积可见(广告行业标准)
  • 时间阈值:元素在视口内停留至少 500ms~1s(避免快速滚动的无效曝光)
  • 去重:同一个元素在同一页面生命周期内只算一次曝光
  • 可见性:如果页面被切到后台(document.hidden === true),不算曝光
// 完整的有效性判定
function isValidExposure(entry) {
  return (
    entry.isIntersecting &&
    entry.intersectionRatio >= 0.5 &&  // 面积阈值
    !document.hidden                    // 页面可见
  );
}

四、埋点数据结构设计

好的数据结构是数据分析的基础。一个设计良好的埋点事件应该包含以下信息:

4.1 标准化的事件结构

{
  // --- 事件标识 ---
  event_name: 'product_click',           // 事件名
  event_id: 'evt_a1b2c3d4',             // 事件唯一 ID(用于去重)
  timestamp: 1710000000000,              // 事件发生时间戳

  // --- 用户标识 ---
  user_id: 'u_123456',                  // 登录用户 ID
  device_id: 'did_abcdef',              // 设备 ID(匿名用户追踪)
  session_id: 'sess_xyz789',            // 会话 ID

  // --- 页面上下文 ---
  page_url: '/products/list',           // 页面 URL
  page_title: '商品列表',               // 页面标题
  referrer: '/home',                    // 来源页
  page_session_id: 'ps_abc',           // 页面级会话(SPA 路由切换时更新)

  // --- 设备信息 ---
  platform: 'web',                      // 平台
  os: 'iOS 17.2',                       // 操作系统
  browser: 'Chrome 121',                // 浏览器
  screen_resolution: '1920x1080',       // 屏幕分辨率
  network_type: '4g',                   // 网络类型

  // --- 业务参数 ---
  params: {
    product_id: 'p_001',
    product_name: 'iPhone 16',
    price: 7999,
    position: 3,
    source: 'recommend'
  },

  // --- SDK 信息 ---
  sdk_version: '2.1.0',
  app_version: '1.5.0'
}

4.2 设计原则

1. 事件命名规范化

// ✅ 好的命名:动词_名词,下划线分隔
product_click
order_submit
page_view
banner_exposure

// ❌ 不好的命名
click              // 太模糊
productClick       // 驼峰(和后端 SQL 不友好)
banner曝光          // 中英混杂
userClickProductDetailPageBuyButton  // 太长了

2. 参数设计考虑分析需求

埋点参数不是越多越好,而是要围绕分析需求来设计。在设计埋点方案时,先问自己:

  • 这个数据要回答什么问题?
  • 分析师会用什么维度来拆解这个数据?
  • 这个参数是否有枚举值?值域是什么?
// ✅ 好的参数设计——有明确的分析用途
tracker.track('add_to_cart', {
  product_id: 'p_001',        // 什么商品被加入购物车
  category: 'electronics',     // 品类维度分析
  price: 7999,                 // 客单价分析
  source: 'search_result',     // 流量来源分析
  position: 3,                 // 位置对转化率的影响
  is_on_sale: true             // 促销对转化的影响
});

// ❌ 不好的参数设计——堆砌无用信息
tracker.track('add_to_cart', {
  product_id: 'p_001',
  product_name: 'iPhone 16',  // 产品库可以关联,不需要在埋点里传
  product_description: '...',  // 绝对不需要
  product_image_url: '...',    // 和分析无关
  timestamp: Date.now()        // SDK 层面自动添加即可
});

3. 用户标识的层级设计

device_id    ← 设备维度(跨 session 持久化,存 localStorage)
session_id   ← 会话维度(30 分钟无操作过期,或关闭浏览器后失效)
user_id      ← 用户维度(登录后才有)

这三层标识构成了用户分析的基础:

  • device_id:追踪匿名用户的行为(比如注册转化率分析)
  • session_id:分析单次访问的行为路径
  • user_id:跨设备、跨 session 关联同一个用户
// device_id 的生成和持久化
function getDeviceId() {
  let deviceId = localStorage.getItem('_device_id');
  if (!deviceId) {
    deviceId = 'did_' + crypto.randomUUID();
    localStorage.setItem('_device_id', deviceId);
  }
  return deviceId;
}

// session_id 的生成和管理
function getSessionId() {
  const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 分钟
  const now = Date.now();

  let session = JSON.parse(sessionStorage.getItem('_session') || 'null');

  if (!session || now - session.lastActive > SESSION_TIMEOUT) {
    session = {
      id: 'sess_' + crypto.randomUUID(),
      lastActive: now
    };
  } else {
    session.lastActive = now;
  }

  sessionStorage.setItem('_session', JSON.stringify(session));
  return session.id;
}

五、数据上报优化

在大流量产品中,埋点上报的优化至关重要。每天几亿条埋点数据,如果上报策略不合理,要么丢数据,要么影响用户体验。

5.1 批量上报

不要每产生一个事件就发一个请求——攒一批再发

class EventBuffer {
  constructor(options = {}) {
    this.buffer = [];
    this.maxSize = options.maxSize || 20;         // 最多攒 20 条
    this.flushInterval = options.flushInterval || 5000; // 最多攒 5 秒
    this.reportUrl = options.reportUrl;

    this._startTimer();
  }

  add(event) {
    this.buffer.push(event);

    // 缓冲区满,立即发送
    if (this.buffer.length >= this.maxSize) {
      this.flush();
    }
  }

  flush() {
    if (this.buffer.length === 0) return;

    const events = this.buffer.splice(0);
    this._send(events);
  }

  _send(events) {
    const data = JSON.stringify(events);

    if (navigator.sendBeacon) {
      // sendBeacon 适合小批量数据
      const success = navigator.sendBeacon(this.reportUrl, data);
      if (!success) {
        // sendBeacon 可能因为数据太大而失败,降级为 fetch
        this._sendViaFetch(data);
      }
    } else {
      this._sendViaFetch(data);
    }
  }

  _sendViaFetch(data) {
    fetch(this.reportUrl, {
      method: 'POST',
      body: data,
      headers: { 'Content-Type': 'application/json' },
      keepalive: true // 确保页面卸载时也能发送
    }).catch(() => {
      // 上报失败,可以存 localStorage 下次重试
    });
  }

  _startTimer() {
    this._timer = setInterval(() => this.flush(), this.flushInterval);
  }

  destroy() {
    clearInterval(this._timer);
    this.flush(); // 销毁前把剩余数据发出去
  }
}

5.2 页面卸载时的上报

这是最容易丢数据的环节。用户关闭标签页、跳转到其他页面时,缓冲区里可能还有未发送的事件。

// visibilitychange 比 unload/beforeunload 更可靠(尤其是移动端)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    eventBuffer.flush();
  }
});

// 兜底:pagehide 事件
window.addEventListener('pagehide', () => {
  eventBuffer.flush();
});

5.3 采样

对于超高频的事件,100% 上报是不现实的。需要根据事件类型做不同的采样策略:

const SAMPLE_RATES = {
  page_view: 1.0,         // 页面浏览:100% 上报
  button_click: 1.0,      // 按钮点击:100% 上报
  exposure: 0.5,           // 曝光:50% 上报
  scroll_depth: 0.1,       // 滚动深度:10% 上报
  mouse_heatmap: 0.01,     // 热力图数据:1% 上报
};

function shouldReport(eventName) {
  const rate = SAMPLE_RATES[eventName] ?? 1.0;
  return Math.random() < rate;
}

// 在上报前检查
function track(eventName, params) {
  if (!shouldReport(eventName)) return;
  eventBuffer.add({ event_name: eventName, params, ... });
}

采样的注意事项:

  • 采样决策应该在用户/会话维度做,而不是事件维度。同一个用户的同一次访问,要么全采要么全不采,否则分析路径行为时数据会不连续
  • 不同事件类型可以有不同的采样率
  • 采样率应该可动态配置(通过服务端下发),不要硬编码
// 更合理的采样:基于用户维度
const userSampleSeed = hashCode(getDeviceId()) % 100; // 0-99

function shouldSampleUser(rate) {
  return userSampleSeed < rate * 100;
}

5.4 失败重试与本地缓存

网络不稳定时(地铁、电梯),埋点可能上报失败。可以把失败的数据存在 localStorage 中,下次访问时重试:

class RetryQueue {
  constructor() {
    this.storageKey = '_tracker_retry_queue';
  }

  save(events) {
    try {
      const existing = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
      // 限制队列大小,避免 localStorage 爆满
      const merged = [...existing, ...events].slice(-100);
      localStorage.setItem(this.storageKey, JSON.stringify(merged));
    } catch (e) {
      // localStorage 可能满了或不可用,静默失败
    }
  }

  consume() {
    try {
      const events = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
      if (events.length > 0) {
        localStorage.removeItem(this.storageKey);
        return events;
      }
    } catch (e) {}
    return [];
  }
}

// 页面加载时,重试之前失败的事件
const retryQueue = new RetryQueue();
const failedEvents = retryQueue.consume();
if (failedEvents.length > 0) {
  eventBuffer._send(failedEvents);
}

六、A/B 实验与埋点

A/B 实验(也叫分桶实验、对照实验)是数据驱动产品决策的核心方法。而埋点是 A/B 实验收集数据的基础设施

6.1 什么是 A/B 实验

A/B 实验的核心思路:

  1. 把用户随机分成两组(或多组)
  2. 对照组看到旧版本(A),实验组看到新版本(B)
  3. 通过埋点数据对比两组的核心指标(转化率、点击率、留存率等)
  4. 如果实验组指标显著优于对照组,就全量上线新版本
用户池
 ├── 对照组(50%)── 看到原版按钮(蓝色)── 埋点记录点击率 12%
 └── 实验组(50%)── 看到新版按钮(红色)── 埋点记录点击率 15%
     → 红色按钮点击率更高,上线红色方案

6.2 埋点在 A/B 实验中的角色

每条埋点事件都需要携带实验信息:

tracker.track('button_click', {
  button_name: 'checkout',
  // 实验信息
  experiments: {
    'exp_checkout_button_color': 'red',   // 实验名 → 分组
    'exp_price_display': 'variant_b'       // 用户可能同时参与多个实验
  }
});

这样数据分析时才能按实验分组对比指标:

-- 分析实验结果(伪 SQL)
SELECT
  experiment_group,
  COUNT(DISTINCT user_id) AS users,
  SUM(CASE WHEN event = 'order_submit' THEN 1 ELSE 0 END) AS conversions,
  conversions / users AS conversion_rate
FROM events
WHERE experiment_name = 'exp_checkout_button_color'
GROUP BY experiment_group;

6.3 实验分流与埋点的配合

// 1. 页面加载时获取实验配置
const experiments = await fetchExperimentConfig(userId);
// 返回: { 'exp_checkout_color': 'red', 'exp_layout': 'variant_a' }

// 2. 将实验信息注入到全局埋点上下文中
tracker.setGlobalParams({
  experiments: experiments
});

// 3. 后续所有埋点自动携带实验信息
tracker.track('page_view', { page: 'checkout' });
// 上报数据中自动包含 experiments 字段

// 4. 根据实验配置渲染不同的 UI
function CheckoutButton() {
  const buttonColor = experiments['exp_checkout_color']; // 'red' or 'blue'

  return (
    <button
      style={{ backgroundColor: buttonColor }}
      onClick={() => {
        tracker.track('checkout_click', { color: buttonColor });
        submitOrder();
      }}
    >
      结算
    </button>
  );
}

6.4 注意事项

  • 分流一致性:同一个用户在同一个实验中必须始终看到同一个版本(通过 userId 哈希确定分组,而不是随机)
  • 实验互斥与正交:多个实验同时进行时,需要确保实验之间不互相干扰
  • 最小样本量:在收集到足够的数据之前不要下结论(统计显著性)
  • 埋点一致性:对照组和实验组必须上报相同的埋点事件,只是 UI 不同。不能实验组多了几个埋点,否则数据不可比

常见误区

误区一:“埋点越多越好,反正存储不值钱”

这是数据治理的大忌。过多的埋点不仅浪费存储和带宽,还会带来严重的维护成本——没人记得这几千个事件分别是干什么的,分析师在数据海洋中找不到需要的指标。正确的做法是:围绕业务目标设计埋点,每个事件都要有明确的分析用途。 定期清理不再使用的埋点,建立埋点文档和审核流程。

误区二:“无痕埋点可以替代代码埋点”

无痕埋点(全埋点)看起来很美——“采集一切,按需分析”。但实际上,无痕埋点只能采集行为数据(点击了什么元素),无法采集业务数据(加了什么商品、选了什么规格、用了什么优惠券)。而真正有价值的分析往往依赖业务数据。无痕埋点适合做兜底和补充,但核心业务事件还是得靠代码埋点。

误区三:“曝光就是元素出现在 DOM 里”

DOM 中存在 ≠ 用户看到了。元素可能在视口之外、可能被其他元素遮挡、可能 display: none、可能页面在后台标签。有效的曝光必须满足:(1)元素在视口内且可见面积超过阈值;(2)停留时间超过阈值;(3)页面处于前台可见状态。IntersectionObserver 解决了前两个问题,document.visibilityState 解决第三个。

误区四:“sendBeacon 就够了,不需要其他上报方式”

sendBeacon 确实好用,但它有数据大小限制(通常 64KB)。当批量上报的数据量较大时,sendBeacon 可能返回 false 表示发送失败。此外,sendBeacon 只支持 POST、无法获取响应、无法设置自定义 Header。完善的上报方案应该是 sendBeacon 为主、fetch(keepalive: true) 为辅、Image Beacon 做兜底。


小结

本章我们系统梳理了前端业务埋点的完整知识体系。

核心要点

  1. 三种埋点类型:代码埋点(精确灵活,主力)、可视化埋点(非技术人员可配,补充)、无痕埋点(全量采集,兜底)
  2. 声明式 vs 命令式:简单行为用声明式降低耦合,复杂逻辑用命令式保证灵活性
  3. 曝光检测用 IntersectionObserver:性能好、原生支持、配合面积阈值和时间阈值判定有效曝光
  4. 数据结构设计:事件标识 + 用户标识(三层) + 页面上下文 + 设备信息 + 业务参数
  5. 上报优化四板斧:批量上报、sendBeacon + visibilitychange、采样、失败重试
  6. A/B 实验依赖埋点:每条事件携带实验分组信息,保证分流一致性

本章思维导图

前端业务埋点
  • 埋点类型
    • 代码埋点:手动调用,精确灵活,开发成本高
    • 可视化埋点:圈选配置,非技术可用,功能受限
    • 无痕埋点:全量采集,不遗漏,噪音多
    • 实际策略:代码为主 + 无痕兜底 + 可视化补充
  • 代码埋点实现
    • 命令式:在事件回调中直接调用,灵活但耦合
    • 声明式:data 属性 / Hook / 高阶组件,解耦但受限
    • 最佳实践:简单行为声明式,复杂逻辑命令式
  • 曝光检测
    • IntersectionObserver(替代 scroll + getBoundingClientRect)
    • 有效性判定:面积阈值 + 时间阈值 + 页面可见
    • React 中用 useRef + useEffect 封装
  • 数据结构设计
    • 事件标识:event_name + event_id + timestamp
    • 用户标识三层:device_id / session_id / user_id
    • 命名规范:动词_名词,下划线分隔
    • 参数设计:围绕分析需求,不堆砌冗余信息
  • 上报优化
    • 批量上报:缓冲区 + 定时 flush + 满量 flush
    • 页面卸载:sendBeacon + visibilitychange
    • 采样:基于用户维度,不同事件不同采样率
    • 失败重试:localStorage 缓存 + 下次访问重发
  • A/B 实验
    • 原理:随机分流 → 埋点对比 → 统计显著性
    • 埋点要求:每条事件携带实验分组信息
    • 注意:分流一致性、实验互斥、最小样本量

练习挑战

第一题 ⭐:埋点方案选择

以下场景分别适合用哪种埋点方式(代码埋点 / 可视化埋点 / 无痕埋点)?说出你的理由。

  1. 用户下单时,需要记录订单金额、商品列表、优惠券信息
  2. 运营想知道一个新上线的活动页中某个 banner 的点击量
  3. 产品想知道用户在一个新页面上最常点击的区域在哪里
点击查看答案
  1. 代码埋点。因为需要携带丰富的业务参数(订单金额、商品列表、优惠券),这些数据只能在业务代码中获取,可视化埋点和无痕埋点都做不到。
  2. 可视化埋点。这是一个简单的”某个元素的点击量”需求,不需要业务参数,运营自己圈选即可,不用等开发排期。当然用代码埋点也可以,但效率低。
  3. 无痕埋点。产品不知道用户会点哪里,所以需要”先采集一切点击,后续分析”。无痕埋点自动记录所有点击的位置和元素信息,配合热力图工具就能看出最常点击的区域。

第二题 ⭐⭐:实现一个曝光检测 Hook

请用 React + IntersectionObserver 实现一个 useExposure Hook,要求:

  1. 元素 50% 面积可见时触发
  2. 同一元素只触发一次曝光
  3. 支持传入自定义的埋点参数
  4. 组件卸载时清理 observer
点击查看答案
import { useEffect, useRef, useCallback } from 'react';

function useExposure(trackParams, options = {}) {
  const { threshold = 0.5 } = options;
  const ref = useRef(null);
  const hasExposed = useRef(false);
  // 用 ref 存 trackParams 避免 useEffect 频繁重建
  const paramsRef = useRef(trackParams);
  paramsRef.current = trackParams;

  useEffect(() => {
    const element = ref.current;
    if (!element || hasExposed.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (
          entry.isIntersecting &&
          entry.intersectionRatio >= threshold &&
          !document.hidden &&
          !hasExposed.current
        ) {
          hasExposed.current = true;
          tracker.track('exposure', paramsRef.current);
          observer.disconnect();
        }
      },
      { threshold }
    );

    observer.observe(element);

    return () => {
      observer.disconnect();
    };
  }, [threshold]);

  return ref;
}

// 使用示例
function ProductCard({ product, position }) {
  const exposureRef = useExposure({
    item_id: product.id,
    item_type: 'product',
    position,
    page: 'product_list'
  });

  return (
    <div ref={exposureRef} className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <span>{product.price}</span>
    </div>
  );
}

关键设计点:

  • useRef 保存曝光状态(hasExposed),避免重复触发
  • paramsRef 存最新的参数引用,避免 useEffect 因参数变化频繁重建 observer
  • 曝光后立即 observer.disconnect() 释放资源
  • 检查 document.hidden 确保页面在前台
  • 组件卸载时 cleanup 函数中 observer.disconnect()

第三题 ⭐⭐⭐:设计一个完整的埋点 SDK

请设计一个埋点 SDK,要求支持以下功能:

  1. 代码埋点:tracker.track(eventName, params)
  2. 自动采集 PV(页面浏览),支持 SPA 路由切换
  3. 批量上报 + sendBeacon
  4. 全局参数注入(用户信息、实验分组)
  5. 采样控制
点击查看答案
class TrackerSDK {
  constructor(options) {
    this.reportUrl = options.reportUrl;
    this.appId = options.appId;
    this.sampleRate = options.sampleRate ?? 1.0;

    this.globalParams = {};
    this.buffer = [];
    this.maxBufferSize = options.maxBufferSize || 20;
    this.flushInterval = options.flushInterval || 5000;

    // 用户标识
    this.deviceId = this._getDeviceId();
    this.sessionId = this._getSessionId();
    this.userId = null;

    // 采样决策(基于用户维度)
    this._shouldSample = this._hashCode(this.deviceId) % 100 < this.sampleRate * 100;

    this._startFlushTimer();
    this._initPageLeaveHandler();
    this._initAutoPageView();
  }

  // ---- 公开 API ----

  track(eventName, params = {}) {
    if (!this._shouldSample) return;

    const event = {
      event_name: eventName,
      event_id: 'evt_' + crypto.randomUUID(),
      timestamp: Date.now(),

      // 用户标识
      device_id: this.deviceId,
      session_id: this._getSessionId(), // 每次获取时更新 lastActive
      user_id: this.userId,

      // 页面上下文
      page_url: location.pathname + location.search,
      page_title: document.title,
      referrer: document.referrer,

      // 设备信息
      screen: `${screen.width}x${screen.height}`,
      user_agent: navigator.userAgent,
      network: navigator.connection?.effectiveType,

      // 合并参数:全局参数 + 事件参数
      params: { ...this.globalParams, ...params },

      // SDK 信息
      app_id: this.appId,
      sdk_version: '1.0.0'
    };

    this.buffer.push(event);

    if (this.buffer.length >= this.maxBufferSize) {
      this.flush();
    }
  }

  setUser(userId) {
    this.userId = userId;
  }

  setGlobalParams(params) {
    this.globalParams = { ...this.globalParams, ...params };
  }

  // ---- PV 自动采集 ----

  _initAutoPageView() {
    // 首次 PV
    this.track('page_view');

    // SPA 路由变化监听
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = (...args) => {
      originalPushState.apply(history, args);
      this._onRouteChange();
    };

    history.replaceState = (...args) => {
      originalReplaceState.apply(history, args);
      this._onRouteChange();
    };

    window.addEventListener('popstate', () => {
      this._onRouteChange();
    });
  }

  _onRouteChange() {
    // 使用 setTimeout 确保 URL 已更新
    setTimeout(() => {
      this.track('page_view');
    }, 0);
  }

  // ---- 上报 ----

  flush() {
    if (this.buffer.length === 0) return;
    const events = this.buffer.splice(0);
    const data = JSON.stringify(events);

    if (navigator.sendBeacon) {
      const success = navigator.sendBeacon(this.reportUrl, data);
      if (!success) {
        this._fetchReport(data);
      }
    } else {
      this._fetchReport(data);
    }
  }

  _fetchReport(data) {
    fetch(this.reportUrl, {
      method: 'POST',
      body: data,
      headers: { 'Content-Type': 'application/json' },
      keepalive: true
    }).catch(() => {
      // 失败时存 localStorage,下次重试
      try {
        const queue = JSON.parse(localStorage.getItem('_track_retry') || '[]');
        const events = JSON.parse(data);
        const merged = [...queue, ...events].slice(-50);
        localStorage.setItem('_track_retry', JSON.stringify(merged));
      } catch (e) {}
    });
  }

  _initPageLeaveHandler() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });
  }

  _startFlushTimer() {
    setInterval(() => this.flush(), this.flushInterval);

    // 页面加载时重试之前失败的事件
    try {
      const retry = JSON.parse(localStorage.getItem('_track_retry') || '[]');
      if (retry.length > 0) {
        localStorage.removeItem('_track_retry');
        const data = JSON.stringify(retry);
        if (navigator.sendBeacon) {
          navigator.sendBeacon(this.reportUrl, data);
        }
      }
    } catch (e) {}
  }

  // ---- 工具方法 ----

  _getDeviceId() {
    let id = localStorage.getItem('_did');
    if (!id) {
      id = 'did_' + crypto.randomUUID();
      localStorage.setItem('_did', id);
    }
    return id;
  }

  _getSessionId() {
    const TIMEOUT = 30 * 60 * 1000;
    const now = Date.now();
    let sess = JSON.parse(sessionStorage.getItem('_sess') || 'null');

    if (!sess || now - sess.ts > TIMEOUT) {
      sess = { id: 'sess_' + crypto.randomUUID(), ts: now };
    } else {
      sess.ts = now;
    }
    sessionStorage.setItem('_sess', JSON.stringify(sess));
    return sess.id;
  }

  _hashCode(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) - hash) + str.charCodeAt(i);
      hash = hash & hash;
    }
    return Math.abs(hash);
  }
}

// 使用
const tracker = new TrackerSDK({
  reportUrl: '/api/track',
  appId: 'my-app',
  sampleRate: 1.0
});

// 设置用户信息
tracker.setUser('u_123');

// 注入实验分组
tracker.setGlobalParams({
  experiments: { 'exp_new_layout': 'variant_a' }
});

// 业务埋点
tracker.track('add_to_cart', {
  product_id: 'p_001',
  price: 299
});

这个 SDK 涵盖了题目要求的所有功能。生产级 SDK 还需要考虑:事件队列的优先级(高优先级事件立即发送)、数据压缩(gzip)、A/B 实验的分流逻辑、多平台(小程序、RN)适配、SDK 自身的异常隔离等。


自我检测

读完本章后,对照下面的清单检验自己的掌握程度:

  • 能说清楚代码埋点、可视化埋点、无痕埋点的区别和各自的适用场景
  • 能解释声明式埋点和命令式埋点的差异,并给出各自的代码示例
  • 能用 IntersectionObserver 实现曝光检测,并说出有效曝光的判定标准(面积、时间、可见性)
  • 能设计一个标准化的埋点数据结构,包含事件标识、用户标识三层、页面上下文和业务参数
  • 能说出事件命名的最佳实践(动词_名词、下划线分隔)
  • 能实现批量上报 + sendBeacon + visibilitychange 的上报方案
  • 能解释为什么采样应该基于用户维度而不是事件维度
  • 能说出 A/B 实验和埋点的关系,以及埋点事件中需要携带哪些实验信息
  • 能设计失败重试方案(localStorage 缓存 + 下次访问重发)
  • 能在面试中系统地讲述”你们团队的埋点体系是怎么设计的”,覆盖类型选择、数据结构、上报优化、治理策略

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90