前端埋点与数据分析

What — 是什么

埋点

前端埋点(Tracking / Instrumentation)是在应用代码中植入数据采集逻辑的技术手段,目的是在用户与页面交互时自动记录行为数据,为后续分析提供原始素材。埋点是前端数据采集的核心手段,是整个数据分析链路的起点。

核心职责:

  • 用户行为采集:点击、滚动、输入、页面跳转等操作
  • 业务事件记录:下单、注册、分享、支付等关键业务动作
  • 性能数据收集:页面加载耗时、接口响应时间、资源体积等
  • 异常信息捕获:JS 错误、接口失败、白屏等故障数据
  • 设备与环境信息:浏览器、操作系统、分辨率、网络状态等

数据分析

数据分析是埋点数据的消费端,通过对采集到的原始数据进行清洗、聚合、建模和可视化,驱动产品决策与业务增长。

分析维度:

维度说明典型指标
流量分析了解用户从哪来、到哪去PV、UV、来源渠道、跳出率
行为分析了解用户在做什么点击率、停留时长、操作路径
转化分析了解用户是否完成目标漏斗转化率、归因分析
留存分析了解用户是否持续回来次日/7日/30日留存率
性能分析了解应用运行是否流畅FCP、LCP、FID、CLS

埋点三大方式

方式原理代表方案
代码埋点开发者在代码中手动调用埋点 SDK 提供的 API神策、GrowingIO SDK
可视化埋点运营通过可视化圈选工具配置埋点规则,无需发版Mixpanel、神策可视化埋点
无痕埋点(全埋点)SDK 全局代理事件,自动采集所有用户行为GrowingIO、Heap

核心概念

概念说明
Event埋点的基本单位,描述”谁在什么时间做了什么事”
PV(Page View)页面浏览量,每次页面加载或路由切换计一次
UV(Unique Visitor)独立访客数,按唯一标识去重统计
埋点 SDK封装数据采集、缓存、上报逻辑的前端库
ETLExtract-Transform-Load,数据清洗与入仓流程
埋点治理管理埋点的生命周期:设计、开发、测试、上线、下线

数据流转架构

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  用户交互     │────▶│  埋点 SDK     │────▶│  数据上报     │
│  点击/输入    │     │  事件采集     │     │  批量/离线    │
│  页面跳转     │     │  数据组装     │     │  sendBeacon  │
└──────────────┘     └──────────────┘     └──────┬───────┘


┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  数据可视化   │◀────│  数据分析     │◀────│  数据接收     │
│  Dashboard   │     │  漏斗/留存    │     │  日志服务     │
│  热力图      │     │  路径/分群    │     │  ETL 清洗    │
└──────────────┘     └──────────────┘     └──────────────┘

Why — 为什么用

三种埋点方式对比

维度代码埋点可视化埋点无痕埋点(全埋点)
侵入性高(需修改业务代码)低(配置化)极低(SDK 自动采集)
灵活性高(可采集任意自定义数据)中(受圈选能力限制)低(只能采集标准事件)
开发成本高(每个埋点需编码)低(运营自行配置)极低(接入 SDK 即可)
数据精度高(业务语义明确)中(依赖元素定位准确性)低(需后期人工映射事件含义)
数据量可控(按需采集)可控(按需圈选)极大(全量采集)
生效方式需发版上线配置即生效SDK 接入即生效
维护成本高(埋点与业务耦合)中(需维护圈选规则)低(无需维护)
适用场景核心业务事件、精确数据需求运营驱动的快速验证初期探索、流量概览

产品决策需要数据支撑

没有埋点数据,产品决策只能靠”拍脑袋”:

  • 功能优先级:埋点数据显示某功能使用率仅 2%,不应继续投入
  • 用户流失点:漏斗分析发现注册第三步流失率 60%,需优化
  • 界面改版效果:AB 测试数据显示新版本转化率提升 15%

典型使用场景

  1. 用户行为分析:了解用户如何使用产品,哪些功能受欢迎
  2. AB 测试:对比不同方案的效果差异,用数据做决策
  3. 转化率优化:定位漏斗中的流失环节,针对性优化
  4. 运营决策:活动效果评估、渠道质量对比、用户分群运营
  5. 性能监控:页面加载速度、接口响应时间影响用户体验
  6. 异常发现:某个页面错误率飙升,及时告警修复
  7. 合规审计:关键操作留痕,满足监管要求

How — 怎么做

1. 埋点架构设计

SDK 整体架构

┌─────────────────────────────────────────────────┐
│                  埋点 SDK                        │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────────┐ │
│  │ 事件采集  │  │ 数据组装  │  │  上报策略      │ │
│  │          │  │          │  │               │ │
│  │ ·手动埋点│  │ ·事件模型│  │ ·批量上报     │ │
│  │ ·自动采集│  │ ·公共属性│  │ ·离线缓存     │ │
│  │ ·曝光监听│  │ ·数据校验│  │ ·页面卸载上报 │ │
│  └──────────┘  └──────────┘  └───────────────┘ │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌───────────────┐ │
│  │ 插件系统  │  │ 配置中心  │  │  调试工具      │ │
│  │          │  │          │  │               │ │
│  │ ·生命周期│  │ ·采样率  │  │ ·事件日志     │ │
│  │ ·中间件  │  │ ·开关    │  │ ·数据校验     │ │
│  │ ·扩展钩子│  │ ·灰度    │  │ ·埋点检测     │ │
│  └──────────┘  └──────────┘  └───────────────┘ │
└─────────────────────────────────────────────────┘

事件模型设计

// tracker/types.ts
interface TrackEvent {
  // 事件基础信息
  eventId: string;           // 事件唯一 ID(UUID)
  eventType: string;         // 事件类型:click / pageview / expose / custom
  eventName: string;         // 事件名称:button_click / page_view / element_expose
  timestamp: number;         // 事件发生时间戳

  // 用户信息
  userId?: string;           // 登录用户 ID
  deviceId: string;          // 设备唯一标识(UV 识别)
  sessionId: string;         // 会话 ID

  // 页面信息
  pageUrl: string;           // 当前页面 URL
  pagePath: string;          // 路由路径
  referrer: string;          // 来源页面
  pageTitle: string;         // 页面标题

  // 业务数据
  properties: Record<string, unknown>;  // 自定义属性

  // 环境信息
  environment: {
    userAgent: string;       // 浏览器 UA
    viewport: string;        // 视口尺寸
    screen: string;          // 屏幕分辨率
    language: string;        // 语言
    platform: string;        // 操作系统
    networkType: string;     // 网络类型
    cookieEnabled: boolean;  // Cookie 是否可用
  };
}

SDK 核心类设计

// tracker/Tracker.ts
type EventName = string;
type Properties = Record<string, unknown>;
type Plugin = {
  name: string;
  beforeTrack?: (event: TrackEvent) => TrackEvent | null;
  afterTrack?: (event: TrackEvent) => void;
};

interface TrackerConfig {
  appId: string;              // 应用标识
  endpoint: string;           // 上报地址
  sampleRate?: number;        // 采样率 0-1
  batchSize?: number;         // 批量上报条数
  batchInterval?: number;     // 批量上报间隔(ms)
  enableAutoTrack?: boolean;  // 启用自动采集
  enableSPA?: boolean;        // SPA 路由监听
  debug?: boolean;            // 调试模式
}

class Tracker {
  private config: Required<TrackerConfig>;
  private queue: TrackEvent[] = [];
  private plugins: Plugin[] = [];
  private commonProperties: Properties = {};
  private timer: ReturnType<typeof setInterval> | null = null;
  private userId: string = '';
  private deviceId: string = '';
  private sessionId: string = '';

  constructor(config: TrackerConfig) {
    this.config = {
      sampleRate: 1,
      batchSize: 10,
      batchInterval: 5000,
      enableAutoTrack: false,
      enableSPA: false,
      debug: false,
      ...config,
    };

    this.deviceId = this.getOrCreateDeviceId();
    this.sessionId = this.createSessionId();
    this.initAutoTrack();
    this.startBatchTimer();
    this.bindUnloadEvent();
  }

  // 手动埋点
  track(eventName: EventName, properties: Properties = {}): void {
    // 采样率控制
    if (Math.random() > this.config.sampleRate) return;

    const event = this.buildEvent(eventName, properties);
    const processedEvent = this.runPlugins(event);
    if (!processedEvent) return;

    this.enqueue(processedEvent);
  }

  // 页面浏览
  pageView(properties: Properties = {}): void {
    this.track('page_view', {
      url: location.href,
      path: location.pathname,
      referrer: document.referrer,
      title: document.title,
      ...properties,
    });
  }

  // 设置用户 ID
  identify(userId: string): void {
    this.userId = userId;
  }

  // 设置公共属性
  setCommonProperties(properties: Properties): void {
    Object.assign(this.commonProperties, properties);
  }

  // 注册插件
  use(plugin: Plugin): void {
    this.plugins.push(plugin);
  }

  // 构建事件对象
  private buildEvent(eventName: EventName, properties: Properties): TrackEvent {
    return {
      eventId: this.generateId(),
      eventType: 'custom',
      eventName,
      timestamp: Date.now(),
      userId: this.userId || undefined,
      deviceId: this.deviceId,
      sessionId: this.sessionId,
      pageUrl: location.href,
      pagePath: location.pathname,
      referrer: document.referrer,
      pageTitle: document.title,
      properties: { ...this.commonProperties, ...properties },
      environment: this.collectEnvironment(),
    };
  }

  // 执行插件链
  private runPlugins(event: TrackEvent): TrackEvent | null {
    let result = event;
    for (const plugin of this.plugins) {
      if (plugin.beforeTrack) {
        result = plugin.beforeTrack(result);
        if (!result) return null; // 插件拦截,不上报
      }
    }
    return result;
  }

