浅析前端曝光埋点方案重构

  在新公司接手之前的人做的前端埋点曝光,业务代码真是一言难尽,故而优化重构了一下。下面做下对比:

一、原曝光方案介绍

1、原曝光方案核心代码

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) {
            // 曝光从2s改成1s
            const { patientId, mobile, phone } = this.$store.state.user.userInfo || {}
            const { platform = '' } = uni.$getAuthInfo()
            // 超过2s才记录

            let option = {
              // 预制属性列表准备:用户相关
              module_name: resourceId ? 'ContentCard' : 'DoctorCard',
              time: uni.launchTime || '', // 初始化小程序时的时间戳
              time_stamp: Date.now(), // 埋点触发时间戳数据上报
              user_type: patientId ? 4 : 2, // 用户类型:2-医生4-患者
              user_id: patientId || '',
              phone: mobile || phone || '',
              platform,
              // 自定义属性列表
              page_id: this.ctPageId,
              page_url: this.$Route?.path,
              site_id: index + 1, // 位置
              start_time,
              end_time,
              duration,
              doctor_id: doctorId || '', // DoctorCard 时为 doctor_id
              resource_id: resourceId || '' // ContentCard 时为 resource_id
            }
            // ContentCard 时增加额外自定义选项
            if (resourceId) {
              Object.assign(option, {
                channel_type: 'mini_app', // 3 小程序
                channel_id: this.ctPageId
              })
            }
            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) {
        /* const res = new Map()
        this.arr = reportArr.filter((item) => !res.has(item.resource_id ? item.resource_id : item.doctor_id) && res.set(item.resource_id ? item.resource_id : item.doctor_id, 1)) */
        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
      }
    }
  })
}

2、原曝光方案如何使用

滚动分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 遍历监听元素位置生效

<template>
<scroll-view @scroll="handleScroll">//第一步:加handleScroll方法
  <view class="list">
    <view v-for="(group, groupIndex) in groupList" :key="groupIndex">    //第二步:加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>  //第三步:引入mixins
  import exposeMixin from '@/mixin/exposeMixin'
  export default {
    mixins: [exposeMixin],
    data() {
      return {
        groupList: []
      }
    },
    watch: {      //第四步:监听组件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>

3、原曝光方案存在的问题

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、技术方案背景

1、浏览器本身有提供API:IntersectionObserver API

  可以自动"观察"元素是否可见,并可在目标元素与视口产生一个交叉区(可配置交叉区范围)

  MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API

  注意 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,低版本需做兼容处理

  文档地址:https://developers.weixin.qq.com/miniprogram/dev/api/wxml/wx.createIntersectionObserver.html

2、重构方案全部代码

/**
 * 曝光埋点方案重构:需上报的组件根元素上加上类 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 || '', resource_id: this.data?.resourceId || '', doctor_id: this.data?.doctorId || '', group_id: this.groupId || ''       }, option)) this.exposeStartTime = null // 上报之后清空组件计时 } else { this.exposeStartTime = Date.now() // 记录曝光开始时间 } }, getOption() { // 预制属性列表准备:用户相关 const { doctorId, patientId, mobile = '', phone = '' } = this.$store.state.user?.userInfo const { platform = '' } = uni.$getAuthInfo() const defaultOptions = { module_name: module_name, // 模块英文名 time: uni.launchTime || '', // 初始化小程序时的时间戳 time_stamp: Date.now(), // 埋点触发时间戳数据上报 user_type: patientId ? 4 : 2, // 用户类型:2-医生4-患者 user_id: doctorId || patientId || '', phone: mobile || phone || '', platform } // 自定义属性列表准备 const end_time = Date.now() const pages = getCurrentPages() const custumOptions = { channel_type: 'mini_app', channel_id: page_id || this.pageId || '', page_id: page_id || this.pageId || '', page_url: pages?.[pages.length - 1]?.route || '', start_time: this.exposeStartTime, end_time, duration: end_time - this.exposeStartTime } return Object.assign(defaultOptions, custumOptions) } } } }

  核心逻辑是:

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

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

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

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

3、后期有额外场景,可在 config 参数里进行相关扩展

3、重构方案如何使用

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

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

<template>  //第一步:需上报的组件根元素上加上类 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>
  // 第二步:引入 mixins
  import exposePointMixin from '@/mixin/exposePointMixin'
  const _exposePointMixin = new exposePointMixin({
    module_name: 'ContentCard'
  })
  export default {
    mixins: [_exposePointMixin],
  }
</script>

  这样原曝光方案存在的问题均可解决

posted @ 2023-02-12 20:28  古兰精  阅读(768)  评论(0编辑  收藏  举报