陌上人如玉
公子世无双

8.8 埋点体系

8.8 埋点体系

一句话:埋点最难的不是「怎么发一条数据」,而是「发得对(事件与属性有规范)、发得准(曝光判定与去重)、发得全(不漏)、发得不重(不重复)、丢得起(上报失败可重试)、合规(不采集隐私)」——所以埋点是一个「数据采集系统」,而不是「一个 track 函数」。

一、埋点的三类与三方式

⭐⭐⭐ 埋点的三类(按「实现难度」排序):

① 【点击/交互埋点】——最简单
   「用户点了什么」→ 事件名 + 关键属性(元素 + 位置 + 上下文)
   ✅ 实现简单(事件委托即可);⚠️ 难点在「命名规范」与「属性规范」

② 【业务埋点】——中等
   「业务动作」(下单、支付、分享、搜索)
   ⭐ 难点在「**与业务语义对齐**」(「下单成功」的标准是什么?「支付」算「发起」还是「成功」?)
   ✅ 通常由业务代码「显式调用」(因为「只有业务知道发生了什么」)

③ 【曝光埋点】——⭐ 最难(也是「最容易做错」的)
   「用户**看到了**什么」
   ⚠️ 难在「**什么是『看到了』**」:
     · 进入视口就算?还是要「可见比例 ≥ 50%」?
     · 瞬间闪过算不算?(→ 需要「时长阈值」)
     · 滚出去又滚回来算一次还是两次?(→ 需要「去重」)
     · 页面在后台(用户切走了)算不算?(→ 需要「可见性判断」)
方式 写法 优点 缺点
命令式(track('xxx', {...})) 业务代码里手动调 ⭐ 精确、可带复杂属性 ⚠️ 容易漏、容易重复、与 UI 代码耦合
⭐ 声明式(data-track="xxx") 标签上加属性,SDK 自动采集 ⭐ 不侵入业务逻辑、统一 ⚠️ 属性受限(复杂数据要额外传)
自动埋点(全量采集) SDK 自动采集所有点击/路由 ✅ 零成本启动 ⚠️ 数据噪声大、属性不语义、⭐ 合规风险
⭐⭐⭐ 实践建议(多数团队的成熟做法):

   · 【点击】→ ⭐ **声明式(`data-track`)+ 事件委托**(一处实现,全站可用)
   · 【业务】→ ⭐ **命令式**(`track('order_paid', {...})`,因为「只有业务知道」)
   · 【曝光】→ ⭐ **声明式组件 `<Exposure>`**(内部用 IntersectionObserver)
   · ⚠️ **不要做「全量自动埋点」**(除非「早期没规范、先粗采」)——
     它会让「数据团队淹没在噪声里」,而且「把用户输入也采了」是合规风险。

二、曝光判定:行业标准的四个条件

⭐⭐⭐ 「有效曝光」通常要同时满足四个条件(这是最容易被做错的地方):

   ① 【元素在视口内】—— IntersectionObserver 的 `isIntersecting`
   ② ⭐ 【可见比例 ≥ 阈值】—— `intersectionRatio >= 0.5`(常见 50%,也有用 30%/60%)
      ⚠️ 注意:`threshold` 要「配成数组」才能拿到「比例变化」;
        且「元素比视口大」时「比例永远到不了 1」
   ③ ⭐ 【持续时长 ≥ 阈值】—— 常见 **1 秒**
      ⚠️ 这就是「闪过不算曝光」——用户快速滚动时元素「一闪而过」不该计
      ⭐ 实现:进入视口时「起一个定时器」,离开时「取消」;
        定时器到点才「真的上报」
   ④ ⭐ 【页面可见】—— `document.visibilityState === 'visible'`
      ⚠️ 页面切到后台时,元素「在视口里但用户没看」→ 不该算
      ✅ 注意「切回来时」要「重新计时」(不能把「后台的时间」算进时长)

   ⑤ 【去重】—— ⭐ 同一个「曝光单元」在一次会话里只报一次
      · 「曝光单元」= 「元素(用 key 标识)+ 与它绑定的业务数据(如商品 id)」
      · ⚠️ 「滚出去又滚回来」不该再报(否则「一条商品被浏览 10 次」)
      · ⚠️ 但也有产品需要「每次都报」(如「广告曝光按次计费」)→ ⭐ 要显式区分
/** ⭐⭐ 一个严谨的曝光 Hook(四个条件 + 去重 + 后台暂停) */
import { useEffect, useRef } from 'react';

export interface ExposureOptions {
  /** ⭐ 曝光事件的标识(⭐ 必须「稳定且唯一」,如 `home-product-${id}`) */
  key: string;
  /** 事件名 */
  event: string;
  /** 附加属性 */
  props?: Record<string, unknown>;
  /** ⭐ 可见比例阈值(默认 0.5) */
  threshold?: number;
  /** ⭐ 持续时长阈值(ms,默认 1000) */
  durationMs?: number;
  /** ⭐ 是否「每次进入都报」(默认 false:一次会话只报一次) */
  repeat?: boolean;
  /** 上报函数 */
  onExpose: (payload: { key: string; event: string; props: Record<string, unknown>; ratio: number; durationMs: number }) => void;
}

