小程序中通过IntersectionObserver实现曝光统计

小程序中的IntersectionObserver

1. 背景

小程序中,需要监听某些元素是否出现在可视区域内,比如帖子列表、商品列表曝光数据采集上报。

2. 官方文档

IntersectionObserver

IntersectionObserver 用于推断某些节点是否可以被用户看见、有多大比例可以被用户看见。

3. 简易使用

3.1 创建

js 复制代码
const observer = wx.createIntersectionObserver(this, options)

3.2 监听

js 复制代码
// 使用选择器指定一个节点,作为参照区域之一
observer.relativeTo(selector, margin)

或者

js 复制代码
// 指定页面显示区域作为参照区域之一
observer.relativeToViewport(margin)

3.3 停止监听

js 复制代码
observer.disconnect()

3.4 监听回调

js 复制代码
observer.observe(selector, callback)

4. 实战

4.1 监听帖子列表曝光

为了使用简易,封装了一个 IntersectionObserver 类,下面贴出核心代码:

js 复制代码
export default class IntersectionObserver {
  constructor(options) {
    this.options = {
      // 阈值 0-1
      thresholds: options.thresholds || 0.5,
      // 是否观察所有节点
      observeAll: options.observeAll || false,
      // 初始的相交比例
      initialRatio: options.initialRatio || 0,
      // 上下文,默认为当前页面
      context: options.context || null,
      // 目标节点
      selector: options.selector || null,
      // 延迟时间
      delayTime: options.delayTime || 200,
      // 相对于某个元素
      relativeToTarget: options.relativeToTarget || null,
      // 相对于某个元素的位置
      relativeToOptions: options.relativeToOptions || {},
      // 相对于视口的位置
      relativeToViewportOptions: options.relativeToViewportOptions || {},
      // 每次触发的回调
      onForEach: options.onForEach || ((res) => res.dataset),
      // 最后触发的回调
      onFinalCallback: options.onFinalCallback || (() => null),
      ...options,
    };
    
    // 监听器
    this.observer = null;
    // 定时器
    this.timer = null;
    // 当前收集的数据
    this.observerData = [];
  }

  connect() {
    if (this.observer) return this;
    this.observer = this.createObserver();
    return this;
  }

  // 重新连接
  reconnect() {
    this.disconnect();
    this.connect();
  }

  createObserver() {
    const observerOptions = {
      thresholds: [this.options.thresholds],
      observeAll: this.options.observeAll,
      initialRatio: this.options.initialRatio,
    };
    // 创建监听器
    const ob = this.options.context
      ? this.options.context.createIntersectionObserver(observerOptions)
      : wx.createIntersectionObserver(null, observerOptions);

    // 设置相对于某个元素
    if (this.options.relativeToTarget) {
      ob.relativeTo(this.options.relativeToTarget, this.options.relativeToOptions);
    } else {
      ob.relativeToViewport(this.options.relativeToViewportOptions);
    }

    let isCollecting = false;
    let observerData = [];
    // 监听回调
    ob.observe(this.options.selector, (res) => {
      const { intersectionRatio, intersectionRect } = res;
      const visible = intersectionRatio >= this.options.thresholds;

      if (!visible) return;

      const data = this.options.onForEach(res);
      observerData.push(data);
      

      if (isCollecting) return
      isCollecting = true

      // 延迟执行
      this.timer = setTimeout(() => {
        this.options.onFinalCallback(observerData);
        isCollecting = false;
        observerData = [];
      }, this.options.delayTime);
    })

    return ob;
  }



  // 断开连接
  disconnect() {
    if (!this.observer) return;
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    } 
    this.observer.disconnect();
    this.observer = null;
  }
}

4.2 参数说明