  // 入队
  private enqueue(event: TrackEvent): void {
    this.queue.push(event);
    if (this.config.debug) {
      console.log('[Tracker] event:', event);
    }
    if (this.queue.length >= this.config.batchSize) {
      this.flush();
    }
  }

  // 批量上报
  flush(): void {
    if (this.queue.length === 0) return;
    const events = this.queue.splice(0);
    this.send(events);
  }

  // 发送数据
  private send(events: TrackEvent[]): void {
    const data = JSON.stringify({
      appId: this.config.appId,
      events,
    });

    // 优先 sendBeacon
    if (navigator.sendBeacon) {
      const blob = new Blob([data], { type: 'application/json' });
      const success = navigator.sendBeacon(this.config.endpoint, blob);
      if (success) return;
    }

    // 降级 XMLHttpRequest
    const xhr = new XMLHttpRequest();
    xhr.open('POST', this.config.endpoint, true);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(data);
  }

  // 启动批量定时器
  private startBatchTimer(): void {
    this.timer = setInterval(() => this.flush(), this.config.batchInterval);
  }

  // 绑定页面卸载事件
  private bindUnloadEvent(): void {
    const handler = () => this.flush();
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') handler();
    });
    window.addEventListener('pagehide', handler);
  }

  // 设备 ID(UV 识别)
  private getOrCreateDeviceId(): string {
    const KEY = '__tracker_device_id__';
    let id = localStorage.getItem(KEY);
    if (!id) {
      id = this.generateId();
      localStorage.setItem(KEY, id);
    }
    return id;
  }

  // 会话 ID
  private createSessionId(): string {
    const KEY = '__tracker_session_id__';
    const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 分钟
    const stored = sessionStorage.getItem(KEY);
    if (stored) {
      const { id, timestamp } = JSON.parse(stored);
      if (Date.now() - timestamp < SESSION_TIMEOUT) {
        sessionStorage.setItem(KEY, JSON.stringify({ id, timestamp: Date.now() }));
        return id;
      }
    }
    const id = this.generateId();
    sessionStorage.setItem(KEY, JSON.stringify({ id, timestamp: Date.now() }));
    return id;
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
  }

  private collectEnvironment(): TrackEvent['environment'] {
    const conn = (navigator as any).connection;
    return {
      userAgent: navigator.userAgent,
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      screen: `${screen.width}x${screen.height}`,
      language: navigator.language,
      platform: navigator.platform,
      networkType: conn?.effectiveType || 'unknown',
      cookieEnabled: navigator.cookieEnabled,
    };
  }

  // SPA 自动采集初始化
  private initAutoTrack(): void {
    if (this.config.enableAutoTrack) {
      this.setupAutoClickTracking();
    }
    if (this.config.enableSPA) {
      this.setupSPARouting();
    }
  }

  private setupAutoClickTracking(): void {
    document.addEventListener('click', (e) => {
      const target = e.target as HTMLElement;
      const xpath = this.getXPath(target);
      this.track('auto_click', {
        tagName: target.tagName,
        xpath,
        text: target.innerText?.slice(0, 50),
        className: target.className,
      });
    }, true);
  }

  // 获取元素 XPath
  private getXPath(element: HTMLElement): string {
    if (element.id) return `//*[@id="${element.id}"]`;
    const parts: string[] = [];
    let node: HTMLElement | null = element;
    while (node && node.nodeType === Node.ELEMENT_NODE) {
      let selector = node.tagName.toLowerCase();
      if (node.id) {
        selector += `#${node.id}`;
        parts.unshift(selector);
        break;
      }
      const parent = node.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter(
          (c) => c.tagName === node!.tagName
        );
        if (siblings.length > 1) {
          const index = siblings.indexOf(node) + 1;
          selector += `[${index}]`;
        }
      }
      parts.unshift(selector);
      node = node.parentElement;
    }
    return '/' + parts.join('/');
  }

  private setupSPARouting(): void {
    // 见 PV/UV 统计章节
  }

  destroy(): void {
    this.flush();
    if (this.timer) clearInterval(this.timer);
  }
}

// 使用示例
const tracker = new Tracker({
  appId: 'my-app',
  endpoint: 'https://track.example.com/collect',
  batchSize: 20,
  batchInterval: 5000,
  enableAutoTrack: true,
  enableSPA: true,
  debug: process.env.NODE_ENV === 'development',
});

tracker.identify('user_12345');
tracker.setCommonProperties({ appVersion: '2.1.0', channel: 'official' });

2. 代码埋点实现

手动埋点函数

// tracker/manual.ts
class ManualTracker {
  // 按钮点击埋点
  trackButtonClick(buttonName: string, extra?: Properties): void {
    tracker.track('button_click', {
      button_name: buttonName,
      page_module: this.getCurrentModule(),
      ...extra,
    });
  }

  // 页面停留时长
  private enterTime: Record<string, number> = {};

  trackPageEnter(pageName: string): void {
    this.enterTime[pageName] = Date.now();
    tracker.track('page_enter', { page_name: pageName });
  }

  trackPageLeave(pageName: string): void {
    const enterTime = this.enterTime[pageName];
    if (enterTime) {
      const duration = Date.now() - enterTime;
      tracker.track('page_leave', {
        page_name: pageName,
        duration_ms: duration,
        duration_sec: Math.round(duration / 1000),
      });
      delete this.enterTime[pageName];
    }
  }

  // 接口请求埋点
  trackApiRequest(apiName: string, duration: number, success: boolean, extra?: Properties): void {
    tracker.track('api_request', {
      api_name: apiName,
      duration_ms: duration,
      success,
      ...extra,
    });
  }

  private getCurrentModule(): string {
    const path = location.pathname;
    const segments = path.split('/').filter(Boolean);
    return segments[0] || 'home';
  }
}

装饰器埋点(TypeScript)

// tracker/decorator.ts
function track(eventName: string, getProperties?: (...args: any[]) => Properties) {
  return function (
    _target: any,
    _propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const result = originalMethod.apply(this, args);
      const properties = getProperties ? getProperties(...args) : {};
      tracker.track(eventName, properties);
      return result;
    };
    return descriptor;
  };
}

// 使用示例
class OrderService {
  @track('order_submit', (order) => ({
    order_id: order.id,
    amount: order.amount,
    item_count: order.items.length,
  }))
  submitOrder(order: Order): Promise<Result> {
    return api.post('/orders', order);
  }

  @track('order_cancel', (orderId, reason) => ({
    order_id: orderId,
    cancel_reason: reason,
  }))
  cancelOrder(orderId: string, reason: string): Promise<Result> {
    return api.delete(`/orders/${orderId}`);
  }

  @track('order_pay', (orderId, method) => ({
    order_id: orderId,
    payment_method: method,
  }))
  payOrder(orderId: string, method: string): Promise<Result> {
    return api.post(`/orders/${orderId}/pay`, { method });
  }
}

React Hook 埋点

// tracker/react.ts
import { useEffect, useRef, useCallback } from 'react';

// 点击埋点 Hook
function useTrackClick(eventName: string, properties?: Properties) {
  return useCallback(() => {
    tracker.track(eventName, properties);
  }, [eventName, properties]);
}

// 使用示例
function BuyButton({ productId, price }: { productId: string; price: number }) {
  const handleClick = useTrackClick('buy_button_click', {
    product_id: productId,
    price,
  });

  return <button onClick={handleClick}>立即购买</button>;
}

// 页面浏览埋点 Hook
function useTrackPageView(pageName: string, properties?: Properties) {
  useEffect(() => {
    tracker.pageView({ page_name: pageName, ...properties });
  }, [pageName]); // 路由变化时重新触发
}

// 页面停留时长 Hook
function useTrackPageDuration(pageName: string) {
  const enterTimeRef = useRef(Date.now());

  useEffect(() => {
    enterTimeRef.current = Date.now();
    tracker.track('page_enter', { page_name: pageName });

    return () => {
      const duration = Date.now() - enterTimeRef.current;
      tracker.track('page_leave', {
        page_name: pageName,
        duration_ms: duration,
      });
    };
  }, [pageName]);
}

// 曝光埋点 Hook
function useTrackExposure(
  ref: React.RefObject<HTMLElement>,
  eventName: string,
  properties?: Properties,
  options?: { threshold?: number; trackOnce?: boolean }
) {
  const trackedRef = useRef(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    if (options?.trackOnce && trackedRef.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          if (options?.trackOnce && trackedRef.current) return;
          trackedRef.current = true;
          tracker.track(eventName, properties);
        }
      },
      { threshold: options?.threshold ?? 0.5 }
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [ref, eventName]);
}

// 使用示例
function ProductCard({ product }: { product: Product }) {
  const cardRef = useRef<HTMLDivElement>(null);

  useTrackExposure(cardRef, 'product_card_expose', {
    product_id: product.id,
    product_name: product.name,
  });

  return (
    <div ref={cardRef}>
      <h3>{product.name}</h3>
      <p>¥{product.price}</p>
    </div>
  );
}

// 组合使用
function ProductPage() {
  useTrackPageView('product_page');
  useTrackPageDuration('product_page');

  return <ProductList />;
}

Vue 指令埋点

// tracker/vue.ts
import type { App, Directive, DirectiveBinding } from 'vue';

// v-track 点击埋点指令
const trackDirective: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { arg, value } = binding;

    switch (arg) {
      case 'click':
        el.addEventListener('click', () => {
          tracker.track('element_click', typeof value === 'object' ? value : { name: value });
        });
        break;

      case 'expose':
        setupExposure(el, value);
        break;

      case 'input':
        el.addEventListener('change', () => {
          tracker.track('input_change', typeof value === 'object' ? value : { name: value });
        });
        break;
    }
  },
};

// 曝光指令
function setupExposure(el: HTMLElement, value: any) {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        tracker.track('element_expose', typeof value === 'object' ? value : { name: value });
        observer.disconnect(); // 只曝光一次
      }
    },
    { threshold: 0.5 }
  );
  observer.observe(el);
}