export function useExposure<T extends Element>(options: ExposureOptions) {
  const {
    key, event, props = {},
    threshold = 0.5, durationMs = 1000, repeat = false,
    onExpose,
  } = options;

  const ref = useRef<T | null>(null);
  /** ⭐ 已上报标记(去重) */
  const reportedRef = useRef(false);
  /** ⭐ 计时器(进入视口时起、离开时取消) */
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  /** ⭐ 计时「开始时刻」(用于报告「实际可见时长」) */
  const startedAtRef = useRef(0);
  /** ⭐ 当前是否「在视口内」(用于「切回前台时重新计时」) */
  const inViewportRef = useRef(false);

  const clearTimer = () => {
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
  };

  /** ⭐⭐ 只有「在视口内 + 页面可见」时才计时 */
  const maybeStartTimer = () => {
    if (timerRef.current) return;                              // 已计时
    if (!inViewportRef.current) return;
    if (document.visibilityState !== 'visible') return;        // ⭐ 条件 ④
    if (!repeat && reportedRef.current) return;                // ⭐ 条件 ⑤(去重)

    startedAtRef.current = Date.now();
    timerRef.current = setTimeout(() => {
      timerRef.current = null;
      // ⭐ 到点后再校验一次(因为「这 1 秒里可能切走了」)
      if (!inViewportRef.current) return;
      if (document.visibilityState !== 'visible') return;
      if (!repeat && reportedRef.current) return;

      reportedRef.current = true;
      onExpose({
        key, event, props,
        ratio: lastRatioRef.current,
        durationMs: Date.now() - startedAtRef.current,
      });
    }, durationMs);
  };

  const lastRatioRef = useRef(0);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    /** ⭐ 条件 ①②:用 IntersectionObserver 判断「可见比例」 */
    const io = new IntersectionObserver(
      (entries) => {
        const entry = entries[entries.length - 1];
        if (!entry) return;
        lastRatioRef.current = entry.intersectionRatio;

        const visible = entry.isIntersecting && entry.intersectionRatio >= threshold;
        inViewportRef.current = visible;

        if (visible) maybeStartTimer();
        else clearTimer();                                     // ⭐ 条件 ③:离开就取消计时
      },
      // ⭐ 用「数组阈值」才能拿到「比例变化」(⚠️ 只给一个 0.5 会「跨过就触发一次」,拿不到更细的比例)
      { threshold: [0, 0.25, threshold, 0.75, 1] }
    );

    io.observe(el);

    /** ⭐ 条件 ④:页面可见性变化时「暂停/恢复计时」 */
    const onVisibility = () => {
      if (document.visibilityState === 'visible') {
        maybeStartTimer();                                     // ⭐ 切回来「重新计时」
      } else {
        clearTimer();                                          // ⭐ 切走「取消计时」
      }
    };
    document.addEventListener('visibilitychange', onVisibility);

    return () => {
      io.disconnect();
      document.removeEventListener('visibilitychange', onVisibility);
      clearTimer();
    };
    // ⚠️ 依赖要「稳定」(key 变了才重新观察,否则「每次渲染都重建 IO」)
  }, [key, threshold, durationMs, repeat]);

  return ref;
}
// ✅ 声明式曝光组件(⭐ 用起来几乎零成本)
export function Exposure({
  trackKey, event, props, threshold, durationMs, repeat = false, children,
}: {
  trackKey: string;
  event: string;
  props?: Record<string, unknown>;
  threshold?: number;
  durationMs?: number;
  repeat?: boolean;
  children: React.ReactNode;
}) {
  const track = useTracker();

  // ⚠️ 注意:`ref` 要绑到「真实的 DOM 元素」上(所以这里包一层 div)
  //    ⭐ 如果不想「多一层 DOM」,可以让「子组件」接收 ref(或用 `asChild` 模式,见 1.11)
  const ref = useExposure<HTMLDivElement>({
    key: trackKey,
    event,
    props,
    threshold,
    durationMs,
    repeat,
    onExpose: (payload) => {
      // ⭐ 曝光事件统一走「同一个上报通道」
      track(payload.event, {
        ...payload.props,
        // ⭐ 曝光特有的属性(便于分析「停留时长」)
        __exposure_key: payload.key,
        __visible_ratio: Number(payload.ratio.toFixed(2)),
        __visible_ms: payload.durationMs,
      });
    },
  });

  return <div ref={ref}>{children}</div>;
}

// 用法
function ProductGrid({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p, index) => (
        <li key={p.id}>
          {/* ⭐ 声明式曝光:一行搞定 */}
          <Exposure
            trackKey={`product-${p.id}`}
            event="product_impression"
            props={{ product_id: p.id, position: index + 1, list: 'home_grid' }}
            threshold={0.5}
            durationMs={1000}
          >
            <ProductCard product={p} />
          </Exposure>
        </li>
      ))}
    </ul>
  );
}

三、数据规范:事件与属性的「白名单」

⭐⭐⭐ 「埋点数据质量」的第一道防线是「规范」,第二道是「工具强制」:

   【事件名规范】
     · 用 `snake_case`(`product_impression`、`order_paid`)
     · 用「`对象_动作`」的结构(`cart_add`、`search_submit`)
     · ⭐ **集中定义**(不要「各处手写字符串」——否则 `product_click` / `productClick` 并存)

   【属性规范】
     · ⭐ **每个事件有「属性白名单 + 类型」**(用 TS 类型或 schema 定义)
     · 属性名用 `snake_case`;✗ 不要把「业务数据」塞成一个「大对象」
     · ⭐ **禁止把「用户输入」作为属性值**(合规 + 数据噪声)
       (✅ 只采「用户行为」:点了什么、搜了几次、结果数是多少)

   【工具强制】⭐
     · 「事件名/属性」用 **TS 类型**约束(写错就编译失败)
     · 「属性白名单」在**运行时**也校验(⭐ 防止「把不该传的对象传进来」)
     · ⭐ **「埋点字典」自动生成文档**(数据团队能查到「这个事件有哪些属性」)
