创见博客
用事件委托做全埋点
七崽爱吃小饼干2026/10/13阅读 0

埋点最省事的方式不是给每个按钮都写一行上报代码,而是在顶层挂一个监听器,用事件委托一把兜住所有交互。这篇不讲空泛的"全埋点价值",只讲三件事:事件委托怎么采集、怎么用 data-* 做过滤和传参、以及哪些事件压根代理不了。

一、为什么挂 window 捕获,而不是给元素绑定

事件流是 window → document → … → target → … → window。捕获阶段从最外层往里走,所以挂在 window 的捕获监听器永远第一个执行,内层业务代码在冒泡阶段怎么处理都影响不到它。

js
window.addEventListener('click', handler, true); // 第三个参数 true = 捕获阶段

三个要点:

  1. 用捕获,不用冒泡:业务代码一句 stopPropagation() 就能打断冒泡链,捕获先执行,被拦的风险小得多。
  2. 挂 window 优先于 document:代价一样,但 window 在捕获路径上更靠前。只有别人在 window 捕获上、注册比你早、调了 stopImmediatePropagation,才拦得住你。
  3. 捕获阶段对非冒泡事件也生效:这点后面会反复用到,focus、mouseenter、元素内 scroll 这些不冒泡的事件,照样能在捕获阶段收到。

二、采集端的最小实现

不在元素上绑事件,就得在回调里自己找"到底点了哪个可埋点的元素"。因为 e.target 往往是图标、<span> 这类子节点,要从内往外回溯。

js
window.addEventListener('click', (e) => {
  // composedPath 从内到外,能跨 Shadow DOM 边界
  const el = e.composedPath().find(
    (n) => n.nodeType === 1 && n.hasAttribute?.('data-track')
  );
  if (!el) return;

  report({
    event: 'click',
    id: el.dataset.trackId,              // 稳定唯一标识
    params: safeParse(el.dataset.trackParams),
    text: el.innerText?.slice(0, 50),    // 兜底
    path: location.pathname,
    time: Date.now(),
  });
}, true);

两个容易踩的点:

  • 用 e.composedPath() 而不是 e.target.closest():closest 跨不过 Shadow DOM 边界,而 click 是 composed 事件、能穿出 shadow root。composedPath() 从内到外遍历,同时满足"回溯到可埋点父元素"和"穿透 Shadow DOM"两个需求。
  • 就近命中:最后命中的是离 target 最近的 data-track 元素,嵌套时里层会"吃掉"外层,团队要约定"埋点元素最小化"。

三、用 data-* 做过滤和传参

全埋点最大的两个痛点——标识不稳定、参数带不上——靠一套 data-* 约定就能解决。这就是业界说的"声明式埋点"。

html
<button
  data-track="click"
  data-track-id="home_hero_signup"
  data-track-params='{"position":"top"}'
>注册</button>

建议的字段规范:

属性作用约定
data-track事件类型click / expose / hover,可空格多值
data-track-id稳定唯一标识命名 页面_模块_元素,如 detail_comment_like
data-track-params附加参数JSON 字符串
data-track-expose-duration曝光时长阈值数字,默认 500ms

data-track-id 是这套方案的灵魂:它替代了不稳定的 XPath,元素改样式、改结构都不影响埋点数据。

几个实践坑:

  • 属性值只有字符串:data-track-params 约定 JSON,解析时务必 try/catch,别让一个坏属性把主流程带崩。
  • 只能塞静态值:列表项的商品 id 这类运行时数据,用 data-track-id="sku_${id}" 或运行时 setAttribute。
  • XSS 和大小:参数若来自用户输入,渲染前要转义;单个属性别塞太大。
  • 和全埋点混合:有 data-track 的走声明式(精准),没有的走自动采集兜底(保覆盖),别二选一。

四、被同节点抢跑拦掉?再补一张网

先说清楚:window 捕获唯一会被打败的场景,是同节点(也是 window 捕获)上、注册比你早、调用 stopImmediatePropagation 的那个监听器把你挡在门外。普通的 stopPropagation、e.cancelBubble = true 都只掐"传到别的节点",掐不到同节点的你,拦不住。

针对这个窄场景,把 addEventListener 包一层,埋点先跑、业务回调后跑,e.target 依旧是真实点击元素:

js
const rawAdd = EventTarget.prototype.addEventListener;
const map = new WeakMap(); // 保证 removeEventListener 还能用

EventTarget.prototype.addEventListener = function (type, listener, options) {
  if (type !== 'click' || typeof listener !== 'function') {
    return rawAdd.call(this, type, listener, options);
  }
  const wrapped = function (e) {
    track(e);                      // 先埋点
    return listener.call(this, e); // 再执行业务
  };
  map.set(listener, wrapped);
  return rawAdd.call(this, type, wrapped, options);
};

