浅析前端曝光埋点方案重构
作者:互联网
最近新入职了一家公司,接手之前的人做的前端埋点曝光,业务代码真是一言难尽,故而优化重构了一下。下面做下对比:
一、原曝光方案介绍
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