// 注册插件
export const TrackerPlugin = {
  install(app: App) {
    app.directive('track', trackDirective);
  },
};

// 使用示例
// <button v-track:click="{ button_name: 'submit', module: 'form' }">提交</button>
// <div v-track:expose="{ element_name: 'banner', position: 'top' }">广告位</div>
// <input v-track:input="{ input_name: 'search' }" />

3. 无痕埋点

全局事件代理

// tracker/auto-track.ts
class AutoTracker {
  private trackedElements = new WeakSet<Element>();

  init(): void {
    this.proxyClickEvents();
    this.proxyInputEvents();
    this.autoTrackPV();
    this.trackPerformance();
  }

  // 代理所有点击事件
  private proxyClickEvents(): void {
    document.addEventListener(
      'click',
      (e: MouseEvent) => {
        const target = e.target as HTMLElement;
        if (!target) return;

        // 向上查找带埋点标识的元素
        const trackEl = target.closest('[data-track]') as HTMLElement;
        if (trackEl) {
          // 有 data-track 属性的元素做精确埋点
          const trackName = trackEl.dataset.track;
          const trackParams = trackEl.dataset.trackParams;
          tracker.track(trackName || 'click', {
            ...this.parseParams(trackParams),
            element_text: trackEl.innerText?.slice(0, 50),
          });
          return;
        }

        // 无标识元素做自动采集
        const xpath = this.getXPath(target);
        const selector = this.getCSSSelector(target);

        tracker.track('auto_click', {
          xpath,
          selector,
          tag_name: target.tagName.toLowerCase(),
          element_text: target.innerText?.slice(0, 50),
          element_class: target.className?.toString().slice(0, 100),
          element_id: target.id || undefined,
          page_x: e.pageX,
          page_y: e.pageY,
          viewport_x: e.clientX,
          viewport_y: e.clientY,
        });
      },
      true // 捕获阶段,最先执行
    );
  }

  // 代理输入事件
  private proxyInputEvents(): void {
    document.addEventListener(
      'focus',
      (e: FocusEvent) => {
        const target = e.target as HTMLInputElement;
        if (!target || !target.tagName) return;
        if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;

        tracker.track('auto_input_focus', {
          tag_name: target.tagName.toLowerCase(),
          input_type: target.type,
          input_name: target.name,
          xpath: this.getXPath(target),
        });
      },
      true
    );
  }

  // 自动采集 PV
  private autoTrackPV(): void {
    tracker.pageView();
  }

  // 解析 data-track-params
  private parseParams(paramsStr?: string): Properties {
    if (!paramsStr) return {};
    try {
      return JSON.parse(paramsStr);
    } catch {
      return { raw_params: paramsStr };
    }
  }

  // CSS 选择器定位
  private getCSSSelector(el: HTMLElement): string {
    const parts: string[] = [];
    let node: HTMLElement | null = el;
    while (node && node !== document.body) {
      let selector = node.tagName.toLowerCase();
      if (node.id) {
        selector += `#${CSS.escape(node.id)}`;
        parts.unshift(selector);
        break;
      }
      if (node.className && typeof node.className === 'string') {
        const classes = node.className.trim().split(/\s+/).slice(0, 2);
        if (classes.length) selector += '.' + classes.map(CSS.escape).join('.');
      }
      const parent = node.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter(
          (c) => c.tagName === node!.tagName
        );
        if (siblings.length > 1) {
          const index = siblings.indexOf(node) + 1;
          selector += `:nth-of-type(${index})`;
        }
      }
      parts.unshift(selector);
      node = node.parentElement;
    }
    return parts.join(' > ');
  }

  // XPath 定位(复用前文方法)
  private getXPath(element: HTMLElement): string {
    if (element.id) return `//*[@id="${element.id}"]`;
    const parts: string[] = [];
    let node: HTMLElement | null = element;
    while (node && node.nodeType === Node.ELEMENT_NODE) {
      let selector = node.tagName.toLowerCase();
      if (node.id) {
        selector += `#${node.id}`;
        parts.unshift(selector);
        break;
      }
      const parent = node.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter(
          (c) => c.tagName === node!.tagName
        );
        if (siblings.length > 1) {
          const index = siblings.indexOf(node) + 1;
          selector += `[${index}]`;
        }
      }
      parts.unshift(selector);
      node = node.parentElement;
    }
    return '/' + parts.join('/');
  }

  // 数据量控制:采样 + 限流
  private sendQueue: TrackEvent[] = [];
  private maxQPS = 5; // 每秒最多上报 5 条
  private lastSendTime = 0;

  private throttledTrack(event: TrackEvent): void {
    const now = Date.now();
    if (now - this.lastSendTime < 1000 / this.maxQPS) {
      // 限流,丢弃或入队
      return;
    }
    this.lastSendTime = now;
    this.sendQueue.push(event);
  }
}

HTML data 属性约定

<!-- 精确埋点:data-track 指定事件名 -->
<button data-track="submit_order" data-track-params='{"from":"cart"}'>
  提交订单
</button>

<!-- 自动采集:无 data-track 的元素由 SDK 自动采集 -->
<div class="product-card">
  <h3>商品名称</h3>
  <button>加入购物车</button>
</div>

<!-- 曝光埋点:data-track-expose -->
<div data-track-expose="banner_expose" data-track-params='{"banner_id":"home_top"}'>
  <img src="banner.jpg" alt="活动横幅" />
</div>

4. 曝光埋点

IntersectionObserver 实现

// tracker/exposure.ts
interface ExposureOptions {
  threshold?: number;        // 可见比例阈值,默认 0.5(50% 可见)
  trackOnce?: boolean;       // 是否只曝光一次,默认 true
  minDuration?: number;      // 最小曝光时长(ms),默认 0
  rootMargin?: string;       // 观察区域边距
}

interface ExposureTarget {
  element: HTMLElement;
  eventName: string;
  properties: Properties;
  options: Required<ExposureOptions>;
  enterTime: number | null;   // 进入视口的时间
  hasTracked: boolean;        // 是否已上报
}

class ExposureTracker {
  private observer: IntersectionObserver;
  private targets = new Map<HTMLElement, ExposureTarget>();

  constructor() {
    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersection(entries),
      {
        threshold: [0, 0.25, 0.5, 0.75, 1],
        rootMargin: '0px',
      }
    );
  }

  // 注册曝光监听
  observe(
    element: HTMLElement,
    eventName: string,
    properties: Properties = {},
    options: ExposureOptions = {}
  ): void {
    const fullOptions: Required<ExposureOptions> = {
      threshold: 0.5,
      trackOnce: true,
      minDuration: 0,
      rootMargin: '0px',
      ...options,
    };

    this.targets.set(element, {
      element,
      eventName,
      properties,
      options: fullOptions,
      enterTime: null,
      hasTracked: false,
    });

    this.observer.observe(element);
  }

  // 取消监听
  unobserve(element: HTMLElement): void {
    this.targets.delete(element);
    this.observer.unobserve(element);
  }

  // 处理交叉变化
  private handleIntersection(entries: IntersectionObserverEntry[]): void {
    const now = Date.now();

    for (const entry of entries) {
      const target = this.targets.get(entry.target as HTMLElement);
      if (!target) continue;
      if (target.hasTracked && target.options.trackOnce) continue;

      const isVisible = entry.intersectionRatio >= target.options.threshold;

      if (isVisible && target.enterTime === null) {
        // 进入视口
        target.enterTime = now;
      } else if (!isVisible && target.enterTime !== null) {
        // 离开视口
        const duration = now - target.enterTime;
        target.enterTime = null;

        // 判断是否为有效曝光
        if (duration >= target.options.minDuration) {
          this.reportExposure(target, duration);
        }
      }
    }
  }

  // 上报曝光事件
  private reportExposure(target: ExposureTarget, duration: number): void {
    target.hasTracked = true;

    tracker.track(target.eventName, {
      ...target.properties,
      exposure_duration_ms: duration,
      exposure_duration_sec: Math.round(duration / 1000),
      is_valid_exposure: true,
    });

    if (target.options.trackOnce) {
      this.unobserve(target.element);
    }
  }

  // 销毁
  destroy(): void {
    this.observer.disconnect();
    this.targets.clear();
  }
}

列表曝光(虚拟滚动场景)

// tracker/list-exposure.ts
import { useEffect, useRef } from 'react';

interface ListExposureOptions {
  threshold?: number;
  minDuration?: number;
  trackOnce?: boolean;
}

function useTrackListExposure<T>(
  listRef: React.RefObject<HTMLElement>,
  items: T[],
  getItemId: (item: T) => string,
  eventName: string,
  getProperties: (item: T) => Properties,
  options?: ListExposureOptions
) {
  const exposureTracker = useRef(new ExposureTracker());
  const trackedIds = useRef(new Set<string>());

  useEffect(() => {
    const container = listRef.current;
    if (!container) return;

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          const index = Number((entry.target as HTMLElement).dataset.index);
          const item = items[index];
          if (!item) continue;

          const itemId = getItemId(item);

          if (entry.isIntersecting) {
            if (options?.trackOnce && trackedIds.current.has(itemId)) continue;
            trackedIds.current.add(itemId);

            tracker.track(eventName, {
              ...getProperties(item),
              item_index: index,
              item_id: itemId,
              list_length: items.length,
            });
          }
        }
      },
      { threshold: options?.threshold ?? 0.5 }
    );

    // 观察列表中每个子元素
    const children = container.children;
    for (let i = 0; i < children.length; i++) {
      (children[i] as HTMLElement).dataset.index = String(i);
      observer.observe(children[i]);
    }

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