注意:改 prototype 后 removeEventListener 会失效,必须用 WeakMap 存 original→wrapped 映射。框架层面 React 16 监听在 document、17+ 在根容器,包裹回调同样生效。

另一个思路是改 Event.prototype.stopImmediatePropagation,调用时直接触发一次上报。它针对的正是上面那个窄场景——有人抢跑 stopImmediatePropagation 把你挡掉的那一次。除此之外它都不该跑(其他点击 window 捕获已经报过),所以它天然是补漏,而不是主方案。

有两点别搞反:

  • 该 patch 的是 stopImmediatePropagation,不是 stopPropagation。 stopPropagation 在主监听挂 window 时打不败你;只有把主监听挪到 document,才会被 window 上的 stopPropagation 打败,那时才需要一起 patch。
  • 别担心 e.cancelBubble = true。 它和 stopPropagation 一样只掐"到别的节点",掐不到同节点的 window 捕获,主网照样收得到,这里没有漏网。

真正必须做的,是加 type === 'click' 过滤,并用 __tracked 标志去重,否则会和主网重复上报:

js
const rawStop = Event.prototype.stopImmediatePropagation;
Event.prototype.stopImmediatePropagation = function () {
  if (this.type === 'click' && this.isTrusted && !this.__tracked) {
    this.__tracked = true;
    maybeTrack(this);   // 再走一遍 data-track 过滤
  }
  return rawStop.apply(this, arguments);
};

五、哪些事件能靠代理上报

因为捕获阶段对非冒泡事件也生效,下面这些都能在 window 捕获上用 e.target 代理:

事件冒泡?捕获可收?
click / dblclick / mousedown / mouseup是✅
mouseover / mouseout / mousemove是✅
mouseenter / mouseleave❌ 不冒泡✅ 靠捕获
pointerover / pointerout / pointerenter混合✅
keydown / keyup是✅
focus / blur❌ 不冒泡✅ 靠捕获
input / change / submit是✅
touchstart / touchmove / touchend是✅
scroll(元素内滚动)❌ 不冒泡✅ 靠捕获
copy / paste是✅

"悬浮"就在这张表里——用 mouseover/mouseout 或 mouseenter 挂 window 捕获都行。但有两个现实问题:

  • 量极大:mousemove/mouseover 一秒能触发几十上百次,必须节流,且只对 [data-track] 元素上报。
  • enter/leave 语义要自己算:按元素维护进出时间戳,停留超过阈值(比如 500ms)才算一次有效悬浮,否则全是噪声。

六、哪些事件代理不了

曝光是重灾区,但严格说它根本不是 DOM 事件。"元素进入视口且可见一段时间"是一个派生语义,浏览器不会为此发任何事件,所以没法"挂个监听就完事"。标准做法是 IntersectionObserver + MutationObserver:

js
const io = new IntersectionObserver((entries) => {
  entries.forEach((en) => {
    if (en.isIntersecting) {
      en.target.__t = Date.now();          // 进入视口,起计时
    } else if (en.target.__t) {
      if (Date.now() - en.target.__t > 500) reportExposure(en.target);
      en.target.__t = 0;
    }
  });
}, { threshold: [0, 1] });

// MutationObserver 负责发现动态新增的节点并交给 IO 观察
const mo = new MutationObserver((muts) => {
  muts.forEach((m) => m.addedNodes.forEach((n) => {
    if (n.nodeType !== 1) return;
    if (n.matches('[data-expose]')) io.observe(n);
    n.querySelectorAll('[data-expose]').forEach((el) => io.observe(el));
  }));
});
mo.observe(document.body, { childList: true, subtree: true });

老式的 scroll + getBoundingClientRect 轮询方案性能和准确度都差,不建议再用。

其他代理不了的:

  • resize:目标就是 window 本身,直接监听即可,谈不上代理。
  • iframe 内的任何事件:完全不会冒泡到父页面,必须往每个 iframe 单独注入。
  • Shadow DOM 内的事件:e.target 会被重定向成宿主元素,拿不到真实点击目标,得用 e.composedPath()[0]。

七、小结

  • 主网是 window 捕获 + data-track 过滤传参,能覆盖点击、悬浮、键盘、表单、触摸、滚动等绝大多数交互。
  • 非冒泡事件靠捕获兜(focus、mouseenter、元素内 scroll),这是很多人不知道的点。
  • 曝光不是事件,只能用 IntersectionObserver + MutationObserver 自己算。
  • 所有代理方案都有两个盲区:iframe 和 Shadow DOM。
  • 只有同节点抢跑的 stopImmediatePropagation 能打败 window 捕获;被挡掉时再补一层 addEventListener 拦截,或 patch stopImmediatePropagation 兜底。
评论
0/100