如何优雅的设计微信小程序前端曝光埋点上报方案

3,068 阅读5分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

背景介绍

最近新入职了一家公司,接手之前的人做的前端埋点曝光方案,业务代码真是一言难尽。

据说是一位来自大厂的人,然后过来主要就做了埋点上报,做了两个月就跑了,我个人觉得还是很诧异的。因为在我印象中这个不是很简单的吗,需要做两个月??

我刚来,就接手了这个烂摊子(其实就是个烂尾解决实施方案,代码难看难维护,与业务代码极度耦合,问题也很多),也没谁了解这块代码。因为新人嘛,对业务刚开始肯定是不大清楚的,前任做了两个月跑路了,我还以为是啥神仙需求呢,故前几天也没敢乱动。就算问题很多,也只是在原代码上修修补补。

后来改着改着慢慢就大概了解是个啥业务了,然后又有新的埋点需求评审。原方案本来就各种问题,埋点上报数据错误、时机错误、与业务代码强耦合等等。

埋点这种通用需求说到底就是:基础没做好,后面就是大坑;基础做好了,后面就极其简单,基本就是哪里需要就在哪里简单加个标识就行了。

所以我提议先优化重构整个埋点曝光方案,项目开发负责人比较高兴的答应了。然后我大概花了2个小时输出了重构方案文档,对原方案和重构方案进行了深度对比。后来在前端组内进行了介绍和评审,对整个项目的埋点曝光进行了全部重构。下面做下技术对比,欢迎一起讨论。

原曝光方案介绍

原曝光方案核心代码

1、逻辑复用:主要逻辑就是监听 scroll 事件(有页面的、也有组件的)、然后 scroll 时触发遍历 list 数据、对每项元素进行监听进行位置判断是否在展示区域

/**
 * exposeMixin 元素曝光监听上报使用说明
 * 页面公共的必要参数有3个:
 * @param {Number} ctPageId 所在页面的id
 * @param {Array} listData 当前页面元素列表数据,列表里的每一项都有resourceId,可以用来记录用户行为,并且在同一页面如果多个列表需要合并到这个列表里
 * @param {String} exposeClass `expose_${item.resourceId}` 当前元素的唯一标识class,对需要被监听的元素都要添加,否则无法被监听
 * 滚动分三种情况:
 * 列表在子组件,父组件滚动:这种情况需要在父组件添加 onPageScroll: debounces(() => {uni.$emit('on-page-scroll')}, 800), 子组件列表页混入mixin,reportData方法生效
 * 列表就在当前页面,当前页面滚动:这种情况只需要混入mixin,onPageScroll: debounces(function () {const that = this;that.collectData()}, 800)方法生效
 * 不用系统滚动,用的是scroll-view的滚动, handleScroll: debounces(function () {const that = this;that.collectData()}, 800)
 */
 import { addExporeEventListener } from '@/util/log'
 import { debounces } from '@/util/util'
 export default {
   data() {
     return {
       timer: null,
       data: [], // 当前页面元素列表数据
       arr: [] // 存储所有的曝光事件,去重,放入队列维护,隔一段时间上报
     }
   },
   // 当前页面滚动的时候,添加监听
   onPageScroll: debounces(function () {
     const that = this
     that.collectData()
   }, 800),
   methods: {
     // scroll-view滚动的时候,添加监听
     handleScroll: debounces(function () {
       const that = this
       that.collectData()
     }, 800),
     // 收集需要上报的数据,并在队列中放入,隔一段时间上报
     collectData() {
       const data = this.data
       if (data && Array.isArray(data) && data.length) {
         data.forEach((item, index) => {
           this.listenData(item, index)
         })
       }
       this.reportExpose()
     },
     /**
      * 监听元素类名的曝光事件
      * @param {Object} item 当前元素对象数据,包含需要上报的一些数据
      * @param {Number} index 当前元素的索引,也是上报所需数据
      */
     listenData(item, index) {
       const { resourceId, doctorId } = item
       ;(resourceId || doctorId) &&
         addExporeEventListener(`.expose_${resourceId || doctorId}`, this, (duration, start_time, end_time) => {
           if (duration > 1000) {
             // ... 业务代码
             this.arr.push(option)
           }
         })
     },
     // 子组件是列表的情况,需要父组件$emit触发事件,子组件监听父组件的滚动并且上报数据
     reportData() {
       const data = this.data
       if (data && Array.isArray(data) && data.length) {
         data.forEach((item, index) => {
           uni.$on('on-page-scroll', () => {
             this.listenData(item, index)
           })
         })
         uni.$on('on-page-scroll', () => {
           this.reportExpose()
         })
       }
     },
     // 队列数据的上报方法,如果有数据就上报,没有就不上报
     reportExpose() {
       const reportArr = [...new Set(this.arr)]
       // 延迟上报
       if (this.arr.length) {
         uni.$sendTrackerBach(reportArr)
         // 上报一次之后清空队列
         this.arr = []
       }
     },
     destroyReport() {
       this.$nextTick(() => {
         this.reportExpose()
       })
     }
   },
   // 页面销毁之前先上报
   beforeDestroy() {
     this.destroyReport()
   },
   onHide() {
     this.destroyReport()
   },
   watch: {
     // 对列表数据的监听,数据可能异步发生变化,或者接口获取数据加载更多
     listData: {
       handler(val) {
         this.data = val
         this.$nextTick(() => {
           this.collectData() // 直接在这个页面或组件的滚动曝光
           this.reportData() // 子组件监听父组件的滚动情况上报曝光
         })
       },
       deep: true,
       immediate: true
     }
   }
 }