/** ⭐⭐ 用 TS 类型「约束」事件与属性(写错就编译失败) */
export interface TrackingEvents {
  // —— 点击类 ——
  'product_click': { product_id: string; position: number; list: string };
  'cart_add': { product_id: string; price: number; from: string };
  'search_submit': { keyword_length: number; result_count: number };  // ⭐ 只记「长度」不记关键词
  'share_click': { channel: 'wechat' | 'weibo' | 'copy'; content_type: string };

  // —— 曝光类 ——
  'product_impression': { product_id: string; position: number; list: string; __visible_ratio: number; __visible_ms: number };
  'banner_impression': { banner_id: string; position: number };

  // —— 业务类 ——
  'order_created': { order_id: string; amount: number; item_count: number; coupon: boolean };
  'order_paid': { order_id: string; amount: number; pay_channel: 'alipay' | 'wechat' | 'card'; ms_since_created: number };
}

export type TrackingEvent = keyof TrackingEvents;
export type TrackingProps<E extends TrackingEvent> = TrackingEvents[E];

/** ⭐⭐ 类型安全 + 运行时校验的 track 函数 */
export function createTracker(options: {
  /** 上报端点 */
  endpoint: string;
  release: string;
  userId: () => string | null;
  /** ⭐ 属性白名单(运行时校验:只允许这些 key) */
  allowedProps?: Partial<Record<TrackingEvent, string[]>>;
}) {
  const { endpoint, release, userId } = options;

  let queue: Array<Record<string, unknown>> = [];
  let timer: ReturnType<typeof setTimeout> | null = null;
  /** ⭐ 会话内的「事件序号」(便于「发现丢失/乱序」,与 7.14 的序号思想一致) */
  let seq = 0;
  const sessionId = getSessionId();

  function flush(useBeacon = false) {
    if (queue.length === 0) return;
    const body = JSON.stringify({ release, sessionId, events: queue });
    queue = [];
    if (timer) { clearTimeout(timer); timer = null; }

    if (useBeacon && typeof navigator.sendBeacon === 'function') {
      navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
      return;
    }
    void fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body,
      keepalive: true,
    }).catch(() => {
      // ⭐⭐ 上报失败 → 放回队列(⚠️ 但要有上限,否则「离线很久」会内存爆)
      try {
        const parsed = JSON.parse(body) as { events: Array<Record<string, unknown>> };
        queue = [...parsed.events, ...queue].slice(0, 500);
      } catch { /* 忽略 */ }
    });
  }

  // ⭐ 定时批量 + 页面隐藏时 flush
  timer = setTimeout(() => flush(false), 3000);
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush(true);
  });
  window.addEventListener('pagehide', () => flush(true));

  return function track<E extends TrackingEvent>(event: E, props: TrackingProps<E>): void {
    // ⭐ ① 运行时白名单校验(防止「传了不该传的属性」)
    const allowed = options.allowedProps?.[event];
    const safeProps: Record<string, unknown> = {};
    if (allowed) {
      for (const key of allowed) {
        // @ts-expect-error 动态取值
        if (props[key] !== undefined) safeProps[key] = props[key];
      }
      if (process.env.NODE_ENV !== 'production') {
        const extra = Object.keys(props).filter((k) => !allowed.includes(k));
        if (extra.length) console.warn(`[track] ${event} 含有未在白名单中的属性:${extra.join(', ')}(已丢弃)`);
      }
    } else {
      Object.assign(safeProps, props as Record<string, unknown>);
    }

    queue.push({
      event,
      props: safeProps,
      // ⭐ ② 上下文(SDK 自动带,业务不用管)
      seq: ++seq,
      user_id: userId(),
      session_id: sessionId,
      page_path: location.pathname,
      // ⚠️ referrer 只记「同源路径」(避免把外部 URL 的 query 带出去)
      referrer_path: safeReferrer(),
      release,
      // ⭐ 设备与网络(用于「分维度分析」)
      ua_platform: getPlatform(),
      connection: (navigator as unknown as { connection?: { effectiveType?: string } }).connection?.effectiveType ?? null,
      // ⭐ 时间(用「服务端校准过的偏移」更好,见下文「坑」)
      client_ts: Date.now(),
      tz_offset_min: new Date().getTimezoneOffset(),
    });

    // ⭐ 队列到一定量立刻发(避免「长时间不报」)
    if (queue.length >= 20) flush(false);
  };
}

function getSessionId(): string {
  const KEY = '__track_session';
  try {
    let id = sessionStorage.getItem(KEY);
    if (!id) { id = crypto.randomUUID(); sessionStorage.setItem(KEY, id); }
    return id;
  } catch { return 'no-session'; }
}

function safeReferrer(): string | null {
  if (!document.referrer) return null;
  try {
    const u = new URL(document.referrer);
    // ⭐ 只有「同源」才记路径(否则只记「有外部来源」这个事实)
    return u.origin === location.origin ? u.pathname : 'external';
  } catch { return null; }
}

function getPlatform(): string {
  const ua = navigator.userAgent;
  if (/iPhone|iPad|iPod/.test(ua)) return 'ios';
  if (/Android/.test(ua)) return 'android';
  if (/Mac OS X/.test(ua)) return 'macos';
  if (/Windows/.test(ua)) return 'windows';
  return 'other';
}

