京东质量社区 一文帮你搞定 H5、小程序、Taro 长列表曝光埋点 | 京东云技术团队

京东云开发者 · 2023年07月14日 · 3846 次阅读

对于很多前端同学来说,“埋点” 常常是一个不愿面对却又无法逃避的话题。为什么这么说呢,相信很多前端同学都深有体会:首先埋点这个事基本是前端 “独享” 的,服务端基本不太涉及;其次添加埋点,往往看起来很简单但实际做起来很麻烦,很多时候为了获取一些埋点需要的信息甚至要对已经写好的代码进行伤筋动骨的修改。

虽然前端埋点费时费力,做起来没什么成就感,但是埋点作为收集线上业务数据(用户购买行为、活动转化等)的重要途径,为产品策略调整提供了重要数据支撑,特别是在像 618、双 11 等大促活动中,埋点数据采集对于促销活动的策略制定、及时调整及最终收益效果的验证都至关重要,因此又是一件研发同学必须要认真对待的事情。本文结合多年来各平台项目实践经验,总结了埋点类需求的开发实战经验及技巧,希望通过本文的分享能让更多读者在开发中尽量少走弯路,准确高效完成埋点开发任务,保证业务在大促及常态运营中的稳定数据支撑。

言归正传,对于各种类型的埋点来说,曝光埋点往往最为复杂、需要用到的技术也最全面、如果实现方式不合理可能造成的影响也最大,因此本文将重点介绍曝光埋点尤其是长列表(或滚动视图)内元素曝光埋点的实现思路及避坑技巧;

1. 监听列表内元素曝光的常见方法

长列表(或滚动视图)中元素的曝光埋点,关键是如何监听子元素的 “曝光” 事件。“曝光” 即元素进入到了屏幕的可见区域,也就是能被用户看到了,这是人类的直观视觉感受,那么如何用代码的方式来判定呢?目前大概有这么三种方法:1.根据接口下发分页数据估算可见元素;2.监听滚动视图的滚动事件,实时计算元素相对位置;3. 利用浏览器(或其他平台如小程序、Taro)标准 API 监听元素与可见区域的相交变化。下面分别介绍一下这三种方法的具体原理、适用范围及优缺点。

1. 1 方式一:根据接口下发分页数据估算可见元素

实现思路:长列表的数据往往通过分页接口进行加载,可以利用这一特性,以单页数据返回的维度粗略估算元素的可见性,具体说就是以每一次的接口返回的数据当做当前可见的元素的列表;

优点:

  • 这种方式的好处是简单:仅仅根据分页接口每次请求的数据进行元素曝光的判断,计算很简单;

缺点:

  • 缺点就是误差太大:一方面分页接口单次请求的数据也往往会超出一屏,另一方面列表内元素的高度可能也是不同的、分页返回的数据条数也可能存在差异,这种方式来计算元素的曝光误差太大;

由于缺点很明显,误差太大,现在很少有人这么来实现曝光埋点,但是在很多精度要求不高的场景或者年代很久的代码中还能看到这种实现方式

1. 2 方式二:监听滚动事件,实时计算元素相对位置

实现思路:监听长列表(或滚动视图容器)的滚动事件,通过平台 UI 基础接口(如浏览器 DOM 接口 getBoundingClientRect)实时获取元素坐标(包括位置和大小信息等),并计算同可视区域的相对状态(是否有重叠)来判定元素是否 “可见”;

优点:

  • 相比方式一,精度有了很大的改进,如果计算的方式正确,计算结果可以说是准确的;
  • 另外由于使用的是平台内的通用基础能力接口,兼容性较好;

缺点:

  • 计算量大,性能损耗严重:这种计算方式需要监听滚动视图的滚动事件,在滚动回调事件内实时进行列表内所有元素的位置坐标计算(获取所有元素的位置并同当前可见区域进行对比),这样带来的计算量是相当大的,往往会造成页面的性能问题(如滑动卡顿);
  • 代码分散、逻辑复杂:除了需要监听滚动视图的滚动事件,还要在首屏数据加载或者数据刷新时,额外进行一次计算,整体复杂度及对页面的性能影响都比较大;
  • 其他问题:可能引发其他额外操作,如在 H5 中 getBoundingClientRect() 的频繁调用也可能引发浏览器的样式重计算和布局; iframe 里,无法直接访问内部元素等等;

这种方式虽然计算量大、逻辑复杂、性能较差(当然也可以进行一些性能上的优化,代价是代码复杂度变的更高,不利于后续更新维护),但是计算结果是准确的,在没有出现方法三中的 Web 端标准接口(2016)之前,在计算精度要求严格的场景下,这视乎是唯一的选择;

