一文帮你搞定H5、小程序、Taro长列表曝光埋点
实现思路:长列表的数据往往通过分页接口进行加载,可以利用这一特性,以单页数据返回的维度粗略估算元素的可见性,具体说就是以每一次的接口返回的数据当做当前可见的元素的列表。
这种方式的好处是简单:仅仅根据分页接口每次请求的数据进行元素曝光的判断,计算很简单。
缺点就是误差太大:一方面分页接口单次请求的数据也往往会超出一屏,另一方面列表内元素的高度可能也是不同的、分页返回的数据条数也可能存在差异,这种方式来计算元素的曝光误差太大;由于缺点很明显,误差太大,现在很少有人这么来实现曝光埋点,但是在很多精度要求不高的场景或者年代很久的代码中还能看到这种实现方式。
实现思路:监听长列表(或滚动视图容器)的滚动事件,通过平台UI基础接口(如浏览器DOM接口getBoundingClientRect)实时获取元素坐标(包括位置和大小信息等),并计算同可视区域的相对状态(是否有重叠)来判定元素是否“可见”。
相比方式一,精度有了很大的改进,如果计算的方式正确,计算结果可以说是准确的。 另外由于使用的是平台内的通用基础能力接口,兼容性较好。
计算量大,性能损耗严重:这种计算方式需要监听滚动视图的滚动事件,在滚动回调事件内实时进行列表内所有元素的位置坐标计算(获取所有元素的位置并同当前可见区域进行对比),这样带来的计算量是相当大的,往往会造成页面的性能问题(如滑动卡顿)。 代码分散、逻辑复杂:除了需要监听滚动视图的滚动事件,还要在首屏数据加载或者数据刷新时,额外进行一次计算,整体复杂度及对页面的性能影响都比较大。 其他问题:可能引发其他额外操作,如在H5中getBoundingClientRect() 的频繁调用也可能引发浏览器的样式重计算和布局; iframe 里,无法直接访问内部元素等等。
这种方式虽然计算量大、逻辑复杂、性能较差(当然也可以进行一些性能上的优化,代价是代码复杂度变的更高,不利于后续更新维护),但是计算结果是准确的,在没有出现方法三中的Web端标准接口(2016)之前,在计算精度要求严格的场景下,这视乎是唯一的选择。
实现思路:Intersection Observer API做为一个专门用于监听页面元素相交变化的Web标准API接口,在2016年首先在Chrone浏览器中提供,并在随后的几年内得到了各主流浏览器的支持;利用该接口提供的异步查询元素相对于其他元素或窗口位置的能力,可以高效的对页面内元素的相交(可见性)变化进行监听。
性能更高: 浏览器底层实现,并进行了相应优化,性能没有问题:监听不会在主线程进行(只要回调方法会在主线程触发)。 计算量小:这里的计算量小是指的我们web开发者需要进行的计算操作,因为大部分的计算浏览器API内已经帮我们计算好了,我们只需要根据需求场景在此基础上进行简单的处理即可满足需求。 计算更结果准确:浏览器API实现的计算结果是比较准确的,这块毋庸置疑。 代码更优雅:大部分的监听、计算逻辑都在API内部实现了,开发者的代码量不会太多太复杂,代码更简洁从而更利用后续维护。
需要新浏览器支持(根据文档描述的浏览器兼容情况其实已经满足绝大多数的使用场景),太低版本的浏览器不支持,如果需要兼容,也有办法,通过官方提供的polyfill可以解决(引入polyfill,当然不可避免的带来代码体积的增量,项目中实测打包后js文件体积增大了8kb,这算是唯一的缺点吧);(w3c 官方提供了对应 polyfill)。
2.1. Web(H5)端
简单来说,利用Intersection Observer API来进行视图元素的可见性观察主要分成这么几个步骤:创建观察者、对目标元素添加观察、处理观察结果(回调)、停止观察。
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),用于对参照物的区域范围进行调整(收缩或扩张)。
let target = document.querySelector('#listItem');observer.observe(target);
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:当前观察者;
// 停止观察某个目标元素observer.unobserve(target)// 终止对所有目标元素可见性变化的观察,关闭监视器observer.disconnect()
| 浏览器 | 平台 | 支持的版本 | 发布时间 |
| 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 |
2.2. 小程序端(微信小程序)
Page({data: {appear: false},onLoad() {this._observer = wx.createIntersectionObserver(this,{thresholds: [0.2, 0.5]})//其他处理...},onUnload() {if (this._observer) this._observer.disconnect()}})
IntersectionObserver IntersectionObserver.relativeTo(string selector, Object margins) :使用选择器指定一个节点,作为参照区域之一,即以该节点的布局区域作为参照区域。 IntersectionObserver IntersectionObserver.relativeToViewport(Object margins): 指定页面显示区域作为参照区域之一。
this._observer = this._observer.relativeTo('.scroll-view')this._observer.observe('.ball', (res) => {console.log(res);this.setData({appear: res.intersectionRatio > 0})})
小程序端是单个触发,回调方法的入参是单个元素(对比web端是多个一起回调,入参是变化元素的数组)。 小程序端入参内同时包含目标节点的节点ID及自定义数据。
if (this._observer) this._observer.disconnect()注意:在组件内,如果在attached组件生命周期函数内添加内部子元素的相交变化观察可能无法监听成功,原因是此时组件布局还未完成,组件内节点未完成创建。
Taro.nextTick(() => {//将监听添加时机(延迟作到下一个时间片再执行),解决监听添加时元素尚未创建导致的监听无效问题expoObjserver.relativeTo('.clpScrollView').observe('.coupon-item', result => {console.log(result.intersectionRatio);//...});});
在纯Taro项目中可以直接使用Taro.getCurrentInstance().page获取小程序页面的实例。 如果是把 Taro 组件编译为原生自定义组件的混合模式,可以通过 props.$scope 获取到小程序的自定义组件对象实例。
interface ObserveCallbackResult {/** 目标边界 */boundingClientRect: BoundingClientRectResult/** 相交比例 */intersectionRatio: number/** 相交区域的边界 */intersectionRect: IntersectionRectResult/** 参照区域的边界 */relativeRect: RelativeRectResult/** 相交检测时的时间戳 */time: number}
{"id": "_n_82","dataset": {},"time": 1687763057268,"boundingClientRect": {"x": 89.109375,"y": 19,...},"intersectionRatio": 1,"intersectionRect": {"x": 89.109375,"y": 19,...},"relativeRect": {"x": 82.109375,...}}
//项目 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.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);//...});});
618技术大揭秘:Switchquery秒级配置触达平台的设计与实现