四、完整实现:一个埋点 SDK(250 行)

// tracker-sdk.ts —— 埋点 SDK(类型安全 + 声明式点击 + 曝光 + 批量上报 + 上下文)
import { createTracker, type TrackingEvents, type TrackingEvent, type TrackingProps } from './schema';

/** ============ 1. 声明式点击:`data-track` + 事件委托(⭐ 一处实现,全站可用) ============ */
export function installDeclarativeClicks(
  container: HTMLElement,
  onTrack: (event: string, props: Record<string, unknown>) => void
) {
  // ⭐⭐ 用「事件委托」:不用给每个元素绑监听(⭐ 与 4.9 的合成事件思想一致)
  const handler = (e: MouseEvent) => {
    const target = e.target as HTMLElement | null;
    if (!target) return;

    // ⭐ 向上找「最近的带 data-track 的元素」(⭐ 这样「按钮里的图标」被点也能命中)
    const el = target.closest<HTMLElement>('[data-track]');
    if (!el) return;

    const event = el.dataset.track;
    if (!event) return;

    // ⭐ 属性从 `data-track-*` 上取(⭐ 值只有「字符串」→ 用 `data-track-props` 传 JSON)
    const props: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(el.dataset)) {
      if (!key.startsWith('track') || key === 'track' || key === 'trackProps') continue;
      // `trackProductId` → `product_id`
      const propKey = key
        .replace(/^track/, '')
        .replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
        .replace(/^_/, '');
      props[propKey] = value;
    }

    // ⭐ 复杂属性用 `data-track-props='{"a":1}'`
    if (el.dataset.trackProps) {
      try { Object.assign(props, JSON.parse(el.dataset.trackProps)); }
      catch { console.warn('[track] data-track-props 不是合法 JSON'); }
    }

    // ⭐ 补充「位置信息」(⭐ 对「列表点击」分析很重要)
    props.__position = getPositionInList(el);

    onTrack(event, props);
  };

  container.addEventListener('click', handler, { capture: true });
  return () => container.removeEventListener('click', handler, { capture: true });
}

/** ⭐ 「在列表里的第几项」(用于「位置分析」) */
function getPositionInList(el: HTMLElement): number | null {
  const listItem = el.closest<HTMLElement>('[data-list-item]');
  if (!listItem?.parentElement) return null;
  const siblings = [...listItem.parentElement.children];
  return siblings.indexOf(listItem) + 1;
}
/** ============ 2. React 集成:Provider + Hook ============ */
import { createContext, useContext, useMemo, useRef, useEffect } from 'react';

const TrackerContext = createContext<((event: TrackingEvent, props: Record<string, unknown>) => void) | null>(null);

export function TrackerProvider({
  endpoint, release, userId, allowedProps, children,
}: {
  endpoint: string;
  release: string;
  userId: () => string | null;
  allowedProps?: Partial<Record<TrackingEvent, string[]>>;
  children: React.ReactNode;
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);

  const track = useMemo(
    () => createTracker({ endpoint, release, userId, allowedProps }) as (event: TrackingEvent, props: Record<string, unknown>) => void,
    [endpoint, release, userId, allowedProps]
  );

  // ⭐ 挂上「声明式点击」的委托(⭐ 在根容器上挂一次即可)
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    return installDeclarativeClicks(el, track);
  }, [track]);

  return (
    <TrackerContext.Provider value={track}>
      <div ref={containerRef} style={{ display: 'contents' }}>{children}</div>
    </TrackerContext.Provider>
  );
}

export function useTracker() {
  const track = useContext(TrackerContext);
  if (!track) throw new Error('useTracker 必须在 <TrackerProvider> 内使用');
  return track;
}

/** ⭐⭐ 类型安全的 Hook(写错事件名/属性 → 编译错误) */
export function useTypedTracker() {
  const track = useTracker();
  return useMemo(
    () => <E extends TrackingEvent>(event: E, props: TrackingProps<E>) =>
      track(event, props as Record<string, unknown>),
    [track]
  );
}
/** ============ 3. 使用示例:一个商品列表(点击 + 曝光) ============ */
function ProductListPage({ products }: { products: Product[] }) {
  const track = useTypedTracker();

  return (
    <div>
      <h1>商品列表</h1>

      <ul>
        {products.map((p, index) => (
          <li key={p.id} data-list-item>
            {/* ⭐ 声明式曝光(⭐ 内部处理「可见比例 + 时长 + 去重 + 后台暂停」) */}
            <Exposure
              trackKey={`list-product-${p.id}`}
              event="product_impression"
              props={{ product_id: p.id, position: index + 1, list: 'home_grid' }}
            >
              <div
                // ⭐ 声明式点击(⭐ 值会自动解析成属性:`trackProductId` → `product_id`)
                data-track="product_click"
                data-track-product-id={p.id}
                data-track-position={String(index + 1)}
                data-track-list="home_grid"
                onClick={() => {
                  // ⭐ 或者用「命令式」(能带更复杂的属性)
                  track('product_click', { product_id: p.id, position: index + 1, list: 'home_grid' });
                  void navigate(`/product/${p.id}`);
                }}
              >
                <span>{p.name}</span>
                <button
                  // ⚠️ 注意:「加入购物车」不该冒泡成「点击商品」
                  //    ⭐ 用它自己的 data-track + `stopPropagation`
                  data-track="cart_add"
                  data-track-product-id={p.id}
                  data-track-price={String(p.price)}
                  data-track-from="home_grid"
                  onClick={(e) => {
                    // ⭐⭐ 关键:阻止冒泡(否则「父元素的 product_click」也会被触发)
                    e.stopPropagation();
                    void addToCart(p);
                  }}
                >
                  加入购物车
                </button>
              </div>
            </Exposure>
          </li>
        ))}
      </ul>
    </div>
  );
}
⭐⭐⭐ 这个实现里的五个「工程要点」:

