新时代的曝光埋点怎么做

曝光埋点,顾名思义,是当元素出现在视口范围内上报的埋点。

这里有两个要点:

  • 在视口范围内
  • 元素可见

这两个内容决定了一个元素是否被曝光。

监听曝光

历史方案

过去的解决方案与其说是监听曝光,不如说是监听滚动,大致的思路是:

  1. 依赖收集:通过 DOM 特征挂载,使用 MutationObservable 去使用一个 set 进行管理
  2. 滚动监听:监听滚动事件
  3. 计算位置:getBoundingClientRect() 计算每个埋点的 DOM 元素与视口的相对位置,如果在视口范围内,则上报
  4. 清除数据:已上报的点位在 set 中删除,下次曝光不再上报

当然,也可以是:

  1. 滚动监听:监听滚动事件
  2. 扫描 DOM:document.querySelectorAll 获取所有埋了点的元素
  3. 计算位置:getBoundingClientRect() 计算每个埋点的 DOM 元素与视口的相对位置,如果在视口范围内,则上报
  4. 清除埋点(或者记录数据):已上报的点位删除标记,下次曝光不再上报;也可以是记录标记为已上报

但这种方案的缺点是,不滚动就不会触发,那么如果我原地变更,而不滚动,在一些设计中可能就没办法触发监听事件了。补偿的方案可能是,回到 MutationObservable 的设计,在初始化时再进行一次判断。

这套方案的主要优点是兼容性极好,从 MutationObservablegetBoundingClientRect 都是好久之前就有的,在国内这个复杂的浏览器市场来看,使用这套方案非常通用。

新方案

现在如果不用考虑 IE 兼容性的话,完全可以使用 IntersectionObservable 作为曝光的新方案,从「曝光」的定义考虑,这个 API 才是真正的为了「曝光」而生的。

Document: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

新方案相比过去,主要合并了「滚动监听」和「计算位置」的部分,由浏览器来为我们做这件事情。步骤大概变成了:

  1. 初始化:new IntersectionObservable,同时扫描初始化前,是否已有埋点,对这些埋点元素进行 intersection.observe(element) 的注册。同时需要准备一个已发送的埋点 set,避免重复曝光。
  2. Mutation 监听:随着 DOM 变化去进行 observeunobserve,在取消注册时同时删除 set 中的元素,这样下次可以继续上报。
  3. 触发 Intersection:由于「滚动监听」和「计算位置」都由 IntersectionObservable 处理了,我们只要处理曝光后的逻辑,如果确认触发上报,需要在上报后压入 set,避免下次上报。

MutationObservable 中,我们不判断可见性,直接注册,这是因为我们不把可见性作为元素注册的条件,详情可以见下文问题中的「为什么可见性的判断非常复杂」。所以在 IntersectionObservable 中,我们需要去判断可见性,详情还是往后读,这里不做赘述,简单说下判断条件:相交状态变更(isIntersecting)、是否已上报(Set.has)、元素可见性(displayvisibility)去决定是否上报。

问题

大部分文章都会用较长的篇幅去告诉你《如何用 IntersectionObservable 实现曝光埋点(的 demo)》,这就是本文的上(小)半部分。但是我估计大部分人也只是「看到了这个 API,觉得可行,写了个 demo 出个文章」,而没有实际落地,因此点到为止。

如果你需要实际开发并投入生产,需要考虑的点很多。

MutationObservable 篇

MutationObservable 优化

当我们使用 IntersectionObservable 之后,就不能用滚动+扫描的方法了,必须将 MutationObservableIntersectionObservable 结合使用。此时我们至少不能全部监听,从 document 顶部开始一口气监听全部变化毫无疑问就是所谓的「不必要的开销」。

实际上我们应该监听的是:

  • 元素的增加删除
  • 元素埋点标识的修改

因此最重要的是,当 DOM 的 attribute 变更时,其实我们只需要对埋点标变更的元素进行监听就可以了:attributeFilter 正是其中一点优化。

MutationObservable Corner Cases

如果你还不是很属性 MutationObservable,那么在这里向你介绍几个需要注意的 Case:

  1. MutationObserver 在添加或者删除 DOM 时都以根节点为准,因此需要再次querySelectorAll 查找子节点的注册情况
  2. 前端框架处于性能,可能会在 Virtual DOM 转换为 DOM 时进行一些优化处理,把 DOM 的删减变成了 attribute 的变更。
  3. 只要触发了 setAttribute 就会触发监听事件,而不会比较内部的值,所以如果有需要,可能需要埋点包或者业务库自行处理

IntersectionObservable 篇

元素本身大于视口

在 intersection 的计算中,如果元素大于视口,而你的 threshold 设置 >0 的情况,是没办法触发曝光事件的,需要做额外的优化,可以参考下面的 issue:https://github.com/w3c/IntersectionObserver/issues/124#issuecomment-476026505

大致的解决方案是,threshold 除了你需要的曝光率外,还需要补充一个零值,变成 [0, config.threshold],这样可以让超过视口的元素走立即曝光的逻辑:

if (
    entry.boundingClientRect.height <= entry.rootBounds.height &&
    entry.boundingClientRect.width <= entry.rootBounds.width &&
    entry.intersectionRatio < config.threshold
) {
    // 如果元素小于视口,走 config.threshold
    return
} else {
    // 元素大于视口,走零值
}

为什么我拿不到 rootBounds

在测试中,我们发现某些情况下是拿不到 rootBounds 的,在 MDN 中并没有说明 rootBounds 的问题。

这个答案在草案中:https://www.w3.org/TR/intersection-observer/#dom-intersectionobserverentry-rootbounds

rootBounds, of type DOMRectReadOnly, readonly, nullable

