Skip to content
 

无痕(全)埋点实现原理、曝光埋点与停留时长统计

更新: 9/17/2026字数: 0 字 时长: 0 分钟

提示

提示

一句话总结无痕埋点 = 业务代码零侵入地自动捕获用户行为,曝光埋点 = 用 IntersectionObserver 监测元素可见性,停留时长 = 用 Page Visibility 精确统计"有效浏览时间"。三者结合,构成了现代前端精细化运营的基石。

一、什么是无痕埋点

核心定义

无痕埋点(也叫全埋点、自动埋点) 是指:业务代码不需要写 sensors.track(...),SDK 自动监听 DOM 事件,捕获所有可埋点的用户行为

业务方只需在元素上加约定的 data-* 属性(或无属性时按全量捕获),SDK 就能"自己动手"完成上报。

1.1 与手动埋点的对比

维度手动埋点无痕埋点
业务侵入性高(每个事件都要写 track几乎为零(SDK 自动)
覆盖度看开发严谨度(容易漏)100% 覆盖(DOM 事件不漏)
数据精度高(可带丰富业务上下文)一般(只有元素位置、文本、类名)
维护成本高(改流程要改埋点)低(SDK 升级即可)
适用阶段业务成熟期、转化漏斗关键路径产品初期、需要快速采集大盘数据

最佳实践

手动 + 无痕结合:关键路径用手动埋点(精确、可带业务字段),其他场景用无痕埋点(覆盖全、零成本)。


二、无痕埋点的实现原理

2.1 整体架构

2.2 三种埋点方式的覆盖矩阵

行为类型手动埋点无痕埋点SDK 自动
关键按钮点击✅ 必备✅ 自动SDK 监听 click
页面浏览(PV)可选✅ 自动SDK 监听路由变化
表单提交可选✅ 自动SDK 监听 submit
曝光(可见性)可选✅ 自动SDK 用 IntersectionObserver
停留时长可选✅ 自动SDK 用 Page Visibility
业务自定义事件(如加入购物车)✅ 必备

三、自动捕获点击事件

3.1 事件代理(核心思想)

不要给每个元素都绑定监听器,只用 document 监听一次,通过 event.target 冒泡获取实际元素:

typescript
// ❌ 错误做法:每个按钮都绑定
document.querySelectorAll('button').forEach(btn => {
  btn.addEventListener('click', handler);
});

// ✅ 正确做法:事件代理
document.addEventListener('click', (e) => {
  const target = e.target as HTMLElement;
  // 找到最近的埋点标记元素(兼容嵌套 DOM)
  const trackEl = target.closest('[data-track]');
  if (trackEl) {
    trackEvent('click', trackEl);
  }
});

3.2 提取元素信息

SDK 需要自动从元素上提取"是什么被点了":

typescript
function extractElementInfo(el: HTMLElement) {
  return {
    // 业务约定的标记
    track_id: el.dataset.track,                    // data-track="submit_order"
    track_params: el.dataset.trackParams,          // data-track-params='{"order_id":"123"}'

    // 自动推断的标识
    tag: el.tagName.toLowerCase(),                  // button
    text: el.textContent?.trim().slice(0, 50),      // 截断防止超长
    class_name: el.className,
    id: el.id,
    selector: generateSelector(el),                 // body > div > button:nth-child(2)

    // 位置信息(用于热力图)
    x: Math.round(el.getBoundingClientRect().left),
    y: Math.round(el.getBoundingClientRect().top)
  };
}

// 生成稳定的 CSS 选择器(用于定位元素)
function generateSelector(el: HTMLElement): string {
  if (el.id) return `#${el.id}`;
  if (el.className) return `.${el.className.split(' ').join('.')}`;

  const path: string[] = [];
  let cur: HTMLElement | null = el;
  while (cur && cur.tagName !== 'BODY') {
    path.unshift(cur.tagName.toLowerCase());
    cur = cur.parentElement;
  }
  return path.join(' > ');
}

3.3 完整的点击无痕埋点

typescript
class AutoTracker {
  constructor(private collector: Collector) {
    this.installClickTracker();
    this.installSubmitTracker();
    this.installChangeTracker();
  }

  // 点击事件捕获
  private installClickTracker() {
    document.addEventListener('click', (e) => {
      const target = e.target as HTMLElement;
      const trackEl = target.closest('[data-track], button, a');

      if (!trackEl) return;

      const info = extractElementInfo(trackEl);
      const eventName = info.track_id || `${info.tag}_click`;

      // 优先用业务自定义参数
      let params: Record<string, any> = {};
      if (info.track_params) {
        try { params = JSON.parse(info.track_params); } catch {}
      }

      this.collector.track(eventName, {
        ...params,
        element: info
      });
    }, { capture: true }); // 捕获阶段,保证比业务代码先执行
  }

  // 表单提交捕获
  private installSubmitTracker() {
    document.addEventListener('submit', (e) => {
      const form = e.target as HTMLFormElement;
      const trackId = form.dataset.track || 'form_submit';

      this.collector.track(trackId, {
        form_id: form.id,
        form_action: form.action,
        form_method: form.method,
        // 注意:不要上报具体字段值(可能含敏感信息)
        field_count: form.elements.length
      });
    }, { capture: true });
  }

  // 输入变化捕获(如搜索框、下拉选择)
  private installChangeTracker() {
    let timer: number | null = null;
    document.addEventListener('change', (e) => {
      const target = e.target as HTMLElement;
      // 只埋特定的交互元素,避免误埋(密码框、单选等)
      if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;

      // 防抖:连续 change 只埋一次
      clearTimeout(timer!);
      timer = window.setTimeout(() => {
        const input = target as HTMLInputElement;
        this.collector.track('input_change', {
          input_type: input.type,
          input_name: input.name,
          // 严格过滤 value,防止上报密码等敏感信息
          has_value: !!input.value,
          value_length: input.value?.length || 0
        });
      }, 500);
    }, { capture: true });
  }
}

3.4 业务标记示例

html
<!-- 关键按钮:手动指定事件名 + 参数 -->
<button
  data-track="submit_order"
  data-track-params='{"order_id":"12345","amount":99}'
>提交订单</button>

<!-- 普通按钮:不标记,SDK 用默认规则 (button_click + 元素信息) -->
<button>取消</button>

<!-- 表单 -->
<form data-track="signup_form" action="/api/signup" method="post">
  <input name="email" />
  <input name="password" type="password" /> <!-- 密码不会被上报 value -->
  <button type="submit">注册</button>
</form>

四、PV(页面浏览)无痕埋点

4.1 SPA 路由监听

传统 MPA 页面,window.onload + 监听 pageshow 事件就够了。但 SPA 路由切换不刷新页面,必须监听 history.pushState

typescript
class PVTracker {
  private currentUrl: string = location.href;

  start() {
    // 1. 上报首次进入页面的 PV
    this.reportPV();

    // 2. 劫持 history 方法(SPA 路由切换的核心)
    const originalPushState = history.pushState;
    history.pushState = (...args) => {
      originalPushState.apply(history, args);
      this.onRouteChange();
    };

    window.addEventListener('popstate', () => this.onRouteChange());
    window.addEventListener('hashchange', () => this.onRouteChange());
  }

  private onRouteChange() {
    if (location.href === this.currentUrl) return; // 防止重复上报

    // 先上报上一页的停留时长(详见第五节)
    durationTracker.flush();
    this.currentUrl = location.href;
    this.reportPV();
  }

  private reportPV() {
    this.collector.track('$pageview', {
      $url: location.href,
      $referrer: document.referrer,
      $title: document.title
    });
  }
}

4.2 Next.js / Vue Router 适配

现代框架的路由切换走自己的 history,pushState 劫持可能失效。可以用 路由钩子

typescript
// Next.js (pages router)
import { useRouter } from 'next/router';
useEffect(() => {
  const handleRouteChange = (url: string) => {
    sensors.track('$pageview', { $url: url });
  };
  router.events.on('routeChangeComplete', handleRouteChange);
  return () => router.events.off('routeChangeComplete', handleRouteChange);
}, []);

// Vue Router
router.afterEach((to, from) => {
  sensors.track('$pageview', {
    $url: to.fullPath,
    $referrer: from.fullPath
  });
});

五、停留时长统计(难点)

停留时长是最容易数据失真的埋点类型——用户切到后台、锁屏、最小化窗口,浏览器会暂停甚至丢弃 setInterval

5.1 核心原理

5.2 用 document.visibilityState 替代 setInterval

typescript
// ❌ 错误做法:setInterval 在后台会被节流(Chrome 后台最小 1s / 次),数据不准确
setInterval(() => {
  activeTime += 1000;
}, 1000);

// ✅ 正确做法:用 Page Visibility API 监听可见性切换
class DurationTracker {
  private activeStart: number = 0;
  private accumulated: number = 0;       // 当前页面已累积的停留毫秒数
  private currentUrl: string = location.href;
  private collector: Collector;

  start() {
    // 页面首次可见:开始计时
    if (document.visibilityState === 'visible') {
      this.activeStart = Date.now();
    }

    // 监听可见性切换
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        // 切到后台:暂停累积
        this.accumulated += Date.now() - this.activeStart;
        this.activeStart = 0;
      } else {
        // 重新可见:继续计时
        this.activeStart = Date.now();
      }
    });

    // 页面卸载:上报停留时长
    window.addEventListener('pagehide', () => this.flush());
    window.addEventListener('beforeunload', () => this.flush());

    // 兜底:每 30s 主动 flush 一次(防止 SPA 内路由切换时丢数据)
    setInterval(() => this.flush(), 30000);
  }

  // 主动 flush(用于 SPA 路由切换)
  flush() {
    let totalDuration = this.accumulated;
    if (this.activeStart > 0) {
      totalDuration += Date.now() - this.activeStart;
      this.activeStart = Date.now(); // 重置起点,避免下次重复累加
    }

    if (totalDuration < 100) return; // 太短不上报(< 100ms 视为无效)

    this.collector.track('$page_duration', {
      $url: this.currentUrl,
      duration_ms: totalDuration
    });

    this.accumulated = 0;
  }
}

