浅析前端曝光埋点方案重构
在新公司接手之前的人做的前端埋点曝光,业务代码真是一言难尽,故而优化重构了一下。下面做下对比:
一、原曝光方案介绍
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>
这样原曝光方案存在的问题均可解决