// 使用示例:商品列表曝光
function ProductList({ products }: { products: Product[] }) {
  const listRef = useRef<HTMLDivElement>(null);

  useTrackListExposure(
    listRef,
    products,
    (p) => p.id,
    'product_list_expose',
    (p) => ({ product_id: p.id, product_name: p.name, price: p.price }),
    { trackOnce: true, minDuration: 500 }
  );

  return (
    <div ref={listRef} className="product-list">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

5. PV/UV 统计

SPA 路由变化监听

// tracker/spa-pv.ts
class SPAPVTracker {
  private currentPath: string = '';
  private enterTime: number = 0;

  init(): void {
    // 首次加载
    this.trackPV();

    // 监听 history 变化
    this.patchHistoryAPI();

    // 监听 hash 变化
    window.addEventListener('hashchange', () => {
      this.trackPV();
    });

    // 监听 popstate(浏览器前进/后退)
    window.addEventListener('popstate', () => {
      this.trackPV();
    });
  }

  // 劫持 history.pushState / replaceState
  private patchHistoryAPI(): void {
    const originalPushState = history.pushState.bind(history);
    const originalReplaceState = history.replaceState.bind(history);

    history.pushState = (...args: Parameters<typeof history.pushState>) => {
      originalPushState(...args);
      this.trackPV();
    };

    history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
      originalReplaceState(...args);
      this.trackPV();
    };
  }

  // 记录 PV 并计算页面停留时长
  private trackPV(): void {
    const now = Date.now();
    const currentPath = location.pathname + location.search;

    // 上报上一页面的停留时长
    if (this.currentPath && this.enterTime) {
      const duration = now - this.enterTime;
      tracker.track('page_leave', {
        page_path: this.currentPath,
        duration_ms: duration,
      });
    }

    // 上报新页面 PV
    this.currentPath = currentPath;
    this.enterTime = now;

    tracker.track('page_view', {
      page_path: currentPath,
      page_url: location.href,
      referrer: document.referrer,
      page_title: document.title,
    });
  }
}

Vue Router 集成

// tracker/vue-router.ts
import type { Router } from 'vue-router';

function setupVueRouterTracking(router: Router) {
  router.afterEach((to, from) => {
    tracker.track('page_view', {
      page_path: to.path,
      page_name: to.name as string,
      page_url: location.href,
      referrer: from.fullPath,
      page_title: document.title,
      route_params: JSON.stringify(to.params),
      query_params: JSON.stringify(to.query),
    });
  });
}

// main.ts
const router = createRouter({ ... });
setupVueRouterTracking(router);

React Router 集成

// tracker/react-router.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function RouteTracker() {
  const location = useLocation();

  useEffect(() => {
    tracker.track('page_view', {
      page_path: location.pathname,
      page_url: location.href,
      page_search: location.search,
      page_hash: location.hash,
      referrer: document.referrer,
      page_title: document.title,
    });
  }, [location]);

  return null;
}

// App.tsx
function App() {
  return (
    <BrowserRouter>
      <RouteTracker />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/product/:id" element={<Product />} />
      </Routes>
    </BrowserRouter>
  );
}

UV 识别方案

// tracker/uv.ts
class UVTracker {
  private DEVICE_ID_KEY = '__uv_device_id__';
  private SESSION_KEY = '__uv_session__';

  // 设备级唯一标识(跨会话持久)
  getDeviceId(): string {
    let id = this.getCookie(this.DEVICE_ID_KEY);
    if (!id) {
      id = this.generateFingerprint();
      this.setCookie(this.DEVICE_ID_KEY, id, 365); // 365 天
    }
    return id;
  }

  // 会话级标识(30 分钟超时)
  getSessionId(): string {
    const stored = this.getCookie(this.SESSION_KEY);
    if (stored) {
      const { id, ts } = JSON.parse(stored);
      if (Date.now() - ts < 30 * 60 * 1000) {
        this.setCookie(this.SESSION_KEY, JSON.stringify({ id, ts: Date.now() }), 1);
        return id;
      }
    }
    const id = crypto.randomUUID?.() || this.generateId();
    this.setCookie(this.SESSION_KEY, JSON.stringify({ id, ts: Date.now() }), 1);
    return id;
  }

  // 浏览器指纹生成(Canvas + WebGL + 字体 + 插件等)
  private generateFingerprint(): string {
    const components = [
      navigator.userAgent,
      navigator.language,
      screen.width + 'x' + screen.height,
      screen.colorDepth,
      new Date().getTimezoneOffset(),
      navigator.plugins.length,
      this.getCanvasFingerprint(),
    ];
    return this.hash(components.join('||'));
  }

  // Canvas 指纹
  private getCanvasFingerprint(): string {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    ctx.textBaseline = 'top';
    ctx.font = '14px Arial';
    ctx.fillText('fingerprint', 2, 2);
    return canvas.toDataURL().slice(-50);
  }