5.3 关键点解释

问题解决方案
setInterval 后台被节流visibilitychange 事件精确计算
SPA 路由切换不刷新页面提供 flush() 方法,让 PV 切换时手动调用
页面快速刷新(< 1s)if (totalDuration < 100) return 过滤
锁屏 / 后台超时被杀pagehide 而不是 unload(兼容 Safari)

七、曝光埋点(IntersectionObserver)

typescript
class ExposureTracker {
  private observer: IntersectionObserver;

  constructor(private collector: Collector) {
    this.observer = new IntersectionObserver(
      (entries) => this.handleEntries(entries),
      {
        // 元素可见 50% 就算曝光
        threshold: 0.5,
        // 提前 100px 触发(优化用户感知)
        rootMargin: '0px 0px -100px 0px'
      }
    );
  }

  // 监听某个元素
  watch(el: HTMLElement, exposureId: string, extraProps: Record<string, any> = {}) {
    el.dataset.exposureId = exposureId;
    el._exposureStart = 0;
    el._exposureProps = extraProps;
    this.observer.observe(el);
  }

  private handleEntries(entries: IntersectionObserverEntry[]) {
    entries.forEach(entry => {
      const el = entry.target as HTMLElement & {
        _exposureStart?: number;
        _exposureProps?: Record<string, any>;
      };
      const exposureId = el.dataset.exposureId;
      if (!exposureId) return;

      if (entry.isIntersecting) {
        // 进入视口:开始计时
        el._exposureStart = Date.now();
      } else if (el._exposureStart) {
        // 离开视口:上报本次曝光时长
        const duration = Date.now() - el._exposureStart;
        el._exposureStart = 0;

        // 过滤太短的曝光(< 500ms 视为无效)
        if (duration < 500) return;

        this.collector.track('$exposure', {
          exposure_id: exposureId,
          exposure_duration_ms: duration,
          ...el._exposureProps
        });
      }
    });
  }

