ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

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

2022-09-04 17:01:44  阅读:165  来源: 互联网

标签:重构 const 元素 浅析 组件 id 上报 埋点 监听


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

一、原曝光方案介绍

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

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

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>

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

标签:重构,const,元素,浅析,组件,id,上报,埋点,监听
来源: https://www.cnblogs.com/goloving/p/16594645.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有