For a same-origin-domain target, this will be the root intersection rectangle. Otherwise, this will be null. Note that if the target is in a different browsing context than the intersection root, this will be in a different coordinate system than boundingClientRect and intersectionRect.

也就是说,在 iframe 中,我们拿不到 rootBounds 是符合规范的。

那么上面对于 rootBounds 的判断逻辑就会有问题,需要有额外的获取方法。

在这种场景下最简单的处理方案是:如果是 iframe,那么我们走零值,不去进行需要 rootBounds 的判断。而 iframe 的判断,简单的判断方法是 window !== top

特别的,如果显示的指定 root 对象,那么 rootBounds 就是 root 对象的取值。

元素 fixed 时无法触发曝光埋点

这一部分细节同样在草案中:https://www.w3.org/TR/intersection-observer/#dom-intersectionobserver-thresholds

If the IntersectionObserver is an implicit root observer,
it’s treated as if the root were the top-level browsing context’s document, according to the following rule for document.
If the intersection root is a document,
it’s the size of the document's viewport (note that this processing step can only be reached if the document is fully active).
Otherwise, if the intersection root has an overflow clip,
it’s the element’s content area.
Otherwise,
it’s the result of running the getBoundingClientRect() algorithm on the intersection root.

这一段的关键在于 content area,content area 和盒模型有关,而我们知道,如果是 fixed 的情况,元素就会脱离文本流,这个时候,就不再是 root 的子元素,无法去判断他们的交集。

解决方案是,不显示指定 root,或者将 root 指定为 document,这样就不满足 content area 的判断条件。

需要注意的另一点是,root: document 的兼容性要求较高,建议不显示填写 root。

为什么可见性的判断非常复杂

这是本文想要介绍的一个重点设计思路。

一个元素在视口范围内由 intersection 去保障,但是实际上看不看得见确实一个非常复杂的问题。

元素可见性理论上代表了「用户能看到」,和在视口范围等效,但实际可以通过以下方法修改(应该还有更多):

  1. display: none
  2. visibility: hidden
  3. opacity: 0
  4. scale: 0
  5. height & width 为 0
  6. z-index 遮挡
  7. filter
  8. transform
  9. CSS 动画特殊效果

来保证元素(DOM)存在,但实际不可见,而判断需要使用 window.getComputedStyle,然后去判断每一个属性。

而如果要更改可见性,也就是更改 CSS 属性,常用两种方法:class 的变更或者 style 的变更。

如果要根据真正的可见性去判断,相对的性能消耗会非常高,首先 MutationObservable 需要监听 classstyle 的变化,这部分的变化相比埋点标本身的变更多了很多。当然,MutationObservable 本身的性能开销还好。最大的问题在于,当收到 classstyle 变更后,我们需要通过一次 window.getComputedStyle 计算得出最终属性,并且 cover 上述所有的可能性。而 window.getComputedStyle 的开销相对较大。

因此在 MutationObservable 时,我们不进行监听,而在 IntersectionObservable 中,对于不可视的元素,甚至可能需要考虑 CSS 动画,这对于我们的计算复杂了不少。简单的场景下,通常切换 CSS 可见性,我们会使用 display 的变更,如果需要占位,则是使用 visibility 的切换,因此主要过滤了这两种情况,将剩下的情况交给业务方去避免。

当然,在未来,会有一套 V2 版本的 IntersectionObservable,他可以确保元素的可见性,但是即便如此,连官方都建议,能不用就不用,因为性能开销相比之前的版本,实在过于昂贵了:

Visibility is much more expensive to compute than intersection. For that reason, Intersection Observer v2 is not intended to be used broadly in the way that Intersection Observer v1 is. Intersection Observer v2 is focused on combatting fraud and should be used only when the visibility information is needed and when Intersection Observer v1 functionality is therefore insufficient.

这一部分交给业务方去避免,才能做到性能和数据的平衡性。

所以,怎么处理 dialog

上述的 fixed + visibility 的场景,其实非常常见于我们的弹窗类元素设计,因为如果用 display 或者 DOM 去控制,会导致弹窗的渐变动画无法正常实现,因此会选择用 visibility 实现。

但正因为如此,我们就不能取消对 visibility 的曝光判断(不触发曝光事件的上报),因为这样会导致弹窗虽然不可见,但已经上报了埋点,造成大量的数据错误。

这也就是「需要业务方」去处理的部分,简单的应对方法是提供一个手动上报曝光埋点的方法,而不光使用 DOM 中的打点,否则的话,就得想办法去重新触发 MutationObservable 进行重新注册。因为尽管没上报,在视口范围内的 IntersectionObservable 监听却真实触发了,不滚动的情况下不会再次触发,但 fixed 滚不起来,就陷入了死循环中。

其他注意点

  1. IntersectionObservable 除了根据 DOM 去判断外,还会把 display:none 作为计算条件,当 display 变化时,同样会触发曝光。
  2. 与旧方案不同,即使不触发滚动,元素注册时(调用 intersection.observe(element))元素已经在视口范围内,同样会触发曝光事件,这使得我们的代码精简了很多。

总结

就上述提到的问题点来说,是我在做埋点库看了好几篇埋点实现文章中都没有提到的细节点,而且资料本身的获取也比较刁钻,在大量集成测试和落地后的实际使用中不断总结得出的。

对于 SDK 的提供方,需要在考虑 case 的情况下综合权衡「易用」+「可读」+「性能」+「兼容性」,将剩下的需要业务方注意的问题标记在文档中。

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: 知识

已有 2 条评论

  1. 候鸟

    大姐真棒

  2. 雨浣潇湘

    如果你还不是很属性 MutationObservable
    属性 -> 熟悉

添加新评论