① 【事件与属性「用 TS 类型约束」】⭐
   `TrackingEvents` 接口定义「每个事件有哪些属性」→
   ⭐ 「写错事件名/漏传属性/传错类型」都变成**编译错误**(比「code review 发现」有效得多)。

② 【「属性白名单」在运行时也校验】⭐
   ⭐ 这是「防止把用户输入传进属性」的机制——
     因为「TS 类型只在编译期」,而「有人可能用 `as any` 绕过」。
   ✅ 运行时只保留白名单里的 key,并在开发期「警告多余的 key」。

③ 【声明式点击用「事件委托」+ `closest`】⭐
   · 「一处实现,全站可用」(不用给每个元素绑监听)
   · `closest` 让「点按钮里的图标」也能命中父元素的 `data-track`
   · ⭐ 并「自动补充位置信息」(`__position`)——对「列表点击分析」很有用

④ 【⭐⭐ 嵌套点击必须「阻止冒泡」】
   「加入购物车」若不 `stopPropagation` → 「商品点击」也会被埋一次(**数据翻倍**)。
   ⭐ 这是埋点最常见的「数据被放大」的原因之一。
   (⭐ 更稳的做法:SDK 里「检测到内层已经处理过就不再触发外层」——
     但显式 `stopPropagation` 更可控)

⑤ 【曝光用「声明式组件」】⭐
   把「可见比例 / 时长阈值 / 去重 / 后台暂停」封在组件里,
   ⭐ 业务侧「包一层就有曝光」(成本极低)→ 这样更容易「全站覆盖」。

五、数据质量:四类问题的排查

⭐⭐⭐ 埋点数据出问题时,通常是这四类(按「发生频率」排序):

① 【漏报(数据少了)】
   原因:① 「上报被打断」(卸载时没用 `sendBeacon`);② 「事件没触发」
     (元素没渲染 / 被条件隐藏 / 委托容器外);③ 「采样/过滤」把它丢了;
     ④ ⚠️ **「广告拦截器」拦掉了上报请求**(⭐ 这是「数据少了一大截」的经典原因)
   ✅ 排查:「真实用户操作数」与「上报量」对比(差多少、差在哪类用户)

② 【重复(数据多了)】⭐ 最常见
   原因:① ⭐ **嵌套点击没阻止冒泡**(一次点击两条);
     ② ⭐ **React 18 StrictMode 的双执行**(开发期「effect 跑两次」→ 重复上报);
     ③ 「组件重挂 + 重复初始化」(如「监听器绑了两次」);
     ④ ⭐ **多标签页**(每个标签页都上报,见 3.14);
     ⑤ 「同一事件在多个地方都埋了」
   ✅ 排查:「同一用户同一秒的同一事件出现多次」

③ 【错乱(对不上)】
   原因:① ⭐ **时间戳不准**(客户端时钟不可靠 → 见下文的坑);
     ② 「事件顺序错」(批量上报 + 网络乱序);
     ③ 「会话切分错」(session 的定义与「业务定义」不一致);
     ④ 「用户 id 切换」(登录/登出时的归属问题)
   ✅ 做法:「带 `seq`」(可发现丢失/乱序)+「服务端用「接收时间」做兜底」+
     ⭐ **「登录/登出时上报一条「身份切换」事件」**(让分析时能正确归属)

④ 【语义错(数据对但含义错)】
   原因:① ⭐ **「事件的『成功』定义不一致」**(「下单」是「提交」还是「支付成功」?)
     ② 「属性单位/口径不一致」(「金额」是「分」还是「元」?含不含运费?)
     ③ 「埋点在错误的位置」(如「点了按钮就报成功」,但实际请求失败了)
   ✅ 做法:⭐ **「事件字典」要写清「触发时机 + 字段口径」**(而不只写「事件名 + 属性名」)
/** ⭐⭐ 防重复的三种手段 */
// ① 幂等上报(同一事件 + 同一 key 在短窗口内只报一次)
export function createDeduper(windowMs = 500) {
  const seen = new Map<string, number>();
  return {
    /** ⭐ 返回 true 表示「重复,应丢弃」 */
    isDuplicate(key: string): boolean {
      const now = Date.now();
      const last = seen.get(key);
      if (last !== undefined && now - last < windowMs) return true;
      seen.set(key, now);
      // ⭐ 清理(避免无限增长)
      if (seen.size > 1000) {
        for (const [k, t] of seen) if (now - t > windowMs) seen.delete(k);
      }
      return false;
    },
  };
}

// ② StrictMode 的「effect 双执行」:用「模块级标记」或「ref」防重
//    ⚠️ 但这只解决「开发期」;⭐ 生产环境不会有双执行
let initialized = false;
export function initTrackerOnce() {
  if (initialized) return;
  initialized = true;
  // ...真正的初始化
}