  // 简单哈希
  private hash(str: string): string {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash |= 0;
    }
    return Math.abs(hash).toString(36);
  }

  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
  }

  private getCookie(name: string): string | null {
    const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
    return match ? decodeURIComponent(match[1]) : null;
  }

  private setCookie(name: string, value: string, days: number): void {
    const expires = new Date(Date.now() + days * 86400000).toUTCString();
    document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/;SameSite=Lax`;
  }
}

6. 性能数据采集

// tracker/performance.ts
class PerformanceCollector {
  collect(): void {
    this.collectNavigationTiming();
    this.collectWebVitals();
    this.collectResourceTiming();
    this.collectCustomMetrics();
  }

  // Navigation Timing:页面加载全链路
  private collectNavigationTiming(): void {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType !== 'navigation') continue;
        const nav = entry as PerformanceNavigationTiming;

        tracker.track('performance_navigation', {
          // 关键时间节点
          dns_lookup_ms: nav.domainLookupEnd - nav.domainLookupStart,
          tcp_connect_ms: nav.connectEnd - nav.connectStart,
          ssl_handshake_ms: nav.secureConnectionStart
            ? nav.connectEnd - nav.secureConnectionStart
            : 0,
          ttfb_ms: nav.responseStart - nav.requestStart,         // Time to First Byte
          content_download_ms: nav.responseEnd - nav.responseStart,
          dom_parse_ms: nav.domInteractive - nav.responseEnd,
          dom_ready_ms: nav.domContentLoadedEventEnd - nav.fetchStart,
          page_load_ms: nav.loadEventEnd - nav.fetchStart,       // 完整加载时间

          // 传输数据量
          transfer_size: nav.transferSize,
          encoded_body_size: nav.encodedBodySize,
          decoded_body_size: nav.decodedBodySize,

          // 连接信息
          protocol: nav.nextHopProtocol,        // h2 / h3
          redirect_count: nav.redirectCount,
        });
      }
    });

    observer.observe({ type: 'navigation', buffered: true });
  }

  // Web Vitals:核心性能指标
  private collectWebVitals(): void {
    // FCP(First Contentful Paint):首次内容绘制
    this.observeMetric('paint', (entry) => {
      if (entry.name === 'first-contentful-paint') {
        tracker.track('performance_fcp', {
          fcp_ms: entry.startTime,
          rating: this.rateFCP(entry.startTime),
        });
      }
    });

    // LCP(Largest Contentful Paint):最大内容绘制
    this.observeMetric('largest-contentful-paint', (entry) => {
      tracker.track('performance_lcp', {
        lcp_ms: entry.startTime,
        rating: this.rateLCP(entry.startTime),
        element: entry.element?.tagName,
        url: (entry as any).url || undefined,
      });
    });

    // FID(First Input Delay):首次输入延迟
    this.observeMetric('first-input', (entry) => {
      const fid = (entry as any).processingStart - entry.startTime;
      tracker.track('performance_fid', {
        fid_ms: fid,
        rating: this.rateFID(fid),
        event_type: entry.name,
      });
    });

    // CLS(Cumulative Layout Shift):累积布局偏移
    let clsValue = 0;
    let clsEntries: any[] = [];
    let sessionValue = 0;
    let sessionEntries: any[] = [];

    this.observeMetric('layout-shift', (entry: any) => {
      if (!entry.hadRecentInput) {
        const firstSessionEntry = sessionEntries[0];
        const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

        if (
          sessionValue &&
          entry.startTime - lastSessionEntry.startTime < 1000 &&
          entry.startTime - firstSessionEntry.startTime < 5000
        ) {
          sessionValue += entry.value;
          sessionEntries.push(entry);
        } else {
          sessionValue = entry.value;
          sessionEntries = [entry];
        }

        if (sessionValue > clsValue) {
          clsValue = sessionValue;
          clsEntries = [...sessionEntries];
        }
      }
    });

    // 页面隐藏时上报 CLS
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden' && clsValue > 0) {
        tracker.track('performance_cls', {
          cls_value: clsValue,
          rating: this.rateCLS(clsValue),
          shift_count: clsEntries.length,
        });
      }
    });

    // INP(Interaction to Next Paint):交互到下一次绘制
    this.observeMetric('event', (entry: any) => {
      const duration = entry.duration;
      if (duration > 0) {
        tracker.track('performance_inp', {
          inp_ms: duration,
          event_type: entry.name,
          rating: this.rateINP(duration),
        });
      }
    });

    // TTFB(Time to First Byte):首字节时间
    const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
    if (navEntry) {
      const ttfb = navEntry.responseStart - navEntry.requestStart;
      tracker.track('performance_ttfb', {
        ttfb_ms: ttfb,
        rating: this.rateTTFB(ttfb),
      });
    }
  }

  // 资源加载性能
  private collectResourceTiming(): void {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const resource = entry as PerformanceResourceTiming;
        // 只采集慢资源(> 1s)和关键资源
        if (resource.duration > 1000 || this.isCriticalResource(resource)) {
          tracker.track('performance_resource', {
            name: resource.name,
            type: resource.initiatorType,
            duration_ms: resource.duration,
            transfer_size: resource.transferSize,
            protocol: resource.nextHopProtocol,
            start_time: resource.startTime,
          });
        }
      }
    });
    observer.observe({ type: 'resource', buffered: true });
  }

  // 自定义业务指标
  private collectCustomMetrics(): void {
    // 使用 Performance Mark & Measure
    performance.mark('app_init_start');

    // ... 应用初始化逻辑 ...

    performance.mark('app_init_end');
    performance.measure('app_init_duration', 'app_init_start', 'app_init_end');

    const measures = performance.getEntriesByName('app_init_duration');
    if (measures.length) {
      tracker.track('performance_custom', {
        metric_name: 'app_init_duration',
        duration_ms: measures[0].duration,
      });
    }
  }

  private observeMetric(type: string, callback: (entry: any) => void): void {
    try {
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          callback(entry);
        }
      });
      observer.observe({ type, buffered: true });
    } catch {
      // 浏览器不支持该指标类型
    }
  }

  private isCriticalResource(resource: PerformanceResourceTiming): boolean {
    return ['script', 'link', 'xmlhttprequest', 'fetch'].includes(
      resource.initiatorType
    );
  }

  // 评级标准(基于 Google Web Vitals)
  private rateFCP(ms: number): 'good' | 'needs-improvement' | 'poor' {
    if (ms <= 1800) return 'good';
    if (ms <= 3000) return 'needs-improvement';
    return 'poor';
  }
  private rateLCP(ms: number): 'good' | 'needs-improvement' | 'poor' {
    if (ms <= 2500) return 'good';
    if (ms <= 4000) return 'needs-improvement';
    return 'poor';
  }
  private rateFID(ms: number): 'good' | 'needs-improvement' | 'poor' {
    if (ms <= 100) return 'good';
    if (ms <= 300) return 'needs-improvement';
    return 'poor';
  }
  private rateCLS(value: number): 'good' | 'needs-improvement' | 'poor' {
    if (value <= 0.1) return 'good';
    if (value <= 0.25) return 'needs-improvement';
    return 'poor';
  }
  private rateINP(ms: number): 'good' | 'needs-improvement' | 'poor' {
    if (ms <= 200) return 'good';
    if (ms <= 500) return 'needs-improvement';
    return 'poor';
  }
  private rateTTFB(ms: number): 'good' | 'needs-improvement' | 'poor' {
    if (ms <= 800) return 'good';
    if (ms <= 1800) return 'needs-improvement';
    return 'poor';
  }
}

7. 数据上报策略

// tracker/reporter.ts
interface ReporterConfig {
  endpoint: string;
  batchSize: number;
  batchInterval: number;
  maxRetryCount: number;
  enableOfflineCache: boolean;
  maxCacheSize: number;
}

class DataReporter {
  private queue: TrackEvent[] = [];
  private retryCount = 0;
  private db: IDBDatabase | null = null;
  private isOnline = navigator.onLine;

  constructor(private config: ReporterConfig) {
    this.initOfflineCache();
    this.bindEvents();
  }

  // 入队并判断是否立即上报
  enqueue(event: TrackEvent): void {
    this.queue.push(event);

    if (this.queue.length >= this.config.batchSize) {
      this.flush();
      return;
    }

    this.startTimer();
  }

  // 批量上报
  async flush(): Promise<void> {
    if (this.queue.length === 0) return;
    if (!this.isOnline) {
      await this.saveToCache(this.queue);
      this.queue = [];
      return;
    }

    const events = this.queue.splice(0);
    const success = await this.send(events);

    if (!success) {
      // 发送失败,放回队列或缓存
      this.queue.unshift(...events);
      this.retryCount++;
      if (this.retryCount > this.config.maxRetryCount) {
        await this.saveToCache(events);
        this.retryCount = 0;
      }
    } else {
      this.retryCount = 0;
    }
  }

  // 发送数据 — 多种策略
  private async send(events: TrackEvent[]): Promise<boolean> {
    const data = JSON.stringify({ events });

    // 策略 1:sendBeacon(页面卸载场景优先)
    if (navigator.sendBeacon) {
      const blob = new Blob([data], { type: 'application/json' });
      const success = navigator.sendBeacon(this.config.endpoint, blob);
      if (success) return true;
    }

    // 策略 2:1x1 GIF 图片(跨域友好)
    if (events.length <= 1) {
      try {
        return await this.sendViaImage(events[0]);
      } catch {
        // 降级
      }
    }

    // 策略 3:XMLHttpRequest / fetch(批量上报)
    return this.sendViaFetch(data);
  }

  // 1x1 GIF 上报
  private sendViaImage(event: TrackEvent): Promise<boolean> {
    return new Promise((resolve) => {
      const img = new Image();
      const params = new URLSearchParams({
        d: JSON.stringify(event),
      });
      img.onload = () => resolve(true);
      img.onerror = () => resolve(false);
      img.src = `${this.config.endpoint}/gif?${params.toString()}`;
    });
  }

  // Fetch 上报
  private async sendViaFetch(data: string): Promise<boolean> {
    try {
      const response = await fetch(this.config.endpoint, {
        method: 'POST',
        body: data,
        headers: { 'Content-Type': 'application/json' },
        keepalive: true, // 页面卸载时仍能发送
      });
      return response.ok;
    } catch {
      return false;
    }
  }

  // 页面卸载前上报
  private flushOnUnload(): void {
    if (this.queue.length === 0) return;
    const data = JSON.stringify({ events: this.queue });
    this.queue = [];

    // sendBeacon 是页面卸载时最可靠的上报方式
    if (navigator.sendBeacon) {
      const blob = new Blob([data], { type: 'application/json' });
      navigator.sendBeacon(this.config.endpoint, blob);
    }
  }

  // 离线缓存(IndexedDB)
  private async initOfflineCache(): Promise<void> {
    if (!this.config.enableOfflineCache) return;
    if (!window.indexedDB) return;

    const request = indexedDB.open('__tracker_offline__', 1);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains('events')) {
        db.createObjectStore('events', { autoIncrement: true });
      }
    };
    request.onsuccess = () => {
      this.db = request.result;
      this.sendCachedEvents();
    };
  }

  private async saveToCache(events: TrackEvent[]): Promise<void> {
    if (!this.db) return;
    const tx = this.db.transaction('events', 'readwrite');
    const store = tx.objectStore('events');
    for (const event of events) {
      store.add(event);
    }
  }

  private async sendCachedEvents(): Promise<void> {
    if (!this.db || !this.isOnline) return;
    const tx = this.db.transaction('events', 'readwrite');
    const store = tx.objectStore('events');
    const request = store.getAll();

    request.onsuccess = async () => {
      const events = request.result as TrackEvent[];
      if (events.length === 0) return;

      const success = await this.send(events);
      if (success) {
        store.clear(); // 发送成功后清除缓存
      }
    };
  }

  // 绑定网络状态与页面卸载事件
  private bindEvents(): void {
    window.addEventListener('online', () => {
      this.isOnline = true;
      this.sendCachedEvents();
      this.flush();
    });

    window.addEventListener('offline', () => {
      this.isOnline = false;
    });

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

    window.addEventListener('pagehide', () => {
      this.flushOnUnload();
    });
  }

  private timer: ReturnType<typeof setTimeout> | null = null;
  private startTimer(): void {
    if (this.timer) return;
    this.timer = setTimeout(() => {
      this.flush();
      this.timer = null;
    }, this.config.batchInterval);
  }
}

上报策略对比

方式页面卸载安全跨域体积限制适用场景
sendBeacon安全需 CORS64KB页面卸载上报、批量数据
1x1 GIF不安全无限制URL 长度限制(~2KB)单条轻量数据、第三方统计
fetch + keepalive安全需 CORS64KB现代浏览器批量上报
XMLHttpRequest不安全需 CORS无限制大批量数据、需响应处理

8. 数据处理与分析

漏斗分析

用户路径:
首页 → 搜索结果 → 商品详情 → 加入购物车 → 提交订单 → 支付成功

各步骤转化:
┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
│ 首页  │──▶│ 搜索  │──▶│ 详情  │──▶│ 购物车│──▶│ 下单  │──▶│ 支付  │
│10000 │   │ 8000 │   │ 6000 │   │ 3000 │   │ 2000 │   │ 1800 │
└──────┘   └──────┘   └──────┘   └──────┘   └──────┘   └──────┘
  100%      80%        60%        30%        20%        18%

步骤转化率:
  首页→搜索:80%    搜索→详情:75%    详情→购物车:50%
  购物车→下单:66.7%  下单→支付:90%

整体转化率:18%(10000 → 1800)
最大流失点:详情→购物车(50% 流失)
// analyzer/funnel.ts
interface FunnelStep {
  eventName: string;
  displayName: string;
}

interface FunnelResult {
  steps: Array<{
    eventName: string;
    displayName: string;
    count: number;
    conversionRate: number;     // 该步相对上一步的转化率
    overallConversionRate: number; // 该步相对第一步的转化率
    dropOffRate: number;        // 该步相对上一步的流失率
  }>;
  totalConversionRate: number;
}

class FunnelAnalyzer {
  analyze(
    events: TrackEvent[],
    steps: FunnelStep[],
    timeWindow: number = 7 * 24 * 60 * 60 * 1000 // 7 天窗口
  ): FunnelResult {
    // 按用户分组
    const userEvents = this.groupByUser(events);

    let firstStepCount = 0;
    const stepCounts = new Array(steps.length).fill(0);

    for (const [, userEventList] of userEvents) {
      // 按时间排序
      userEventList.sort((a, b) => a.timestamp - b.timestamp);

      let stepIndex = 0;
      let lastStepTime = 0;

      for (const event of userEventList) {
        if (stepIndex >= steps.length) break;

        // 检查是否超时
        if (lastStepTime && event.timestamp - lastStepTime > timeWindow) {
          break;
        }

        if (event.eventName === steps[stepIndex].eventName) {
          if (stepIndex === 0) firstStepCount++;
          stepCounts[stepIndex]++;
          lastStepTime = event.timestamp;
          stepIndex++;
        }
      }
    }

    const result = steps.map((step, index) => ({
      eventName: step.eventName,
      displayName: step.displayName,
      count: stepCounts[index],
      conversionRate: index === 0
        ? 1
        : stepCounts[index - 1] > 0
          ? stepCounts[index] / stepCounts[index - 1]
          : 0,
      overallConversionRate: firstStepCount > 0
        ? stepCounts[index] / firstStepCount
        : 0,
      dropOffRate: index === 0
        ? 0
        : stepCounts[index - 1] > 0
          ? 1 - stepCounts[index] / stepCounts[index - 1]
          : 0,
    }));

    return {
      steps: result,
      totalConversionRate: firstStepCount > 0
        ? stepCounts[steps.length - 1] / firstStepCount
        : 0,
    };
  }

  private groupByUser(events: TrackEvent[]): Map<string, TrackEvent[]> {
    const map = new Map<string, TrackEvent[]>();
    for (const event of events) {
      const userId = event.userId || event.deviceId;
      if (!map.has(userId)) map.set(userId, []);
      map.get(userId)!.push(event);
    }
    return map;
  }
}

路径分析

// analyzer/path.ts
interface PathNode {
  page: string;
  count: number;
}

interface PathEdge {
  from: string;
  to: string;
  count: number;
  conversionRate: number;
}

class PathAnalyzer {
  analyze(events: TrackEvent[]): { nodes: PathNode[]; edges: PathEdge[] } {
    const pageViews = events
      .filter((e) => e.eventName === 'page_view')
      .sort((a, b) => a.timestamp - b.timestamp);

    // 按用户分组
    const userPaths = this.groupByUser(pageViews);

    // 统计页面访问次数
    const nodeMap = new Map<string, number>();
    // 统计页面跳转次数
    const edgeMap = new Map<string, number>();

    for (const [, views] of userPaths) {
      for (let i = 0; i < views.length; i++) {
        const page = views[i].pagePath;
        nodeMap.set(page, (nodeMap.get(page) || 0) + 1);

        if (i < views.length - 1) {
          const nextPage = views[i + 1].pagePath;
          const edgeKey = `${page}→${nextPage}`;
          edgeMap.set(edgeKey, (edgeMap.get(edgeKey) || 0) + 1);
        }
      }
    }

    const nodes: PathNode[] = Array.from(nodeMap.entries())
      .map(([page, count]) => ({ page, count }))
      .sort((a, b) => b.count - a.count);

    const edges: PathEdge[] = Array.from(edgeMap.entries())
      .map(([key, count]) => {
        const [from, to] = key.split('→');
        const fromCount = nodeMap.get(from) || 0;
        return {
          from,
          to,
          count,
          conversionRate: fromCount > 0 ? count / fromCount : 0,
        };
      })
      .sort((a, b) => b.count - a.count);

    return { nodes, edges };
  }

  private groupByUser(events: TrackEvent[]): Map<string, TrackEvent[]> {
    const map = new Map<string, TrackEvent[]>();
    for (const event of events) {
      const userId = event.userId || event.deviceId;
      if (!map.has(userId)) map.set(userId, []);
      map.get(userId)!.push(event);
    }
    return map;
  }
}

留存分析

// analyzer/retention.ts
interface RetentionResult {
  cohortDate: string;       // 分组日期
  cohortSize: number;      // 分组人数
  retention: Record<number, number>; // { 第N天: 留存率 }
}

class RetentionAnalyzer {
  analyze(
    events: TrackEvent[],
    startDate: Date,
    endDate: Date,
    retentionDays: number[] = [1, 3, 7, 14, 30]
  ): RetentionResult[] {
    // 找出每日新增用户
    const dailyNewUsers = this.getDailyNewUsers(events, startDate, endDate);
    // 找出每日活跃用户
    const dailyActiveUsers = this.getDailyActiveUsers(events, startDate, endDate);

    return Array.from(dailyNewUsers.entries()).map(([date, newUsers]) => {
      const retention: Record<number, number> = {};

      for (const day of retentionDays) {
        const targetDate = this.addDays(date, day);
        const activeOnTargetDate = dailyActiveUsers.get(targetDate) || new Set<string>();
        const retained = newUsers.intersection(activeOnTargetDate).size;
        retention[day] = newUsers.size > 0 ? retained / newUsers.size : 0;
      }

      return {
        cohortDate: date,
        cohortSize: newUsers.size,
        retention,
      };
    });
  }

  private getDailyNewUsers(
    events: TrackEvent[],
    start: Date,
    end: Date
  ): Map<string, Set<string>> {
    const result = new Map<string, Set<string>>();
    const userFirstSeen = new Map<string, string>();

    for (const event of events) {
      const userId = event.userId || event.deviceId;
      const date = this.toDateString(event.timestamp);
      if (!userFirstSeen.has(userId)) {
        userFirstSeen.set(userId, date);
      }
    }

    for (const [userId, date] of userFirstSeen) {
      if (!result.has(date)) result.set(date, new Set());
      result.get(date)!.add(userId);
    }

    return result;
  }

  private getDailyActiveUsers(
    events: TrackEvent[],
    start: Date,
    end: Date
  ): Map<string, Set<string>> {
    const result = new Map<string, Set<string>>();

    for (const event of events) {
      const userId = event.userId || event.deviceId;
      const date = this.toDateString(event.timestamp);
      if (!result.has(date)) result.set(date, new Set());
      result.get(date)!.add(userId);
    }

    return result;
  }

  private toDateString(timestamp: number): string {
    return new Date(timestamp).toISOString().slice(0, 10);
  }

  private addDays(dateStr: string, days: number): string {
    const date = new Date(dateStr);
    date.setDate(date.getDate() + days);
    return date.toISOString().slice(0, 10);
  }
}

热力图数据采集

// tracker/heatmap.ts
interface HeatmapPoint {
  x: number;       // 相对页面宽度的百分比
  y: number;       // 相对页面高度的百分比
  pageX: number;   // 绝对页面坐标
  pageY: number;
  type: 'click' | 'move' | 'scroll';
  timestamp: number;
}

class HeatmapCollector {
  private points: HeatmapPoint[] = [];
  private moveThrottleTimer: number | null = null;
  private maxPoints = 5000; // 限制数据量

  init(): void {
    this.collectClicks();
    this.collectMouseMove();
    this.collectScrollDepth();
  }

  private collectClicks(): void {
    document.addEventListener('click', (e: MouseEvent) => {
      this.addPoint({
        x: (e.clientX / window.innerWidth) * 100,
        y: (e.clientY / window.innerHeight) * 100,
        pageX: e.pageX,
        pageY: e.pageY,
        type: 'click',
        timestamp: Date.now(),
      });
    }, true);
  }

  private collectMouseMove(): void {
    document.addEventListener('mousemove', (e: MouseEvent) => {
      if (this.moveThrottleTimer) return;
      this.moveThrottleTimer = window.setTimeout(() => {
        this.moveThrottleTimer = null;
      }, 500); // 500ms 节流

      this.addPoint({
        x: (e.clientX / window.innerWidth) * 100,
        y: (e.clientY / window.innerHeight) * 100,
        pageX: e.pageX,
        pageY: e.pageY,
        type: 'move',
        timestamp: Date.now(),
      });
    });
  }

  private collectScrollDepth(): void {
    let maxScrollPercent = 0;

    window.addEventListener('scroll', () => {
      const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
      const scrollPercent = scrollHeight > 0
        ? (window.scrollY / scrollHeight) * 100
        : 0;

      if (scrollPercent > maxScrollPercent) {
        maxScrollPercent = scrollPercent;
        // 每达到 25% 的整数倍时上报
        const milestones = [25, 50, 75, 100];
        for (const milestone of milestones) {
          if (scrollPercent >= milestone && maxScrollPercent < milestone) {
            tracker.track('scroll_depth', { depth_percent: milestone });
          }
        }
      }
    });
  }

  private addPoint(point: HeatmapPoint): void {
    if (this.points.length >= this.maxPoints) return;
    this.points.push(point);
  }

  // 上报热力图数据
  flush(): void {
    if (this.points.length === 0) return;
    tracker.track('heatmap_data', {
      page_width: window.innerWidth,
      page_height: document.documentElement.scrollHeight,
      viewport_width: window.innerWidth,
      viewport_height: window.innerHeight,
      points: this.points,
    });
    this.points = [];
  }
}

9. 常用分析平台

平台类型特点适用场景
Google Analytics (GA4)免费通用全球最大、功能全面、与 Google 生态打通海外业务、通用流量分析
神策数据国内商业私有化部署、全埋点、SQL 查询国内企业级、数据安全要求高
GrowingIO国内商业全埋点领先、可视化分析强产品增长分析、运营驱动
Mixpanel海外商业事件驱动、用户分群强SaaS 产品、用户行为分析
Amplitude海外商业行为队列分析、实时流式产品分析、增长黑客
自建方案自研完全可控、定制化强大厂、有专属数据团队

自建埋点方案架构

前端 SDK → Nginx 日志 → Kafka → Flink/Spark → 数据仓库 → BI 工具

          Node.js 日志服务

           ClickHouse

          数据查询 API

          内部 Dashboard
// 自建日志收集服务(Node.js)
// server/collect.ts
import express from 'express';
import { Kafka } from 'kafkajs';

const app = express();
app.use(express.json({ limit: '1mb' }));

const kafka = new Kafka({ brokers: ['kafka:9092'] });
const producer = kafka.producer();

app.post('/collect', async (req, res) => {
  const { appId, events } = req.body;

  // 基本校验
  if (!appId || !Array.isArray(events)) {
    return res.status(400).json({ error: 'Invalid payload' });
  }

  // 写入 Kafka
  try {
    await producer.send({
      topic: `tracking-${appId}`,
      messages: events.map((event: TrackEvent) => ({
        key: event.deviceId,
        value: JSON.stringify({
          ...event,
          serverTimestamp: Date.now(),
          ip: req.ip,
        }),
      })),
    });
  } catch (err) {
    console.error('Kafka write failed:', err);
  }

  // 204 无内容响应,减少带宽
  res.status(204).end();
});

app.listen(3001);

10. 隐私合规

GDPR/CCPA 合规要点

法规地区核心要求违规处罚
GDPR欧盟用户明确同意、数据可删除、数据可携带全球营收 4% 或 2000 万欧元
CCPA加州用户有权拒绝数据出售、有权删除数据每次违规 $7,500
个人信息保护法中国最小必要原则、明示同意、安全存储没收违法所得 + 罚款

数据脱敏

// tracker/privacy.ts
class PrivacyManager {
  // 数据脱敏规则
  private sensitiveFields = [
    'password', 'passwd', 'pwd',
    'phone', 'mobile', 'telephone',
    'email', 'mail',
    'id_card', 'idcard', 'identity',
    'credit_card', 'card_number',
    'address', 'addr',
    'token', 'secret', 'api_key',
  ];

  // 对事件数据进行脱敏
  sanitize(data: Record<string, unknown>): Record<string, unknown> {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(data)) {
      if (this.isSensitiveField(key)) {
        result[key] = this.maskValue(value);
      } else if (typeof value === 'object' && value !== null) {
        result[key] = this.sanitize(value as Record<string, unknown>);
      } else if (typeof value === 'string' && this.looksSensitive(value)) {
        result[key] = this.maskValue(value);
      } else {
        result[key] = value;
      }
    }
    return result;
  }

  private isSensitiveField(key: string): boolean {
    const lowerKey = key.toLowerCase();
    return this.sensitiveFields.some((field) => lowerKey.includes(field));
  }

  // 检测值是否像敏感数据
  private looksSensitive(value: string): boolean {
    // 手机号
    if (/^1[3-9]\d{9}$/.test(value)) return true;
    // 邮箱
    if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return true;
    // 身份证号
    if (/^\d{17}[\dXx]$/.test(value)) return true;
    // 银行卡号
    if (/^\d{16,19}$/.test(value)) return true;
    return false;
  }

  private maskValue(value: unknown): string {
    const str = String(value);
    if (str.length <= 2) return '**';
    if (str.length <= 6) return str[0] + '****' + str[str.length - 1];
    return str.slice(0, 2) + '****' + str.slice(-2);
  }
}

用户同意管理

// tracker/consent.ts
interface ConsentStatus {
  necessary: boolean;     // 必要 Cookie(始终开启)
  analytics: boolean;     // 分析 Cookie
  marketing: boolean;     // 营销 Cookie
  preferences: boolean;   // 偏好 Cookie
}

class ConsentManager {
  private CONSENT_KEY = '__tracker_consent__';

  // 获取用户同意状态
  getConsent(): ConsentStatus {
    const stored = localStorage.getItem(this.CONSENT_KEY);
    if (stored) {
      try {
        return JSON.parse(stored);
      } catch {
        // 无效数据,需要重新获取同意
      }
    }
    return { necessary: true, analytics: false, marketing: false, preferences: false };
  }

  // 用户授予同意
  grantConsent(categories: Partial<ConsentStatus>): void {
    const consent = { ...this.getConsent(), ...categories };
    localStorage.setItem(this.CONSENT_KEY, JSON.stringify(consent));

    // 根据同意状态启用/禁用埋点
    if (consent.analytics) {
      tracker.setCommonProperties({ consent_analytics: true });
    } else {
      // 禁用分析类埋点
      tracker.use({
        name: 'consent-filter',
        beforeTrack: (event) => {
          if (event.eventName.startsWith('analytics_')) return null;
          return event;
        },
      });
    }
  }

  // 用户撤回同意
  revokeConsent(): void {
    localStorage.removeItem(this.CONSENT_KEY);
    // 清除已设置的 Cookie
    this.clearTrackingCookies();
  }

  // 检查是否需要弹出同意对话框
  needsConsent(): boolean {
    // GDPR:欧盟用户必须获取同意
    // CCPA:加州用户必须提供"拒绝"选项
    return !localStorage.getItem(this.CONSENT_KEY);
  }

  private clearTrackingCookies(): void {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
      const name = cookie.split('=')[0].trim();
      if (name.startsWith('__tracker_') || name.startsWith('_ga') || name.startsWith('_gid')) {
        document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
      }
    }
  }
}

11. AB 测试与埋点

// tracker/ab-test.ts
interface Experiment {
  id: string;               // 实验ID
  name: string;             // 实验名称
  variants: Variant[];      // 实验变体
  trafficPercent: number;   // 流量分配比例 0-1
  metrics: string[];        // 观测指标
}

interface Variant {
  id: string;               // 变体ID
  name: string;             // 变体名称(control / treatment)
  weight: number;           // 权重(流量分配比例)
}

class ABTestManager {
  private experiments = new Map<string, Experiment>();
  private userVariants = new Map<string, Map<string, string>>(); // userId → experimentId → variantId
  private STORAGE_KEY = '__ab_variants__';

  // 注册实验
  registerExperiment(experiment: Experiment): void {
    this.experiments.set(experiment.id, experiment);
  }

  // 为用户分配变体
  getVariant(experimentId: string, userId?: string): string {
    const experiment = this.experiments.get(experimentId);
    if (!experiment) return 'control';

    const uid = userId || tracker.getDeviceId?.() || 'anonymous';

    // 检查是否已分配
    const existing = this.getUserVariant(uid, experimentId);
    if (existing) return existing;

    // 流量控制:不在实验流量范围内的用户返回默认
    if (!this.isInExperiment(uid, experiment)) {
      return 'control';
    }

    // 按权重分配变体
    const variant = this.assignVariant(uid, experiment);
    this.saveVariant(uid, experimentId, variant);

    // 上报实验分组
    tracker.track('ab_experiment_assign', {
      experiment_id: experiment.id,
      experiment_name: experiment.name,
      variant_id: variant,
      is_new_assignment: true,
    });

    return variant;
  }

  // 判断用户是否在实验流量内
  private isInExperiment(userId: string, experiment: Experiment): boolean {
    const hash = this.hashUserId(userId, experiment.id);
    return hash < experiment.trafficPercent;
  }

  // 分配变体(加权随机)
  private assignVariant(userId: string, experiment: Experiment): string {
    const hash = this.hashUserId(userId, experiment.id + '_variant');
    let cumulative = 0;
    for (const variant of experiment.variants) {
      cumulative += variant.weight;
      if (hash < cumulative) return variant.id;
    }
    return experiment.variants[0].id;
  }

  // 一致性哈希:同一用户总是分配到同一变体
  private hashUserId(userId: string, salt: string): number {
    const str = `${userId}:${salt}`;
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
    }
    return (Math.abs(hash) % 10000) / 10000; // 0-1 之间
  }

  // 记录实验指标
  trackMetric(experimentId: string, metricName: string, value: number): void {
    const variant = this.getUserVariant(
      tracker.getUserId?.() || 'anonymous',
      experimentId
    );
    if (!variant) return;

    tracker.track('ab_metric', {
      experiment_id: experimentId,
      variant_id: variant,
      metric_name: metricName,
      metric_value: value,
    });
  }

  // 显著性检验(简化版 Z-test)
  calculateSignificance(
    control: { count: number; sum: number; sumSquares: number },
    treatment: { count: number; sum: number; sumSquares: number }
  ): { pValue: number; isSignificant: boolean; confidence: number } {
    const mean1 = control.sum / control.count;
    const mean2 = treatment.sum / treatment.count;
    const var1 = control.sumSquares / control.count - mean1 * mean1;
    const var2 = treatment.sumSquares / treatment.count - mean2 * mean2;

    const se = Math.sqrt(var1 / control.count + var2 / treatment.count);
    const zScore = (mean2 - mean1) / se;
    const pValue = 2 * (1 - this.normalCDF(Math.abs(zScore)));

    return {
      pValue,
      isSignificant: pValue < 0.05,
      confidence: 1 - pValue,
    };
  }

  // 正态分布 CDF 近似
  private normalCDF(x: number): number {
    const a1 = 0.254829592;
    const a2 = -0.284496736;
    const a3 = 1.421413741;
    const a4 = -1.453152027;
    const a5 = 1.061405429;
    const p = 0.3275911;
    const sign = x < 0 ? -1 : 1;
    x = Math.abs(x) / Math.SQRT2;
    const t = 1.0 / (1.0 + p * x);
    const y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
    return 0.5 * (1.0 + sign * y);
  }

  private getUserVariant(userId: string, experimentId: string): string | null {
    if (!this.userVariants.has(userId)) {
      this.loadFromStorage(userId);
    }
    return this.userVariants.get(userId)?.get(experimentId) || null;
  }

  private saveVariant(userId: string, experimentId: string, variantId: string): void {
    if (!this.userVariants.has(userId)) {
      this.userVariants.set(userId, new Map());
    }
    this.userVariants.get(userId)!.set(experimentId, variantId);
    this.persistToStorage();
  }

  private loadFromStorage(userId: string): void {
    try {
      const stored = localStorage.getItem(this.STORAGE_KEY);
      if (stored) {
        const data = JSON.parse(stored);
        this.userVariants.set(userId, new Map(Object.entries(data)));
      }
    } catch { /* ignore */ }
  }

  private persistToStorage(): void {
    const data: Record<string, Record<string, string>> = {};
    for (const [userId, experiments] of this.userVariants) {
      data[userId] = Object.fromEntries(experiments);
    }
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
  }
}

// 使用示例
const abTest = new ABTestManager();

abTest.registerExperiment({
  id: 'exp_checkout_btn_color',
  name: '结账按钮颜色测试',
  variants: [
    { id: 'control', name: '蓝色按钮', weight: 0.5 },
    { id: 'treatment', name: '红色按钮', weight: 0.5 },
  ],
  trafficPercent: 0.2, // 20% 流量
  metrics: ['checkout_click_rate', 'order_conversion_rate'],
});

// React 组件中使用
function CheckoutButton() {
  const variant = abTest.getVariant('exp_checkout_btn_color');

  const bgColor = variant === 'treatment' ? 'red' : 'blue';

  return (
    <button
      style={{ backgroundColor: bgColor }}
      onClick={() => {
        abTest.trackMetric('exp_checkout_btn_color', 'checkout_click_rate', 1);
        tracker.track('checkout_click', { variant });
      }}
    >
      立即结算
    </button>
  );
}

常见坑点

#坑点表现解决方案
1埋点数据丢失页面卸载时部分事件未上报使用 sendBeacon + visibilitychange、批量上报 + 离线缓存
2SPA 路由 PV 不准SPA 切换路由不触发页面刷新,PV 未统计劫持 history API + 监听 hashchange/popstate
3曝光埋点性能差列表 1000+ 元素全部注册 IntersectionObserver,主线程卡顿虚拟滚动 + 批量观察 + threshold 优化 + 防抖
4数据量过大无痕埋点全量采集,日增 TB 级数据采样率控制 + 限流 + 数据分级 + 只采集关键页面
5埋点与业务耦合业务代码充斥埋点逻辑,难以维护装饰器 / Hook / 指令封装、埋点配置化管理
6公共属性污染setCommonProperties 设置的属性覆盖业务属性属性合并采用”后覆盖前”策略,业务属性优先级更高
7UV 统计不准隐私模式下 Cookie/localStorage 不可用,UV 虚高多级标识(Cookie → localStorage → fingerprint)、服务端去重
8埋点版本不一致前端发版后旧版埋点仍在上报,数据口径混乱埋点事件携带 SDK 版本号 + App 版本号,后端按版本过滤
9跨域上报被拦截CORS 限制导致上报请求失败服务端配置 CORS、1x1 GIF 方式兜底、代理转发
10隐私合规违规未获取用户同意就采集数据,被监管处罚实现 ConsentManager、数据脱敏、按地区差异化策略

最佳实践

  1. 混合埋点策略 — 核心业务事件用代码埋点保证精度,辅助行为用无痕埋点降低成本
  2. 事件命名规范 — 采用 对象_动作 格式(button_click / page_view / order_submit),全团队统一
  3. 属性设计前瞻 — 事件属性预留扩展位,避免频繁变更事件结构
  4. 埋点文档化 — 维护埋点字典(事件名、属性、类型、说明),与代码同步更新
  5. 上报前校验 — SDK 内置数据校验(必填字段、类型检查、枚举值限制),脏数据不入库
  6. 批量上报优先 — 合并多条事件为一次请求,减少网络开销,提升成功率
  7. 离线缓存兜底 — IndexedDB 缓存未上报数据,网络恢复后自动补发
  8. 采样率分级 — 核心事件 100% 采集,辅助事件 10% 采样,调试事件 1% 采样
  9. 性能数据独立上报 — 性能指标不与业务事件混合,避免批量延迟导致数据时效性差
  10. 埋点治理流程 — 上线前 Code Review 检查埋点、埋点自动化测试、上线后数据校验告警

面试题

1. 三种埋点方式(代码埋点、可视化埋点、无痕埋点)各自的优缺点是什么?

:代码埋点的优点是灵活性最高、数据精度最好,可以采集任意自定义业务数据(如下单金额、商品 ID);缺点是侵入性强、开发成本高、每个埋点需要编码且需发版生效。可视化埋点的优点是无需编码、运营可自行配置、配置即生效;缺点是灵活性受限于圈选能力,只能基于 DOM 元素定位,无法采集复杂业务语义数据。无痕埋点的优点是接入成本极低(接入 SDK 即可)、全量采集不怕遗漏;缺点是数据精度低(需后期人工映射事件含义)、数据量极大(带来存储和计算压力)、无法采集业务特有数据(如订单金额)。


2. 无痕埋点的原理是什么?如何实现全局事件代理?

:无痕埋点的核心原理是在 document 上通过事件捕获阶段代理所有用户交互事件。具体实现:在 document.addEventListener('click', handler, true) 注册捕获阶段监听器,这样在事件冒泡到具体元素之前就能拦截。当事件触发时,通过 event.target 获取目标元素,然后用 XPath 或 CSS Selector 定位元素在 DOM 树中的位置,记录标签名、文本内容、类名、位置坐标等信息。元素定位的两种方案:XPath 从根节点逐层描述路径(如 /html/body/div[2]/button),CSS Selector 用 id/class/tag 组合定位。两种方案各有优劣——XPath 精确但脆弱(DOM 变动易失效),CSS Selector 容错但可能不唯一。实际方案通常两者结合,优先用 id 定位,其次 XPath,最后 CSS Selector。


3. 曝光埋点如何实现?如何判定”有效曝光”?

:曝光埋点使用 IntersectionObserver API 实现。核心流程:为需要监听曝光的元素注册 Observer,当元素进入视口且可见比例超过阈值(threshold,通常 0.5 即 50%)时触发回调。“有效曝光”的判定条件通常包括三个维度:可见比例(intersectionRatio >= threshold,默认 50%)、停留时长(minDuration,如 500ms,防止快速划过算作曝光)、曝光次数(trackOnce 控制是否只上报一次)。实现要点:进入视口时记录 enterTime,离开视口时计算 duration = Date.now() - enterTime,只有 duration >= minDuration 才上报。列表场景下需注意性能,避免对大量元素逐一注册 Observer,应结合虚拟滚动只观察可视区域内的元素。


4. SPA 应用如何准确统计 PV?为什么传统方式不准确?

:传统 PV 统计依赖页面刷新(window.onload / performance.navigation),但 SPA 应用路由切换不会触发页面刷新,导致路由变化时 PV 不会自动统计。解决方案是监听路由变化事件,具体实现需要覆盖三种路由模式:history 模式需要劫持 history.pushStatehistory.replaceState(因为它们不触发任何事件),同时监听 popstate 事件(浏览器前进/后退);hash 模式监听 hashchange 事件。React 项目推荐在 useLocationuseEffect 中上报;Vue 项目推荐在 router.afterEach 钩子中上报。此外还需注意:首次加载需要单独上报一次 PV;路由切换时记录上一页面的停留时长;区分页面跳转和参数变化(同页面不同参数是否算新 PV)。


5. sendBeacon 相比其他上报方式有什么优势?有什么限制?

:sendBeacon 的核心优势是页面卸载安全——它将数据交给浏览器在空闲时发送,不阻塞页面卸载,即使页面已经关闭数据也能成功发出。这是 XMLHttpRequestfetch(不带 keepalive)做不到的,它们在页面卸载时会被浏览器取消。此外 sendBeacon 是异步的,不阻塞主线程,对用户体验无影响。限制包括:数据大小限制约 64KB(Chrome),超出会被拒绝;需要服务端配置 CORS(只能发 POST 请求);无法获取响应内容(只能知道是否成功入队);浏览器兼容性(IE 不支持)。实际策略是页面卸载场景优先使用 sendBeacon,普通场景使用 fetch + keepalive 或 XMLHttpRequest,1x1 GIF 作为兼容兜底。


6. 漏斗分析的流程是什么?如何定位流失环节?

:漏斗分析流程分五步:第一步定义漏斗步骤——确定关键路径上的事件序列(如 首页→搜索→详情→购物车→下单→支付);第二步确定时间窗口——用户必须在窗口内完成所有步骤才算有效转化(通常 7-30 天);第三步按用户分组——将同一用户的事件按时间排序,找到最早满足步骤序列的路径;第四步统计各步骤人数——计算每步到达人数、步骤转化率和整体转化率;第五步分析流失原因——对流失率最高的步骤下钻分析(用户属性、设备类型、来源渠道等维度交叉分析)。定位流失环节的关键指标是步骤流失率(1 - 步骤转化率),流失率最高的步骤就是最需要优化的环节。例如详情页到购物车流失 50%,需要分析是价格问题、体验问题还是信息不足。


7. 数据上报策略有哪些?如何保证数据不丢失?

:上报策略包括四种方式:sendBeacon(页面卸载安全、64KB 限制、适合离场数据)、1x1 GIF(跨域简单、URL 长度限制约 2KB、适合单条轻量数据)、fetch + keepalive(现代浏览器推荐、64KB 限制、适合批量数据)、XMLHttpRequest(体积无限制、但页面卸载不安全)。保证数据不丢失的策略:批量上报——合并多条事件为一次请求,减少网络请求次数提升成功率;离线缓存——网络断开时将数据写入 IndexedDB,网络恢复后自动补发;重试机制——上报失败后指数退避重试,超过最大重试次数后持久化缓存;页面卸载上报——监听 visibilitychangehidden 状态)和 pagehide 事件,在页面关闭前 flush 队列;数据确认——服务端返回成功确认后才能从队列移除,否则重发。


8. 前端埋点需要遵守哪些隐私合规要求?如何实现?

:主要合规法规包括 GDPR(欧盟)、CCPA(加州)、个人信息保护法(中国)。核心要求:明示同意——采集数据前必须获取用户明确同意,不能默认开启;最小必要——只采集业务必需的数据,不超范围采集;数据可删除——用户有权要求删除其个人数据(“被遗忘权”);数据可携带——用户有权获取其数据的副本;数据安全——传输加密、存储加密、访问控制。前端实现方式:ConsentManager 管理用户同意状态,按类别(必要/分析/营销)控制埋点开关;数据脱敏——对手机号、邮箱、身份证号等敏感字段自动遮蔽(138****1234);数据分级——区分个人身份信息(PII)和匿名行为数据,PII 需更严格的保护;Cookie 政策——非必要 Cookie 需用户同意后才能设置,提供”拒绝全部”选项;地区差异化——根据用户 IP 或地区设置自动应用对应法规要求。


相关链接