埋点最省事的方式不是给每个按钮都写一行上报代码,而是在顶层挂一个监听器,用事件委托一把兜住所有交互。这篇不讲空泛的"全埋点价值",只讲三件事:事件委托怎么采集、怎么用 data-* 做过滤和传参、以及哪些事件压根代理不了。
一、为什么挂 window 捕获,而不是给元素绑定
事件流是 window → document → … → target → … → window。捕获阶段从最外层往里走,所以挂在 window 的捕获监听器永远第一个执行,内层业务代码在冒泡阶段怎么处理都影响不到它。
window.addEventListener('click', handler, true); // 第三个参数 true = 捕获阶段
三个要点:
- 用捕获,不用冒泡:业务代码一句
stopPropagation()就能打断冒泡链,捕获先执行,被拦的风险小得多。 - 挂
window优先于document:代价一样,但window在捕获路径上更靠前。只有别人在window捕获上、注册比你早、调了stopImmediatePropagation,才拦得住你。 - 捕获阶段对非冒泡事件也生效:这点后面会反复用到,
focus、mouseenter、元素内scroll这些不冒泡的事件,照样能在捕获阶段收到。
二、采集端的最小实现
不在元素上绑事件,就得在回调里自己找"到底点了哪个可埋点的元素"。因为 e.target 往往是图标、<span> 这类子节点,要从内往外回溯。
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-* 约定就能解决。这就是业界说的"声明式埋点"。
<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 依旧是真实点击元素:
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 标志去重,否则会和主网重复上报:
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:
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拦截,或 patchstopImmediatePropagation兜底。