// ③ 嵌套点击:尽量在「SDK 层」防(不依赖业务记得 stopPropagation)
export function createClickDispatcher(onTrack: (event: string, props: Record<string, unknown>) => void) {
  return (e: MouseEvent) => {
    const target = e.target as HTMLElement | null;
    if (!target) return;

    // ⭐⭐ 找「所有」祖先里的 data-track(从内到外)
    const chain: HTMLElement[] = [];
    let cur: HTMLElement | null = target;
    while (cur) {
      const el = cur.closest<HTMLElement>('[data-track]');
      if (!el) break;
      chain.push(el);
      // ⭐ 继续往上一层找(跳过当前这个)
      cur = el.parentElement?.closest<HTMLElement>('[data-track]') ?? null;
    }

    // ⭐ 只触发「最内层」的那一个(⭐ 这样「业务不用写 stopPropagation」)
    const innermost = chain[0];
    if (!innermost?.dataset.track) return;
    onTrack(innermost.dataset.track, collectProps(innermost));
  };
}

function collectProps(el: HTMLElement): Record<string, unknown> {
  const props: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(el.dataset)) {
    if (!key.startsWith('track') || key === 'track' || key === 'trackProps') continue;
    const propKey = key.replace(/^track/, '').replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`).replace(/^_/, '');
    props[propKey] = value;
  }
  if (el.dataset.trackProps) {
    try { Object.assign(props, JSON.parse(el.dataset.trackProps)); } catch { /* 忽略 */ }
  }
  return props;
}

六、本篇特有的坑

// ① 曝光「进入视口就报」(闪过也算 → 数据虚高)
// ✅ 加「可见比例 + 时长阈值」(1 秒)+「页面可见」+「去重」

// ② 曝光「不判断页面可见性」(后台标签页里元素「在视口内」也报)⭐
// ✅ `document.visibilityState === 'visible'` 才计时;⭐ 切回来「重新计时」

// ③ 曝光「不去重」(滚出去又滚回来 → 同一条商品被浏览 10 次)⭐
// ✅ 「曝光单元 key」+「会话内只报一次」(⭐ 但「广告按次计费」这类要显式开 `repeat`)

// ④ ⭐ **嵌套点击没阻止冒泡**(一次点击报两条 → 数据翻倍)⭐ 最常见
// ✅ SDK 层「只取最内层 data-track」+ 业务侧 `stopPropagation`
//    ⚠️ 尤其是「列表项里的按钮」(点按钮 = 「按钮点击」+「列表项点击」)

// ⑤ ⭐ **StrictMode 的 effect 双执行**(开发期埋点翻倍)
// ✅ 「初始化加防重」(模块标记/ref)
//    ⚠️ 但不要为了「埋点不重复」而关掉 StrictMode(它会暴露真问题,见 8.5)

// ⑥ ⭐ **多标签页各自上报**(同一用户事件翻倍)
// ✅ Leader 选举只让主标签页上报(见 3.14);⭐ 或「上报时带 tab id,服务端侧去重」

// ⑦ 上报「没用 sendBeacon」(卸载时的事件丢失)
// ✅ `navigator.sendBeacon` + `keepalive`(⭐ 「下单成功」这类关键事件尤其重要)

// ⑧ 上报「有广告拦截器」时静默丢失(⭐ 数据少一大截)
// ⚠️ 这是「前端上报」的结构性问题(无法根治)
// ✅ 「上报通道」用「与静态资源同域的路径」(⭐ 比 `/api/track` 更不容易被拦);
//    并把「上报量与后端『真实业务量』对比」纳入数据质量监控

// ⑨ ⭐ 把「用户输入」当属性上报(合规风险 + 数据噪声)
track('search_submit', { keyword: 'xxx' });                    // ❌
// ✅ track('search_submit', { keyword_length: 3, result_count: 12 });

// ⑩ 属性「没有白名单」(有人传了整个对象)
track('order_created', { order: orderObject });                // ❌ 可能含隐私字段
// ✅ 白名单 + 「只传标量」

// ⑪ 事件名「不统一」(`productClick` / `product_click` / `clickProduct` 三者并存)
// ✅ 集中定义 + TS 类型约束

// ⑫ 「时间戳」用客户端时间(不可靠)
// ⚠️ 用户改系统时间/时区 → 数据错乱
// ✅ 「带 `client_ts` + `tz_offset`」并「服务端记录接收时间」;
//    ⭐ 分析时「以服务端时间为准」,前端的 `client_ts` 只作参考

// ⑬ 「金额」的口径不一致(分/元、含不含运费、含不含优惠)
// ✅ 事件字典里写清口径;⭐ 并且「统一单位」(推荐「分」的整数)

// ⑭ 「埋点写在『发出请求前』」(请求失败了但埋点是「成功」)
await track('order_created'); await createOrder();             // ❌ 顺序反了
// ✅ 「成功后再埋」;⭐ 如果要「埋转化漏斗」,就埋「两个事件」(attempted / succeeded)

// ⑮ 「埋点阻塞业务」(await 上报)
await track(...);                                              // ❌
// ✅ 上报「异步 + 不 await」

// ⑯ 「开发/预发环境的数据混入生产看板」⭐
// ✅ 用 `environment` 维度隔离;⭐ 或「非生产环境不发到生产端点」

// ⑰ 「SPA 的 PV(页面浏览)」用「路由变化」但没处理「同一路由的 query 变化」
// ✅ ⭐ 明确「PV 的定义」(是「路由变化」还是「内容变化」);
//    并用「路由桥」统一上报(见 6.13/6.14)

// ⑱ 「曝光组件的 `ref` 包了一层 div」(破坏了布局,如 `flex` 子项)
// ⭐ 症状:「包一层 div 后布局变了」
// ✅ 用 `display: contents`(⭐ 但要小心「它不影响布局但对旧浏览器有兼容问题」);
//    或用「asChild 模式」把 ref 透传给子元素(见 1.11)

// ⑲ 「埋点字典」不更新(新人不知道「这个事件有哪些属性」)
// ✅ ⭐ 「从 TS 类型自动生成文档」(一次定义,两处使用)

// ⑳ 「没有『埋点验证』的环节」(上线后才发现埋错)
// ✅ ⭐ **「埋点验收」纳入发布流程**:
//    · 开发期「调试模式」(console 打印 + 不发上报)
//    · 预发环境「人工走查 + 核对上报」
//    · ⭐ 上线后「对关键漏斗做一次端到端核对」(用很小的测试样本)
// ⑳ 的完整处理(埋点的「调试模式」)
export function createTrackerWithDebug(options: Parameters<typeof createTracker>[0] & { debug?: boolean }) {
  const track = createTracker(options);

  if (!options.debug) return track;

  // ⭐ 调试模式:只打 console、不发上报(⭐ 便于「开发期核对埋点」)
  return (event: string, props: Record<string, unknown>) => {
    console.groupCollapsed(`%c[track] ${event}`, 'color:#0a0;font-weight:bold');
    console.log('props', props);
    console.table(props);
    console.groupEnd();
    void track;                     // ⭐ 不真的上报
  };
}
// ⑦ 的完整处理(关键事件的「立即上报 + 重试」)
export async function trackCritical(event: string, props: Record<string, unknown>) {
  const payload = JSON.stringify({ event, props, ts: Date.now() });

  // ⭐ 关键事件(如「支付成功」)用「立即 + sendBeacon + 重试」
  const tryOnce = async (): Promise<boolean> => {
    try {
      const res = await fetch('/api/track/critical', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: payload,
        keepalive: true,
      });
      return res.ok;
    } catch {
      return false;
    }
  };

  // ⭐ 最多重试 2 次(并且「不阻塞业务」—— 用 void 起一个「后台任务」)
  for (let i = 0; i < 3; i++) {
    if (await tryOnce()) return;
    await new Promise((r) => setTimeout(r, 500 * (i + 1)));
  }

  // ⭐ 最后兜底:sendBeacon(⭐ 它「不会被页面卸载打断」)
  navigator.sendBeacon?.('/api/track/critical', payload);
}

七、面试延伸

  1. 「埋点分几类?各自怎么做?」
    三类:① 点击/交互——⭐ 声明式(data-track + 事件委托) 最省心(一处实现全站可用),且能「自动补位置信息」;② 业务——⭐ 命令式(track('order_paid', {...})),因为「只有业务代码知道『发生了什么』」(且业务事件常带复杂属性);③ ⭐ 曝光——声明式组件 <Exposure>(内部用 IntersectionObserver 处理「可见比例 + 时长 + 去重 + 后台暂停」)。⚠️ 不建议「全量自动埋点」——它会让数据团队淹没在噪声里,而且把用户输入也采了是合规风险。

  2. 「曝光埋点怎么判定『用户真的看到了』?」
    行业标准是「四个条件 + 去重」:① 元素在视口内(isIntersecting);② ⭐ 可见比例 ≥ 阈值(常见 50%,⚠️ threshold 要配成数组才能拿到比例变化,且「元素比视口高」时比例永远到不了 1);③ ⭐ 持续时长 ≥ 阈值(常见 1 秒——这就是「闪过不算」,⭐ 实现是「进入时起定时器、离开时取消」);④ ⭐ 页面可见(visibilityState === 'visible',并且切回来时要重新计时,不能把「后台的时间」算进去);⑤ 去重——⭐ 同一「曝光单元(元素 key + 业务数据)」在一次会话里只报一次(⚠️ 但「广告按次计费」这类要显式开 repeat)。

  3. 「埋点数据『多了』最常见的原因是什么?」
    ⭐ 嵌套点击没阻止冒泡——「列表项里的按钮」被点击时,「按钮的埋点」与「列表项的埋点」都会触发(数据翻倍)。其他常见原因:① ⭐ React 18 StrictMode 的 effect 双执行(开发期「effect 跑两次」→ 重复上报,⚠️ 但不要为了这个关掉 StrictMode,它会暴露真问题);② 组件重挂 + 监听器绑两次;③ ⭐ 多标签页各自上报(✅ Leader 选举只让主标签页上报,见 3.14);④ 同一事件在多个地方都埋了。解法:⭐ 在 SDK 层「只取最内层的 data-track」(不依赖「业务记得 stopPropagation」)+ 幂等上报(同 key 短窗口内去重) + 「初始化防重」。

  4. 「埋点数据『少了』怎么办?」
    四个方向:① ⭐ 上报被打断——「卸载时的事件」必须用 sendBeacon / keepalive;② 事件没触发——元素没渲染 / 被条件隐藏 / 委托容器之外(⭐ 检查「元素是否真的在 DOM 里且处于「可点击」状态);③ 采样或过滤把它丢了;④ ⭐ 广告拦截器——这是「数据少一大截」的经典原因(⚠️ 且无法根治)。✅ 排查方法:把「上报量与后端『真实业务量』对比」纳入数据质量监控(如「下单接口调用次数」vs「order_created 事件数」——⭐ 两者差距大就说明埋点丢了)。缓解:上报通道用「与静态资源同域的路径」(比 /api/track 更不容易被拦)。

  5. 「埋点的『规范』应该包含什么?」
    三层:① ⭐ 事件名规范——snake_case + 「对象_动作」结构(cart_add、search_submit、product_impression),并且集中定义(不要各处手写字符串,否则 productClick/product_click 并存);② ⭐ 属性规范——每个事件有「属性白名单 + 类型」,用 TS 类型约束(写错编译失败)+ 运行时校验(防止 as any 绕过),且 ⭐ 禁止把「用户输入」作为属性值(✅ 只采「行为」,如 keyword_length/result_count);③ ⭐ 「事件字典」写清「触发时机 + 字段口径」——而不只是「事件名 + 属性名」(⚠️ 「下单」是「提交」还是「支付成功」?「金额」是分还是元、含不含运费?这些语义问题比「技术问题」更容易导致「数据对但结论错」)。

  6. 「时间戳与会话(session)怎么处理?」
    ① ⭐ 时间戳——客户端时钟不可靠(用户改系统时间、时区偏差)→ ✅ 带上 client_ts + tz_offset,但 ⭐ 分析时以服务端接收时间为准(client_ts 只作参考,如「客户端内部的事件间隔」);② ⭐ 会话——「session 的定义」要与「业务定义」一致(常见是「30 分钟无操作则新会话」),并 ⭐ 在「登录/登出」时上报一条「身份切换」事件——否则「登录前的匿名行为」与「登录后的行为」无法正确归属(⚠️ 这是「用户行为分析对不上」的常见原因);③ ⭐ 事件顺序——批量上报 + 网络乱序会让「事件顺序错」→ ✅ 带 seq(会话内递增),分析时可按 seq 排序(⭐ 与 7.14 的「消息序号」是同一个思想)。

  7. 「埋点会影响性能吗?怎么控制?」
    会,要控制四点:① ⭐ 不阻塞业务——上报「异步 + 不 await」(❌ await track(...) 会把「上报耗时」加到「业务耗时」上);② ⭐ 批量 + 定时——攒够 N 条或每几秒发一次(而不是「每个事件一个请求」);③ ⭐ 曝光用的 IntersectionObserver 成本低(⭐ 优于「scroll 事件 + getBoundingClientRect」,见 1.14),但 ⚠️ 「每个元素一个 IO」在长列表里会很重 → ✅ 「复用观察器」或「只对前 N 项挂」;④ ⭐ 「上报通道」不要与「业务接口」抢并发(浏览器同域并发有限,见 5.16)→ ✅ 「上报走独立域名」或「用 sendBeacon(不占连接)」。⭐ 另外:「调试模式」要能在生产关闭(避免「console 打印拖慢主线程」)。

  8. 「怎么保证埋点『不漏』?」
    四层保障:① ⭐ 声明式优先——「data-track/<Exposure> 包一层就有」(比「手动调 track」更不容易漏,⭐ 尤其曝光);② ⭐ 覆盖检查——「关键页面 × 关键事件」的清单(如「列表页要有:曝光 + 点击 + 加购」),并用「自动化(E2E 走一遍,断言上报发生)」验证;③ ⭐ 数据侧交叉验证——用「后端指标」对照(「下单接口调用数」vs「order_created 事件数」、「支付成功数」vs「order_paid 事件数」),差距大就说明埋点丢了;④ ⭐ 发布流程里加「埋点验收」——开发期「调试模式打印」、预发「人工走查」、上线后「对关键漏斗做端到端核对」。⭐ 一句话:「埋点不是『开发完顺手加』的,它应该和『功能』一样有验收标准」。

一句话速记

埋点 = 「点击(⭐ 声明式 data-track + 事件委托)/ 业务(⭐ 命令式 track)/ 曝光(⭐ 声明式 <Exposure>)」,⚠️ 不做「全量自动埋点」(噪声 + 合规风险);⭐ 曝光的四个条件 + 去重:在视口内 + 可见比例 ≥ 50%(threshold 要传数组)+ 持续 ≥ 1 秒(「闪过不算」,进入起定时器/离开取消)+ 页面可见(visibilityState,⭐ 切回要重新计时)+ 同一「曝光单元」会话内只报一次(⚠️ 按次计费的要显式 repeat);⭐ 数据「多了」的头号原因是「嵌套点击没阻止冒泡」(✅ SDK 层只取最内层 data-track)+ StrictMode 双执行 + 多标签页(✅ Leader 选举)+ 重复埋点,统一靠「幂等去重 + 初始化防重」兜住;⭐ 数据「少了」的原因:未用 sendBeacon(卸载丢)、事件没触发、⭐ 广告拦截器(无法根治,用「与静态资源同域的上报路径」缓解,并把「上报量 vs 后端真实业务量」纳入监控);⭐ 规范三层:事件名(snake_case + 对象_动作 + 集中定义)、属性(TS 类型 + 运行时白名单,⭐ **禁止把用户输入当属性值)、事件字典(写清「触发时机 + 字段口径」);时间戳用客户端时钟不可靠 → 分析以服务端接收时间 为准(前端 client_ts 只作参考)+ **登录/登出上报「身份切换」事件(否则匿名与登录后行为无法归属)+ 带 seq(防乱序);性能:不 await 上报、批量定时、IO 复用、上报走独立域名/sendBeacon;防漏四层:声明式优先 + 覆盖清单/E2E 断言 + 数据侧与后端指标交叉验证 + 发布流程里的埋点验收。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.8 埋点体系

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