1. 3 方式三:利用浏览器标准 API 监听元素可视区变化

实现思路:Intersection Observer API做为一个专门用于监听页面元素相交变化的 Web 标准 API 接口,在 2016 年首先在 Chrone 浏览器中提供,并在随后的几年内得到了各主流浏览器的支持;利用该接口提供的异步查询元素相对于其他元素或窗口位置的能力,可以高效的对页面内元素的相交(可见性)变化进行监听;

优点:

  • 性能更高: 浏览器底层实现,并进行了相应优化,性能没有问题:监听不会在主线程进行(只要回调方法会在主线程触发)
  • 计算量小:这里的计算量小是指的我们 web 开发者需要进行的计算操作,因为大部分的计算浏览器 API 内已经帮我们计算好了,我们只需要根据需求场景在此基础上进行简单的处理即可满足需求;
  • 计算更结果准确:浏览器 API 实现的计算结果是比较准确的,这块毋庸置疑;
  • 代码更优雅:大部分的监听、计算逻辑都在 API 内部实现了,开发者的代码量不会太多太复杂,代码更简洁从而更利用后续维护;

缺点:

  • 需要新浏览器支持(根据文档描述的浏览器兼容情况其实已经满足绝大多数的使用场景),太低版本的浏览器不支持,如果需要兼容,也有办法,通过官方提供的 polyfill 可以解决(引入 polyfill,当然不可避免的带来代码体积的增量,项目中实测打包后 js 文件体积增大了 8kb,这算是唯一的缺点吧);(w3c 官方提供了对应 polyfill)

基于以上,这种方式是目前最推荐的一种实现元素曝光监听的方式,具体怎么用呢,下面对 H5、小程序、Taro 的使用场景分别来介绍一下。

2. 列表内元素曝光事件监听的具体实现

2. 1 Web(H5)端

简单来说,利用 Intersection Observer API 来进行视图元素的可见性观察主要分成这么几个步骤:创建观察者、对目标元素添加观察、处理观察结果(回调)、停止观察;

2. 1. 1 具体使用方法:

第一步:创建一个观察者(IntersectionObserver)

首先我们需要创建一个观察者IntersectionObserver ,用于监听目标元素相对于根视图(可以是父视图或当前窗口)的相交状态变化情况(即元素的 “可见” 状态)。具体创建方式是利用 Web 标准 API:IntersectionObserver构造方法,具体代码如下:

let observer = new IntersectionObserver(callback, options);
  • 其中callback 就是当监听到元素位置变化时触发的回调方法,具体定义及用法会在第三步处理观察结果中具体介绍;

  • options是定制观察模式的参数,参数定义如下

    interface IntersectionObserverInit {
        root?: Element | Document | null;
        rootMargin?: string;
        threshold?: number | number[];
    }
    
    • root: 用于指定观察的参照区域,一般是目标元素的父视图容器或整个视图窗口(必须是目标元素的父级元素。如果未指定或者为 null,则默认为浏览器视窗)
    • rootMargin:参照区域(root)的外边距,类似于 CSS 中的 margin 属性,比如 "10px 20px 30px 40px" (top, right, bottom, left),用于对参照物的区域范围进行调整(收缩或扩张);
    • threshold:相交比例阈值,用于定制需要观察的相交比例的临界值;元素的交集(相交比例)发生变化时并不是每次变化都会执行回调方法,只有当相交比例达到设置的阈值时才会触发回调(callback);可以是单一数值(number)也可以是一组数值;例如当设置为 0.25 时,只有当相交达到 0.25 时(增大到 0.25 或减小到 0.25 都会触发)才会触发回调;如果是一组数值的话,相交比例达到其中任意值时也都会触发回调;(备注:除此外,元素首次添加观察时也会触发一次回调,不论是否达到阈值)

例如上图中的 threshold 设置状态,每当元素滑动到虚线位置与父视图边界相交时就会触发回调

第二步:对目标元素添加观察

有了观察者后,就可以对目标元素进行观察了,具体代码如下:

let target = document.querySelector('#listItem');
observer.observe(target);

需要注意添加观察的时机,要保证在目标元素创建好以后再添加观察;如果是动态创建的元素(例如分页加载数据),需要在每次创建完元素后再次对新增的元素添加观察;

第三步:处理观察结果

当被观察的目标元素与参照视图(root)相交的比例达到设置的阈值时,就会触发我们注册的回调方法(callback),回调方法的定义如下:

interface IntersectionObserverCallback {
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

可以看到该回调方法内可以接收到两个参数:

  • entries :IntersectionObserverEntry 的数组,是相交状态发生变化的元素的集合,每个 IntersectionObserverEntry 对象内有 6 个属性:

    • time:发生相交的时间戳,单位毫秒。(发生交集变化的时间相对于文档创建的时间)
    • target:被观察的目标元素,是一个 DOM 节点对象;
    • rootBounds: root 元素(参照区域)的矩形边界
    • boundingClientRect:目标元素的边界信息,边界的计算方式与 Element.getBoundingClientRect() 相同。
    • intersectionRect:目标元素同 root 元素的相交区域
    • intersectionRatio:目标元素同根元素相交的部分尺寸与目标元素整体尺寸的比值,即 intersectionRect 与 boundingClientRect 的比值;
    • isIntersecting:目标元素同根元素是否相交(根据设定的阈值判定)
  • observer:当前观察者;

有了这些信息,就可以轻松监测目标元素的可见状态变化,方面进行后续的埋点上报、数据记录、延迟加载等各种处理;

注册的回调函数将会在主线程中被执行,所以该函数执行速度要尽可能的快。如果有一些耗时的操作需要执行,建议使用 Window.requestIdleCallback() 方法。

第四部:停止观察

如果需要停止观察,可以在合适的时间解除对某个元素的观察或终止对所有目标元素的观察;

// 停止观察某个目标元素
observer.unobserve(target)

// 终止对所有目标元素可见性变化的观察,关闭监视器
observer.disconnect()

2. 1. 2 Tips

需要注意的是在 Intersection Observer API 的 V2 版本新增了一个 isVisible 属性(新版 Chrome 浏览器已经支持,Safari 等其他浏览器内不支持),用来标识元素是否 “可见”(因为即使元素在可视区域内,也有肯能因为被其他元素遮挡、样式属性 hiden 等影响导致元素不能被看到);官方说明中,为了保证性能,这个字段的值不一定是准确的,除非特殊场景,不建议使用这个字段,大部分场景isIntersecting就够了;

感兴趣的可以查看文档说明:Intersection Observer V2 Explained

2. 1. 3 Intersection Observer API 的浏览器支持情况

浏览器 平台 支持的版本 发布时间
Chrome PC 51 2016-05-25
Chrome Android Android 51 2016-06-08
WebView Android Android 51 2016-06-08
Safari macOS 12.1 2019-03-25
Safari on iOS iOS 12.2 2019-03-25
Edge PC 15 2017-04-05
Firefox PC 55 2017-08-08
Firefox for Android Android 55 2017-08-08
Opera PC 38 2016-06-08
Opera Android Android 41 2016-10-25

可以根据具体使用场景(支持的浏览器版本情况)来决定是否直接使用标准 API 还是需要添加 polyfill 或其他方式来兼容低版本浏览器;

2. 2 小程序端(微信小程序)

同 Web 端接口类似,微信小程序提供了对应的小程序版本 API 接口,功能同 web 端的 Intersection Observer API 类似,使用方式也基本相同,只是部分细节存在差异;具体步骤:

第一步:创建一个观察者(IntersectionObserver)

通过微信小程序框架提供的IntersectionObserver wx.createIntersectionObserver(Object component, Object options)方法,可以方便的创建观察者;

Page({
  data: {
    appear: false
  },
  onLoad() {
    this._observer = wx.createIntersectionObserver(this,{
      thresholds: [0.2, 0.5]
    })
    //其他处理...
  },
  onUnload() {
    if (this._observer) this._observer.disconnect()
  }
})

类比 web 端的 IntersectionObserver 构造方法,不同的是小程序里这一步不需要设置回调方法,回调方法放到后面添加观察的时候注册;

入参说明:component一般需要传当前页面或组件实例;options可定义触发阈值、是否同时观测多个目标节点等信息

第二步:指定参照节点(参照区域)

不同于 web 端的创建时指定,小程序端提供了两个单独接口用于指定参照节点(参照区域)