js 复制代码
const options = {
  // 阈值 0-1
  thresholds: 0.5,
  // 是否观察所有节点
  observeAll: false,
  // 初始的相交比例
  initialRatio: 0,
  // 上下文,默认为当前页面
  context: null,
  // 目标节点
  selector: null, 
  // 延迟时间
  delayTime: 200,
  // 相对于某个元素
  relativeToTarget: null,
  // 相对于某个元素的位置
  relativeToOptions: {},
  // 相对于视口的位置
  relativeToViewportOptions: {},
  // 每次触发的回调
  onForEach: (res) => res.dataset, // 最后触发的回调
  onFinalCallback: (res) => res,
};
  • context:上下文,一般传入 this 即可,在自定义组件中,也必须是 this
  • selector:目标节点, 比如列表的class类名'.list' 。
  • relativeToTarget:相对于某个元素,传了此参数,即开启了relativeTo模式,即 使用选择器指定一个节点,作为参照区域,无参数则代表使用relativeToViewport,即指定页面显示区域作为参照区域也就是可视区域。
  • relativeToOptions:relativeToTarget有值开启之后才生效,相对于某个元素的位置,一般传入{ bottom: 0 },代表相对于某个元素底部,其参数如下:
    • top:顶部
    • bottom:底部
    • left:左边
    • right:右边
  • relativeToViewportOptions:相对于视口的位置,一般传入{ bottom: 0 },代表相对于视口底部,其参数如下:
    • top:顶部
    • bottom:底部
    • left:左边
    • right:右边
  • thresholds:阈值,0-1,默认为0.5,即相交比例达到50%时触发回调。
  • observeAll:是否观察所有节点,默认为false,即只观察第一个匹配的节点。
  • initialRatio:初始的相交比例,默认为0,即初始时相交比例为0。
  • delayTime:延迟时间,单位为毫秒,默认为200,即延迟200毫秒触发回调。
  • onForEach:每次触发的回调,参数为IntersectionObserver实例的回调参数,默认为res => res.dataset,即返回节点数据。
  • onFinalCallback:最后触发的回调,参数为IntersectionObserver实例的回调参数,默认为res => res,即返回节点数据。

4.3 页面中使用

js 复制代码
import IntersectionObserver from '../../utils/IntersectionObserver.js';
Page({
  data: {
    // 列表数据
    list: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
  },

  onLoad() {
    // 初始化IntersectionObserver
    this.observer = new IntersectionObserver({
      selector: '.list',  
      observeAll: true,
      context: this,
      onForEach: ({dataset}) => {
        // id以及key对应list列表上的自定义属性,例如:data-id="1" data-key="0"
        const { id, key } = dataset;
        // 这里需要返回想要的数据,例如:帖子id
        return { id };
      },
      onFinalCallback: (args) => {
        console.log('observer view', args);
      },
    });

    // 连接IntersectionObserver
    this.observer.connect();
  },

  onUnload() {
    // 断开IntersectionObserver
    this.observer.disconnect();
  },
});
wxml 复制代码
<view class="container">
  <view class="list" wx:for="{{list}}" wx:key="index" data-id="{{item}}" data-key="{{index}}">
    {{item}}
  </view>
</view>

说明:在IntersectionObserver中,我们通过observe方法来观察目标节点,当目标节点与视口相交时,会触发回调函数。在回调函数中,我们可以获取到目标节点的数据,并进行相应的处理。在页面中,我们通过onLoad方法来初始化IntersectionObserver,并在onUnload方法中断开连接。

onFinalCallback回调中,我们已经能拿到想要的数据,例如:帖子id,然后我们可以进行相应的处理,例如:发送请求,上报给服务端即可统计到帖子的曝光量,根据自己的业务进行操作。

4.4 存在的问题以及解决方案

  1. 在小程序中,IntersectionObserver的回调函数是在onLoad生命周期中触发的,因此,如果页面中的节点在onLoad生命周期之前就已经加载完毕,那么IntersectionObserver将无法获取到这些节点的数据。另外,列表数据大部分场景都是异步获取的,因此,如果列表数据在IntersectionObserver初始化之前就已经获取到了,那么IntersectionObserver也无法获取到这些节点的数据。