2、元素事件监听是否在可视区域

/**
 * 多个元素曝光,元素在可视区曝光时触发的事件, 可以用来记录用户行为
 * @param {Element} el 元素class/id
 * @param {Element} self 绑定的this,一定要有,否则无法获取到元素
 * @param {Function} callback 回调函数
 */
export const addExporeEventListener = (el, self, callback) => {
  // 屏幕可视高度
  const windowHeight = uni.getSystemInfoSync().windowHeight
  self.$u.getRect(el).then((res) => {
    if (res && res?.top) {
      if (res.top > 0 && res.top < windowHeight - res.height) {
        // 可视区域内 100% 曝光
        self[el] = Date.now()
      }
      if (self[el] && (res.top < 0 || res.top > windowHeight - res.height)) {
        // 有进入才有离开---页面销毁或者隐藏也算离开
        self[`${el}dur`] = Date.now() - self[el]
        callback(self[`${el}dur`], self[el], Date.now())
        self[el] = null
      }
    }
  })
}

原曝光方案如何使用

滚动分3种情况:

1、列表在子组件,父组件滚动:这种情况需要在父组件添加 onPageScroll: debounces(() => {uni.$emit('on-page-scroll')}, 800), 子组件列表页混入mixin,reportData方法生效

2、列表就在当前页面,当前页面滚动:这种情况只需要混入mixin,onPageScroll: debounces(function () {const that = this;that.collectData()}, 800)方法生效

3、不用系统滚动,用的是scroll-view的滚动, handleScroll: debounces(function () {const that = this;that.collectData()}, 800)

说实话,太麻烦了,我接手这代码时头都大了,所以仅以第3种情况为例介绍如何使用,总体需要4步:(伪代码,仅截取使用步骤部分)

1、第一步:加 handleScroll 方法

2、第二步:需要监听的元素加上 class="expose_${id}" 用于获取元素进行位置监听

3、第三步:引入 mixins

4、第四步:监听组件 list 进行转换设置 mixins 里的 list,以使后续 list 遍历监听元素位置生效

这第4步是不是亮了,是不是挺绕口的,我也是捋了好久才把这之前代码想干啥给捋清楚

伪代码如下:

<template>
<scroll-view @scroll="handleScroll">//1、第一步:加handleScroll方法
  <view class="list">
    <view v-for="(group, groupIndex) in groupList" :key="groupIndex">
    //2、第二步:加class用于获取元素进行位置监听
    <view v-for="(item, itemIndex) in group.list" :key="itemIndex" :class="[`expose_${item.teachData.resourceId}`]">
      <teach-card-item :getResourceIds="getResourceIds" :data="item.teachData" :pageId="ctPageId" :index="itemIndex" />
    </view>
  </view>
</scroll-view>
</template>
<script>
  //3、第三步:引入mixins
  import exposeMixin from '@/mixin/exposeMixin'
  export default {
    mixins: [exposeMixin],
    data() {
      return {
        groupList: []
      }
    },
    watch: {
      //4、第四步:监听组件list进行转换设置mixins里的list,以使后续list遍历监听元素位置生效
      groupList(val) {
        if (val.length) {
          this.listData = val.map((info) => (Array.isArray(info.list) ? info.list : [])).filter((info) => info.length)
          this.listData = this.listData.flat(this.listData.length).map((item) => item.teachData)
        }
      }
    },
    //......
  }
</script>

这第4步的 watch 里的 listData 看到没,在 mixins 里还有这个 listData 的 watch。也就是说是对 list 数据的双重 watch,再加各种遍历。接手维护这代码,我真的是栓Q啊

原曝光方案存在的问题

1、代码可读性差,难以理解,后期难以维护,难以扩展

(如正常情况是从上往下滑,但是聊天场景是从下往上滑,那就需要更改统一的元素位置监听的判断逻辑,易对全局产生问题)

2、性能问题:

(1)使用方式基本都需要:

先监听组件的 list(用于设置 mixins 里的 list 以触发 mixins 里的 list 监听) -> 再监听 mixins 里的 list -> 再遍历进行元素事件监听

(2)同时存在大量的 scroll 事件监听(尽管做了防抖,也会存在大量无意义的事件触发)

(3)且有事件监听,无事件解绑,易产生内存泄漏问题

3、与组件实际业务耦合性太强,杂糅在一起,存在大量重复性代码

4、代码本身存在大量业务问题:

元素位置监听是判断在可视区域时,会在组件实例上记录一个开始时间。在切出可视区域时,会在组件实例上记录一个结束时间,然后收集 push 到 reportArr 里,srcoll 停止时上报。故存在很多上报时机和数据错误的业务问题,如:

(1)收集数据监听元素位置的回调是异步、但上报是同步,故上报数据存在错位(当次上报的是上次需要上报的内容)。所以按常规操作,如果一直缓慢滚动,最后切出,那就没有数据上报

(2)页面不滚动或小滚动就不会触发。

如屏幕上有4条数据,不产生滚动条,没法滚动,或者产生的滚动区间不足以让一条数据完全隐匿,那这4条数据切出时都不会上报

(3)如屏幕上有6条数据,往上滚动1条,停顿1s,不上报。再滚动1条,停顿1s,会上报第一条数据。而剩下的可视区域内的4条数据在切出时都不会上报

(4)从上往下滚动时,有数据上报;当从下往上再次查看,有元素重新进入再切出时,不会上报

重构曝光方案

技术方案背景

1、浏览器本身有提供API:IntersectionObserver API 可以自动"观察"元素是否可见,并可在目标元素与视口产生一个交叉区(可配置交叉区范围)

详细文档见MDN:developer.mozilla.org/zh-CN/docs/…

注意 options 的三个参数,其中下面这个可以设定是否 100% 曝光

threshold:可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 IntersectionObserver 注册的回调函数将会被执行。

如果你只是想要探测当 target 元素的在 root 元素中的可见性超过 50% 的时候,你可以指定该属性值为 0.5。

如果你想要 target 元素在 root 元素的可见程度每多 25% 就执行一次回调,那么你可以指定一个数组 [0, 0.25, 0.5, 0.75, 1]

默认值是 0 (意味着只要有一个 target 像素出现在 root 元素中,回调函数将会被执行)。

该值为 1.0 含义是当 target 完全出现在 root 元素中时候 回调才会被执行。