  destroy() {
    this.observer.disconnect();
  }
}

业务使用

html
<!-- 商品卡片:用 data-exposure-id 标记 -->
<div
  data-exposure-id="product_card_123"
  data-exposure-params='{"product_id":"P123","price":99}'
>
  商品名 / 图片 / 价格
</div>
typescript
// 监听所有 [data-exposure-id] 元素
document.querySelectorAll('[data-exposure-id]').forEach(el => {
  const params = JSON.parse(el.dataset.exposureParams || '{}');
  exposureTracker.watch(el, el.dataset.exposureId!, params);
});

// 新增的 DOM 也要监听(比如无限滚动加载的商品)
const observer = new MutationObserver(() => {
  document.querySelectorAll('[data-exposure-id]:not([data-_observed])').forEach(el => {
    el.dataset._observed = '1';
    const params = JSON.parse(el.dataset.exposureParams || '{}');
    exposureTracker.watch(el, el.dataset.exposureId!, params);
  });
});
observer.observe(document.body, { childList: true, subtree: true });

八、完整无痕 SDK 整合

把上面 3 大模块(自动点击 + PV + 停留 + 曝光)打包成一个 AutoCollector,跟第一篇的 Collector 配合:

typescript
import { sensors } from './sdk'; // 第一篇里的 SDK