解决方案:在IntersectionObserver实例化之前,我们可以先获取到列表数据,然后通过setData方法将列表数据更新到页面中,这样,IntersectionObserver就可以获取到这些节点的数据了,也就是在通过接口获取到数据之后,再初始化IntersectionObserver,保证IntersectionObserver初始化时,列表数据已经获取并渲染到了页面。

  1. 大部分场景,列表都是异步获取,并且是有分页的,因此,在分页加载时,IntersectionObserver无法获取到新加载的节点的数据。下面是一个分页加载的示例:
js 复制代码
 onReachBottom() {
   const { list } = this.data;
   list.push(11, 12, 13, 14, 15, 16, 17, 18, 19, 20);
   this.setData({
     list: list.concat(list)
   })
 },

上面是通过onReachBottom方法来模拟分页加载,每次加载10条数据,当用户滑动到底部时,会触发onReachBottom方法,然后通过setData方法将新加载的数据更新到页面中。实际测试效果如下:

解决方案:在分页加载时,我们需要在setData的回调函数中,重连,也就是调用this.ob.reconnect();,这个时候,IntersectionObserver就相当于关闭之前的观察期,然后重新链接,因为这个时候IntersectionObserver会监听到新加进来的元素,这样就能保证获取到新加载的节点的数据了。实际测试效果如下:

  1. 上面的方案虽然能通过重连的方式监听到可视化区域的元素,但是很明显分页的时候也就加载了11,12这2条,但是确将之前已经可视化的元素也重新加载了,这样明显是不合理的,因此,我们需要在分页的时候,将之前已经可视化的元素过滤掉,只加载未可视化的元素。

解决方案:在分页的时候,我们需要在ob.observe的回调函数中,判断当前节点是否已经可视化了,如果已经可视化了,那么就不加载,否则就加载。具体实现如下:

类里面的有个this.observerData数组,用来存储上次已经可视化的元素,在onFinalCallback函数中将本次可视化的元素存储到this.observerData数组中。下一次重连的时候,通过this.observerData数组中的最后一项,找到observerData中的索引位置,截取掉observerData数组中已经可视化的元素,这样就能保证分页的时候,只加载未可视化的元素了。具体代码如下:

js 复制代码
  // 延迟执行
  this.timer = setTimeout(() => {
    
    // 重新连接的时候,需要过滤掉之前已经监听到的数据
    const lastObserverData = this.observerData.length ? this.observerData[this.observerData.length - 1] : null;
    const index = observerData.findIndex(item => item === lastObserverData);
    if (index !== -1) {
      observerData = observerData.slice(index + 1);
    }
    
    this.options.onFinalCallback(observerData);
    // 保存当前数据,需要断开连接时使用
    this.observerData = observerData;
    isCollecting = false;
    observerData = [];
  }, this.options.delayTime);

实际测试效果如下:

  1. 使用时的优化,为了在使用过程中,会触发接口频繁上报,可以用一个防抖函数,控制上报的评率,也可以在onFinalCallback函数中,将接口请求放到setTimeout中,这样就能保证接口请求不会频繁的上报了。具体代码如下:
js 复制代码
  onReady() {
    this.ob = new IntersectionObserver({
      selector: '.list',
      observeAll: true,
      context: this,
      onForEach: ({ dataset }) => {
        const { key, id } = dataset || {} 
        return id
      },
      onFinalCallback: args => {
        if (!args) return
        console.log('observer view', args)
        // 如果需要过滤已经上报的数据,可以在这里过滤
        // reportids:全局定义的一个数组,用来存储上报的id
        for (var i = 0; i < args.length; i++) {
          if (reportids.indexOf(args[i]) == -1) reportids.push(args[i]);
        }
        if (timer != null) {
          clearTimeout(timer);
        }
        timer = setTimeout(function () {
          console.log(reportids);
          // ajax 上报逻辑
          reportids = [];
        }, 400)
      },
    }).connect()
  },

4.5 组件中使用