  • IntersectionObserver IntersectionObserver.relativeTo(string selector, Object margins) :使用选择器指定一个节点,作为参照区域之一,即以该节点的布局区域作为参照区域。
  • IntersectionObserver IntersectionObserver.relativeToViewport(Object margins): 指定页面显示区域作为参照区域之一

示例:

this._observer = this._observer.relativeTo('.scroll-view')

同样可以通过margins来对参照区域进行扩展(或收缩);如果有多个参照节点,则会取它们布局区域的 交集 作为参照区域。

第三步:开启观察

通过前两步创建好观察者,设置好相关参数(触发阈值、是否多目标等)并指定参照区域后,就可以对目标元素进行观察了。这里通过选择器的方式(web 端是元素实例)来指定目标元素,同时这里需要指定相交状态变化的回调方法:IntersectionObserver.observe(string targetSelector, function callback)

示例:

this._observer.observe('.ball', (res) => {
    console.log(res);
    this.setData({
        appear: res.intersectionRatio > 0
    })
})

第四步:处理回调

当元素相对于参照区域的相交情况发生变化(同 web 端一致,触发时机由第一步创建观察者时设置的 thresholds 阈值决定)就会触发相应的回调方法。回调方法内接受的参数同 web 端基本一致,但也存在差异:

  • 小程序端是单个触发,回调方法的入参是单个元素(对比 web 端是多个一起回调,入参是变化元素的数组);
  • 小程序端入参内同时包含目标节点的节点 ID 及自定义数据;

第五步:停止监听

IntersectionObserver.disconnect()

停止监听。回调函数将不再触发

if (this._observer) this._observer.disconnect()

Tips

注意:在组件内,如果在 attached 组件生命周期函数内添加内部子元素的相交变化观察可能无法监听成功,原因是此时组件布局还未完成,组件内节点未完成创建;

2. 3 Taro 框架内(Taro3+React)

Taro 内也提供了对应的 IntersectionObserver 的 API,其 API 的定义及使用方式基本是同微信小程序端对齐的;Taro 本身是支持 H5、小程序等多端的,其 IntersectionObserver 接口内部对 H5、微信小程序、京东小程序等各平台进行了对齐抹平,具体来说在 H5 端是按照微信小程序端的格式进行的封装,其内部实现是调用的 Web 端的 Intersection Observer API,在小程序端由于标准对齐,基本上就是桥接对应平台小程序原生的接口;

由于接口定义及使用方式同微信小程序对齐,这里就不再赘述 Taro 端的具体使用方式,需要说明的是由于 Taro 框架的特殊性(相比小程序原生方式多了一层),在用 Taro 进行小程序端滑动曝光监听开发时,有几个容易出错或需要特殊处理的点:

1. 监听不生效的问题

由于 Taro 运行时机制,在 Taro 组件的数据更新方法(例如 setState)执行后立刻添加监听可能会不生效,原因是对应的由数据驱动的小程序元素实例此时还未完成创建或挂载,需要添加延迟或在 Taro.nextTick 回调内执行(Taro 最新版本已经默认将 observe 方法添加到 Taro.nextTick 内执行);如果遇到添加监听不生效的情况,可以尝试这个方法;

Taro.nextTick(() => {
    //将监听添加时机(延迟作到下一个时间片再执行),解决监听添加时元素尚未创建导致的监听无效问题
    expoObjserver.relativeTo('.clpScrollView').observe('.coupon-item', result => {
        console.log(result.intersectionRatio);
        //...
    });
});

2. 创建 Observer 需传入原生组件实例

在创建 observer 时需要传入小程序的页面或者组件实例,而在 Taro 组件或页面内直接使用 this 获取的是 Taro 层的页面或组件的实例,两者是不同的;

那么如何获取小程序层的组件实例呢:

  • 在纯 Taro 项目中可以直接使用Taro.getCurrentInstance().page获取小程序页面的实例;
  • 如果是把 Taro 组件编译为原生自定义组件的混合模式,可以通过 props.$scope 获取到小程序的自定义组件对象实例

3. 回调方法内如何获取目标元素的其他信息?

如果创建及设置正确,随着列表的滑动或其他元素的位置变化,对应的回调方法应该会被触发,在回调方法内我们需要接收回调的入参数并进行处理(例如上报相关业务信息)。根据 Taro 文档定义,回调方法的入参是ObserveCallbackResult类型:

interface ObserveCallbackResult {
      /** 目标边界 */
      boundingClientRect: BoundingClientRectResult
      /** 相交比例 */
      intersectionRatio: number
      /** 相交区域的边界 */
      intersectionRect: IntersectionRectResult
      /** 参照区域的边界 */
      relativeRect: RelativeRectResult
      /** 相交检测时的时间戳 */
      time: number
    }

可以看到其中只有 intersectionRatio、time 等位置相交的相关信息,但是却没有自定义数据字段(作为对比微信小程序提供的回调方法内除了这些还包括节点 ID、节点自定义数据属性 dataset 等信息),那么在 Taro 内如何获取目标元素上的其他数据信息呢?

实际调试发现,虽然文档中没有 id、dataset,但是实际返回值内是有这俩字段的,哈哈,看到这里是不是觉得没啥问题了,以为只是 Taro 文档跟我们开了个小小的玩笑;先别高兴的太早,虽然实际返回值里有 dataset 字段,但是不幸的是 dataset 字段始终是空的,实际返回结果(result)示例如下:

{
    "id": "_n_82",
    "dataset": {},
    "time": 1687763057268,
    "boundingClientRect": {
        "x": 89.109375,
        "y": 19,
        ...
    },
    "intersectionRatio": 1,
    "intersectionRect": {
        "x": 89.109375,
        "y": 19,
        ...
    },
    "relativeRect": {
        "x": 82.109375,
        ...
    }
}

what?why?看到这里估计大家有想砸键盘的冲动,先别着急,我们先来分析一下为什么dataset是空呢?

这是由于dataset是小程序的特殊的模版属性,主要作用是可以在事件回调的 event 对象中获取到 dataset 相关数据,Taro 对于这些能力是部分支持的,Taro 通过在逻辑层的模拟已经支持在事件回调对象中通过 event.target.dataset 或 event.currentTarget.dataset 获取到。但是由于是在逻辑层模拟实现的,并没有真正在模板设置这个属性。所以在小程序中有一些 API(如:createIntersectionObserver)获取到页面的节点的时候,是获取不到dataset的。

上一点所说的,Taro 对于小程序 dataset 的模拟是在小程序的逻辑层实现的。并没有真正在模板设置这个属性。

但在小程序中有一些 API(如:createIntersectionObserver)获取到页面的节点的时候,由于节点上实际没有对应的属性而获取不到。

--来自 Taro 官方文档: Taro-React-dataset

既然在回调传参中直接取值是空,那我们该怎么获取元素上的自定义数据呢?

方案一:taro-plugin-inject 方案

官方给出的解决方案是使用taro-plugin-inject插件,向子元素内注入一些通用属性;实际验证发现,利用插件插入后回调的 dataset 中确实能看到有对应的属性,但是该方法插入的属性只能是统一的固定值,无法根据实际数据动态设置属性值,因此该方案不能满足诉求。

//项目 config/index.js 中的 plugins 配置:
  plugins: [
    [
      '@tarojs/plugin-inject',
      {
        components: {
          View: {
            'data-index': "'dataIndex'",
            'data-info': "'dateInfo'"
          },
        },
      },
    ]
  ],

//实际返回值
{
    "id": "_n_82",
    "dataset": {
        "index": "dataIndex",
        "info": "dateInfo"
    },
    "time": 1687766275879,
    ...
}
方案二:访问 Taro 虚拟 DOM

根据 Taro 官方文档关于 React 框架使用差异的描述 (Taro-React-生命周期触发机制),Taro3 在小程序逻辑层上实现了一份遵循 Web 标准 BOM 和 DOM API。通过这些 API 可以获取对应的虚拟 DOM 节点 (TaroElement 对象),既然是逻辑层实现的,那么节点上应该也能看到对应的dataset信息。

Taro DOM Reference文档内的 TaroElement 字段说明也证实了这一点。

那么具体如何实现呢?回调参数中虽然没有我们想要的自定义数据字段,但是可以拿到节点 id 信息,可以通过 Taro 提供的document.getElementById();API 利用节点 id 获取对应的 Taro 虚拟 DOM 节点,从该节点上拿到我们需要的dataset信息,代码如下:

Taro.nextTick(() => {
    //将监听添加时机(延迟作到下一个时间片再执行),解决监听添加时元素尚未创建导致的监听无效问题
    expoObjserver.relativeTo('.clpScrollView').observe('.coupon-item', result => {
        if (!result?.id) return;
        // !!!获取虚拟DOM节点
        const tarTaroElement = document.getElementById(result?.id);
        const dataInfo = tarTaroElement?.dataset; //拿到dataset信息
        console.log(tarTaroElement);
        console.log(dataInfo);
       //...
    });
});

至此,我们就拿到了想要的自定义数据(业务数据),后续就是根据述求随意的使用这些数据了,Taro 内列表滑动元素曝光埋点搞定~

总结一下,通过上面几种常见方案和各平台内的具体实现的介绍,长列表滑动元素的曝光监听问题应该不再是难事,搞定了滑动元素曝光监听,基于此之上的曝光埋点或者其他高级玩法(如长列表优化 - 资源惰性加载、无限循环滚动等)后续我们都可以从容应对。

参考资料

作者:京东零售 丁鑫

来源:京东云开发者社区

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册