2、若不支持该 API 的话,W3C 提供了一个 polyfill,当浏览器不支持时使用常规解决方案替代

3、微信小程序基础库 1.9.3 开始支持实现了该 API,低版本需做兼容处理。

详细API如何使用,见小程序文档地址:developers.weixin.qq.com/miniprogram…

重构方案全部代码

由于 mixins 本质上就是一个函数,我为了做到配置灵活性,故采用函数方式

/**
 * 曝光埋点方案重构:需上报的组件根元素上加上类 expose-point
 * @param {*} config:module_name模块英文名称、page_id属性等由产品定义
 */
export default function (config = {}) {
  let { module_name = '', page_id = '' } = config
  return {
    mounted() {
      this.observe()
    },
    beforeDestroy() {
      this.observeCb() // 组件销毁前,上报屏幕可见区域内卡片内容
      this.pointObserver.disconnect() // 注销监听,防止内存泄漏
    this.pointObserver = null
      this.exposeStartTime = null
    },
    methods: {
      // 元素可见性监听
      observe() {
        this.pointObserver = this.createIntersectionObserver({ observeAll: true })
        this.pointObserver.relativeToViewport({ bottom: -100, top: 0 }).observe('.expose-point', this.observeCb)
      },
      observeCb() {
        if (this.exposeStartTime) {
          // 曝光小于1s不上报,清除计时
          if (Date.now() - this.exposeStartTime < 1000) {
            this.exposeStartTime = null
            return
          }
          // 发送上报
          const option = this.getOption()
          uni.$sendTracker(Object.assign({
            site_id: this.index || '',
            // other options
            group_id: this.groupId || ''
      }, option))
          this.exposeStartTime = null // 上报之后清空组件计时
        } else {
          this.exposeStartTime = Date.now() // 记录曝光开始时间
        }
      },
      getOption() {
        // 预制属性列表准备:用户相关
        const defaultOptions = {
          // any options
        }
        // 自定义属性列表准备
        const custumOptions = {
          // any options
        }
        return Object.assign(defaultOptions, custumOptions)
      }
    }
  }
}

核心逻辑是:

1、通过 IntersectionObserverAPI 进行元素可见性监听

  切入切出均会触发回调,即2次回调,1次切入1次切出,故可在元素切入时,在实例上记录一个开始时间。

  切出时,判断是否有开始时间(有即是切出),判断时间间隔是否 > 1s:> 1s 则上报, < 1s 则清空组件时间

2、组件销毁前,需做2个操作:(1)上报可见区域内内容(2)注销监听,防止内存泄漏

3、后期有额外场景,可在 config 参数里进行相关扩展(比如加参数是否开启监听等,那么就可以加一个参数 enableListen,如开启才监听,不开启就不监听等扩展工作)

4、如果需要进行判断元素展示百分之多少的话,那就可以加上参数:threshold 进行判断

重构方案如何使用

1、第一步:在上报的组件根元素上加上类 expose-point

2、第二步:引入 mixins 即可(可设置 module_name、page_id)

<template>
  // 1、第一步:需上报的组件根元素上加上类 expose-point
  <view class="teach-box expose-point" @click="goTeachDetail">
    <view class="title">{{ title }}</view>
    <image :src="teachPoster" mode="aspectFill" class="image" />
  </view>
</template>
<script>
  // 2、第二步:引入 mixins
  import exposePointMixin from '@/mixin/exposePointMixin'
  const _exposePointMixin = new exposePointMixin({
    module_name: 'ContentCard'
  })
  export default {
    mixins: [_exposePointMixin],
  }
</script>

这样设计处理之后,代码简单、易用、易理解、易扩展,且原曝光方案存在的业务问题均可很好的得到解决。

PS:额外说一下,因为项目技术栈使用的 vue2,故采用的 mixins。如果是 vue3 的话,那么可以封装 hooks 去做。

且最初想的是封装一个自定义指令,那么需要埋点的地方就加指令即可。但是因为很多需要的字段及设计之前并不规范化,所以权衡考虑灵活性和扩展性之后,mixins 为最合适的选择。