有的时候,帖子列表或者商品列表单个列表被包裹在一个组件中,这个时候,我们只需要在组件中,监听IntersectionObserver即可,不需要在页面中监听,具体代码如下:

wxml 复制代码
<view class="lists">
   <list-item wx:for="{{list}}" wx:key="unique" index="{{index}}" item="{{item}}"></list-item>
</view>

listItem.js:

js 复制代码
import IntersectionObserver from '../../../../utils/IntersectionObserver.js';
let reportid = [];
let iTime = null;

Component({
  behaviors: [],
  properties: {
    item: {
      type: Object,
      value: {},
    },
    index: {
      type: Number,
      value: 0,
    },
  },
  data: {

  },
  lifetimes: {
    created() {

    },
    attached() {

    },
    ready() {
      this.ob = new IntersectionObserver({
        selector: '.list',//自定监听的class 元素
        observeAll: true,
        context: this,
        onForEach: ({ dataset }) => {
          const { id, key } = dataset || {} //获取设置的key 值 ,自定义
          return key
        },
        onFinalCallback: args => {
          if (!args) return
          console.log('observer view', args)//打印监听到的元素
          for (var i = 0; i < args.length; i++) {
            if (reportid.indexOf(args[i]) == -1) reportid.push(args[i]);
          }
          if (iTime != null) {
            clearTimeout(iTime);
          }
          iTime = setTimeout(function () {
            var reportidstr = reportid.toString();
            reportid = [];
          }, 800)


        },
      })
      this.ob.connect()
    },
    moved() {

    },
    detached() {

    },
  },
  methods: {

  },
});

测试效果如下:

这种使用方式,就不需要通过重新连接的方式来处理分页获取到的动态数据了,组件ready的时候,必定是能获取到元素节点的,只不过上报的时候是一条条单个数据上报的,所以需要设置一个定时器,来合并上报,避免频繁的上报。

4.6 相对于某个元素

js 复制代码
 onReady() {
    this.ob = new IntersectionObserver({
      selector: '.list',
      observeAll: true,
      relativeToTarget: '.header',
      relativeToOptions: {
        bottom: 50,
      },
      context: this,
      onForEach: ({ dataset }) => {
        const { key, id } = dataset || {} 
        return id
      },
      onFinalCallback: args => {
        if (!args) return
        console.log('observer view', args)
        for (var i = 0; i < args.length; i++) {
          if (reportids.indexOf(args[i]) == -1) reportids.push(args[i]);
        }
        if (timer != null) {
          clearTimeout(timer);
        }
        timer = setTimeout(function () {
          console.log(reportids);
          // ajax 上报逻辑
          reportids = [];
        }, 400)
      },
    }).connect()
  },

4.8 relativeTo扩展

通过relativeTo,可以监听相对于某个元素,并且可以设置相对于某个元素的位置,比如距离顶部多少,距离底部多少,距离左侧多少,距离右侧多少。可以通过这个实现pageScroll对某个元素的的监听,监听某个元素距离顶部多少。

相关推荐
sasaraku.22 分钟前
serviceWorker缓存资源
前端
RadiumAg1 小时前
记一道有趣的面试题
前端·javascript
yangzhi_emo1 小时前
ES6笔记2
开发语言·前端·javascript
yanlele2 小时前
我用爬虫抓取了 25 年 5 月掘金热门面试文章
前端·javascript·面试
The_era_achievs_hero2 小时前
微信小程序71~80
微信小程序·小程序
中微子3 小时前
React状态管理最佳实践
前端
烛阴3 小时前
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
前端·javascript
中微子3 小时前
JavaScript 事件与 React 合成事件完全指南:从入门到精通
前端
Hexene...3 小时前
【前端Vue】如何实现echarts图表根据父元素宽度自适应大小
前端·vue.js·echarts
dssxyz3 小时前
uniapp打包微信小程序主包过大问题_uniapp 微信小程序时主包太大和vendor.js过大
javascript·微信小程序·uni-app