class AutoCollector {
  private pvTracker: PVTracker;
  private durationTracker: DurationTracker;
  private exposureTracker: ExposureTracker;
  private autoTracker: AutoTracker;

  constructor() {
    this.pvTracker = new PVTracker(sensors);
    this.durationTracker = new DurationTracker(sensors);
    this.exposureTracker = new ExposureTracker(sensors);
    this.autoTracker = new AutoTracker(sensors);
  }

  start() {
    this.pvTracker.start();
    this.durationTracker.start();
    // AutoTracker 在构造函数里已经 install 了所有事件
  }
}

// 业务方调用一次即可
const autoCollector = new AutoCollector();
autoCollector.start();

九、避坑指南

现象解决
重复上报SPA 路由切换时 PV 上报多次onRouteChange 里判断 location.href !== currentUrl
元素信息泄露自动捕获的 textContent 包含用户隐私SDK 内部过滤敏感字段(手机号、邮箱、身份证正则匹配)
高频事件input change 一次输入触发几十次加 500ms 防抖
曝光元素太多1000 个商品卡都监听 IntersectionObserver滚动出视口 5s 后 disconnect(节省 CPU)
iOS Safari 后台计时锁屏后 setInterval 不准不要用 setInterval 算时长,用 visibilitychange
iframe 嵌套父子页面重复上报 PV检测 window.top === window 决定是否上报
<br/> 在 SVG <text>部分浏览器把换行当成两次渲染&#10;tspan

敏感字段过滤示例

typescript
const SENSITIVE_REGEX = /1[3-9]\d{9}|[\w-]+@[\w-]+\.\w+|\d{17}[\dXx]/; // 手机号/邮箱/身份证

function safeText(text: string): string {
  return text.replace(SENSITIVE_REGEX, '***');
}

// 在 extractElementInfo 里调用
text: safeText(el.textContent?.trim().slice(0, 50) || ''),

十、总结

能力关键 API难度
自动点击事件代理 + closest()
PV 自动埋点劫持 pushState + popstate⭐⭐
停留时长visibilitychange + pagehide⭐⭐⭐
曝光埋点IntersectionObserver⭐⭐
完整 SDK整合以上 + 配合第一篇的 SDK⭐⭐⭐⭐

核心原则手动埋点管"精度",无痕埋点管"覆盖",两者结合才是完整方案