书接上回《拥有设计理念的H5埋点方案实践(上篇)》
在上文中,我们已经实现了“埋点”实例——SpmStat的创建,具备了最基本的指令式上报的能力。
本文基于上文的解决方案,主要讲解如何将埋点设计为指令,以及详细谈到了我的设计思想。并讲解了埋点指令在项目中的使用方式。
本篇文章是下篇,没看过的朋友们可先见拥有设计理念的H5埋点方案实践(上篇)
一. 用指令去承载埋点
在前文中,我们实现了各种埋点的上报行为,在元素的埋点事件触发时,只需要调用spm.<埋点类型>(<参数>)即可。但这样还不够,当埋点触发行为很多,参数个性化程度高时,如果我们手动的为每一个元素编写js事件,再去整理个性化参数,实在折磨人。
除了手动为每一个元素编写JS事件的麻烦外,还有三个弊端:
- 哪些元素绑定了埋点不清晰: 开发者需要从各种元素的DOM事件中寻找哪些元素绑定了埋点。
- 分解开发者对业务逻辑的注意力: 开发者往往关注的是点击行为背后的业务逻辑,而不是和业务无关的数据上报逻辑。这种埋点的数据上报逻辑和业务逻辑混杂的模式,使开发者不得不去阅读和业务无关的代码。
- 造成大量代码冗余: 点击上报的行为统一性很高,差异点很可能只是参数的整理,其他部分的逻辑很相似。
正因为这些不利问题的存在,我才选择了将埋点的触发逻辑借用vue指令来承载,这样做有几个好处:
- 简单方便: 只需在元素上填上埋点指令 + 带入参数,即可。如同使用
v-click一样简单。 - 容易发现哪些元素绑定了哪些埋点: 埋点都在vue template(或说View层)中,开发者可以清晰方便的看到到底有哪些元素被绑定了埋点。
- 业务逻辑和埋点触发逻辑最大化分割:有了指令式的埋点,大多数情况下你不再需要在script中去写埋点的上报和参数整理逻辑。譬如元素的点击埋点上报,你不需要在click事件的业务逻辑中混入埋点相关逻辑。开发者看到的click事件只会是业务逻辑。
- 没有冗余问题: 上报行为的统一逻辑都在指令中承载,处处复用。
二. 如何设计指令式的埋点?
我习惯将埋点触发的相关逻辑封装成一个“埋点触发类”,然后实例化得到“埋点触发对象” 。
然后通过在指令中调用埋点触发对象暴露的api,这样做有两个好处, 以点击埋点为例:
- 分离出“埋点触发类”,即使脱离了指令,我们依旧可以在业务逻辑中,用一行代码解决点击埋点的维护和上报全功能。 比如:你希望在
flag === true时,点击DOM A才上报点击埋点。你可以:
if (flag) {
// 一行
clickTrack.add(A, params)
}
而不是:
import spm from './index'
if (flag) {
// 三行
A.addEventListener('click', () => {
spm.touch(params)
})
}
上面点击埋点的例子很简单,如果是曝光埋点或者是浏览埋点,那么完全不是简简单单的addEventListener就够的。若将埋点触发的相关逻辑封装成一个“埋点触发类”,那么在业务逻辑中,永远都只需要 clickTrack.add(A, params)这样一行代码就能搞定。
-
指令的mounted和unmounted + “埋点触发对象”的组合使得逻辑自然被分割为 “埋点启动/移除时机” + “埋点触发/注销逻辑” 这两个部分。凡是和“埋点启动时机”相关的,都放到指令这一层,和“埋点触发/注销行为”相关的,都放在“埋点触发类”这一层。
-
分离出“埋点触发类”,相当于将埋点触发逻辑和数据上报逻辑分开,埋点库进而可以实例化出不同数据平台的专用vue上报指令!做到一套埋点库对接同个项目中的多个数据上报平台!
三. 点击埋点指令实现(v-click-track)
“点击埋点触发类”重点是以下几个逻辑:
- 原生click事件的绑定与回调
- DOM移除时,对click事件监听器的移除,防止内存泄露。
看看“点击埋点触发类”的实现,伴随注释进行解释:
import spm from "./index";
import { baseParams } from "./config"; // 你的一些基本参数,结合自己的需求设计
export default class ClickTrack {
trackNodes: HTMLElement[];
private _spm: SpmStat
constructor(spm: SpmStat) {
// 需要上报的埋点DOM集合,方便在控制台打印有哪些dom存在点击埋点
// 如果你不需要,也可以不维护trackNodes
this.trackNodes = []
// this_spm 表示当前clickTrack的点击上报行为是基于哪个spm基类实例的。
// 如果有不同的上报方向、参数整理方式,可以在spmStat基类上通过参数传递,
// 然后实例化出不同的spm基类
this._spm = spm
}
// 为DOM节点添加埋点的api,暴露的最重要接口
add(el: HTMLElement | null, value: BindingArgument['value']) {
if (el !== null) {
const trackNodeConfig = {
el,
callback: (event: MouseEvent) => this.callback(value)
}
this.trackNodes.push(trackNodeConfig)
el.addEventListener('click', trackNodeConfig.callback)
}
}
callback(value: BindingArgument['value']): void {
let { params } = value
const { b, c, d, scm, spm: _spm } = value
params = Object.assign(baseParams, {
scm, //根据产品侧要求,产品侧要求就传,没要求就不需要传
spm: _spm ? _spm : { b, c, d }, // value中spm可以是对象形式,也可以是拼接好的字符串,此时就忽略b,c,d位
params
})
// 请求上报
this._spm.touch(params)
console.log('触发点击埋点', params)
}
最后包装成Vue指令:
// 引入spm基
import SpmStat, { SpmConfig } from "./SpmStat";
// 引入点击埋点触发类实例
import ClickTrack from "./ClickTrack";
const config: SpmConfig = {
env: isDev()
? 'staging'
: isPre()
? 'preview'
: isPro()
? 'release'
: 'staging',
spmA: 'MiShow_NT',
}
// 创建vue指令的方法
function createClickDirective (clickTrack: ClickTrack) {
return {
mounted(el: HTMLElement, { value }: ClickBinding) {
console.log('v-click-track mounted')
clickTrack.add(el, value)
},
unmounted(el: HTMLElement) {
console.log('v-click-track unmounted')
clickTrack.remove(el)
},
}
}
// 使用上初始化分3步骤 ↓:
// 1. 创建spm基类,确定上报方向,公共参数..
const spm = new SpmStat(config)
// 2. 基于spm基类实例创建点击埋点上报实例
const clickTrack = new ClickTrack(spm)
// 3. 基于点击埋点上报实例创建点击埋点上报实例
createClickDirectives(clickTrack)
之后点击埋点,可以在项目中如下使用 v-click-track:
< img
v-if = "+content.type == 1"
:src = "content.url"
v-click-track = "{
b: spmParams.b,
c: `swiper${index + 1}`,
d: '1',
scm: detailPage ? undefined : `server.0.0.0.page.screen_home_${model}.0.0`,
params: {
img_id: content.id
}
}"
/>
首先,你需要根据创建的spm基创建埋点触发类实例,你可以让所有点击埋点上报的公共参数都来自这个spm实例。如果你有很多套不同上报方向和上报参数的点击埋点。你可以为不同的上报方向实例化多个spm。然后基于这些spm去实例化不同的vue点击埋点指令。
这样将埋点触发逻辑与数据整理做出分割的好处是,可以让埋点库实例化出很多不同数据平台的专用vue上报指令!一套埋点库对接同个项目中的多个数据上报平台!
四. 曝光埋点实现(v-exporsure-track)
何为曝光?曝光是指DOM节点出现在用户可见视野内。 需要注意的是,节点如果被其他节点遮挡,那么即使这节点在视口内,也不能算是曝光。
“曝光埋点触发类”重点是以下几个逻辑:
- 判断元素出现在可见视野内, 核心api可见IntersectionObserver - Web API | MDN
- 在组件销毁前,组件中的曝光节点只能上报一次,不能重复上报。
- 曝光埋点需要定期合并上报,防止上报行为过于频繁(这一点我已经在上篇 spm.exporsure() 的逻辑中实现)。
- 对于需要加载的元素,如:图片,需要等待他们加载完成后才进行上报。
看看“曝光埋点触发类”的实现,伴随注释进行解释:
export type AddElArgu = HTMLElement & { _cb?: any }
// 有一些节点需要再加载完成之后延迟上报,比如img标签,需要再onload之后上报,否则图片加载未完成之前会造成意外的曝光埋点触发。
// 你可以将这一系列DOM标签维护在loadedReportNodes中
export const loadedReportNodes = ['img']
function startObserve(el: AddElArgu | null, params: any) {
// 之所以将回调函数挂在el._cb上,是因为这样能在IntercetionObserver回调中方便拿到节点上的埋点参数
el._cb = callback.bind(this, el, deepClone(params))
// 当IntercetionObserver准备就绪后,开启对el的监听
this._observer && this._observer.observe(el)
}
export default class ExposureTrack {
private _observer: any;
private _spm: SpmStat
constructor(spm) {
this._observer = null; // 使用的IntersectionObserver实例,下划线表示Exposure 属性
this._spm = spm // 道理同点击埋点
this.init() // 初始化埋点曝光监听逻辑
}
init() {
// 具体可参阅IntersectionObserver Api的相关文档。
// 你可以根据自己的需求进一步定义“曝光”的触发条件。
const observerOption = {
root: null, // null表示将根节点设置为整个浏览器窗口
rootMargin: "0px", // 曝光埋点到视口临近0px的时候触发曝光埋点
threshold: 0 // 只要触碰到临界值就出发,可以控制触发曝光的比例
}
// 实例化IntersectionObserver Api
this._observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 当元素出现的时候,才进行下面回调操作
const { target } = entry;
// 执行元素上植入的_cb回调
(target as AddElArgu)._cb && (target as AddElArgu)._cb()
// 节点曝光一经上报,不再重复上报
this._observer.unobserve(target)
}
})
}, observerOption)
}
// 对DOM节点进行曝光监听
add(el: AddElArgu | null, params: any) {
if (el === null) return true
if (el && loadedReportNodes.includes(el.tagName.toLowerCase())) {
// 可以将需要onload后加载的元素维护在一个数组中,在我的需求中,只有img需要,你可以按照你的需求增改
el.addEventListener('load', (p) => {
// 在这些元素load后,添加曝光监听
startObserve.call(this, el, params, this.callback)
})
} else {
// 添加曝光监听
startObserve.call(this, el, params, this.callback)
}
}
// 当触发曝光时的回调
callback(el: HTMLElement, value: BindingArgument['value']) {
let { params = {} } = value!
const { b = '', c = '', d = '', scm = '', spm: _spm = '' } = value!
params = Object.assign(baseParams, {
scm, //根据产品侧要求,产品侧要求就传,没要求就不需要传
spm: _spm ? _spm : { b, c, d },
params
})
this._spm.visible(params)
console.log('触发曝光埋点', JSON.stringify(params))
}
// 删除节点的曝光监听,防止内存泄露
remove(el: HTMLElement | null) {
if (el !== null) {
this._observer && this._observer.unobserve(el)
}
}
}
最后包装成Vue指令:
import spm from '@/assets/tracks/index'
import ClickTrack from '@/assets/tracks/ClickTrack'
import createClickDirectives from '@/assets/tracks/create-click-directive'
export default function createExposureDirectives(exposureTrack) {
return {
mounted(el: HTMLElement, { value, instance } : ExposureBinding) {
console.log('v-exporsure-track mounted')
exposureTrack.add(el, value)
},
unmounted(el: HTMLElement) {
console.log('v-exporsure-track unmounted')
exposureTrack.remove(el)
},
}
}
// 类似前文描述过的v-click-track的创建过程,经历同样的三步
// 1. 创建spm基类,确定上报方向,公共参数..
const spm = new SpmStat(config)
// 2. 基于spm基类实例创建曝光埋点上报实例
const exposureTrack = new ExposureTrack(spm)
// 3. 基于点击埋点上报实例创建点击埋点上报实例
createExposureDirectives(clickTrack)
在项目中使用:
<img
v-if="+content.type == 1" class="image-item" :src="content.url"
v-exposure-track="{
b: spmParams.b,
c: `swiper${index + 1}`,
d: '1',
scm: detailPage ? undefined : `server.0.0.0.page.screen_home_${model}.0.0`,
params: {
img_id: content.id
}
}"
/>
四. 点击曝光埋点实现(v-click-exporsure-track)
你也可以结合自己的需求,将两种埋点指令合并成一个,比如,在我的需求中,曝光埋点和点击埋点时常同时存在。为了方便,我会额外编写一个“点击曝光埋点指令”。实现上只是将两种指令合并一起,不过多赘述。
import spm from '@/assets/tracks/index'
import ClickTrack from '@/assets/tracks/ClickTrack'
import createClickDirectives from '@/assets/tracks/create-click-directive'
interface ClickExposureBinding extends BindingArgument {
value?: TrackValue | null
}
export function createClickExposureDirctives(clickTrack, exposureTrack) {
return {
mounted(el: HTMLElement, { value }: ClickExposureBinding) {
if (!value) return
console.log('v-exporsure-track mounted')
exposureTrack.add(el, value)
console.log('v-click-track mounted')
clickTrack.add(el, value)
},
unmounted(el: HTMLElement) {
console.log('v-click-track unmounted')
clickTrack.remove(el)
console.log('v-exporsure-track unmounted')
exposureTrack.remove(el)
},
}
}
// 1. 创建spm基类,确定上报方向,公共参数..
const spm = new SpmStat(config)
// 2. 基于spm基类实例创建埋点上报实例
const clickTrack = new ClickTrack(spm)
const exposureTrack = new ExposureTrack(spm)
// 3. 基于点击埋点上报实例创建点击埋点上报实例
createClickExposureDirctives(clickTrack, exposureTrack)
五. 浏览埋点实现(v-view-track)
什么是浏览埋点?所谓浏览埋点是指一个页面(或者页面的某一部分)自进入开始收集信息,直到销毁时上报。
在我的需求中,收集的信息是浏览时间。
“曝光埋点触发类”重点是以下几个逻辑:
- 组件挂载时开始计时,组件销毁时终止计时并上报。
- 通过WeakMap维护组件浏览时间,组件DOM删除后,自动垃圾清除计时,防止内存泄露.
- 页面浏览时间小于300ms,不上报。
因为一个页面中,可以存在多个浏览埋点。我们需要维护一个Map结构对DOM节点和DOM挂载时间进行统计。为了方便的维护这个Map结构,我构造了EnterTimeMap类。
class EnterTimeMap {
private _value: WeakMap<HTMLElement, number>
constructor() {
this._value = new WeakMap([]) // 使用WeakMap,可以在DOM被卸载掉后,自动垃圾清除掉记录的时间,防止内存泄露
}
get(el: HTMLElement) {
if (!el) return 0
return this._value.get(el) || 0
}
set(el: HTMLElement) {
if (!el) return
this._value.set(el, Date.now()) // 存入对应的K-V分别是 DOM节点-DOM挂载时间戳
}
delete(el: HTMLElement) {
if (!el) return
return this._value.delete(el)
}
}
之后“浏览埋点触发类”的实现,伴随注释进行解释:
export default class ViewTrack {
private _enterMap: EnterTimeMap;
private _spm = spm
constructor() {
this._enterMap = new EnterTimeMap();
this._spm = spm
}
// 将当前需要添加view埋点的DOM节点维护到WeakMap中。
add(el: HTMLElement) {
if (el === null) return
this._enterMap.set(el)
}
// 移除DOM节点的的view埋点,上报view埋点信息
remove(el: HTMLElement | null, value: BindingArgument['value']) {
if (el === null) return
const enterTime = this._enterMap.get(el) // 从WeakMap中拿到挂载起始时间
const duration = Date.now() - enterTime // 计算当前节点的存在时间
if (duration < 300) return // 如果存在时间<300毫秒,不上报
let { params = {} } = value!
const { b = '', c = '', d = '', scm = '', uri } = value!
// 整理参数,可以按照你的需求整理
params = Object.assign(baseParams, {
scm,
spm: { b, c, d },
params: Object.assign(params, {
duration
}),
uri
})
// 调用了浏览埋点的上报方法,可见SpmStat类的实现
this._spm.view(params)
console.log('上报view埋点', params)
// 之后别忘记从WeakMap中及时删除节点的挂载起始时间
return this._enterMap.delete(el)
}
}
最后包装成vue指令:
// 引入spm基
import SpmStat, { SpmConfig } from "./SpmStat";
// 引入点击埋点触发类实例
import ViewTrack from "./ViewTrack";
interface ViewBinding extends BindingArgument {
value?: TrackValue | null
}
// 创建vue指令的方法
export function createViewDirective(viewTrack) {
return {
mounted(el: HTMLElement, { value }: ViewBinding) {
if (!value) return // 如果不存在value则视为不需要上报
// 触发view逻辑
console.log('++++++++++', window.location.href, '当前页面已经进入view埋点监听')
viewTrack.add(el)
},
unmounted(el: HTMLElement, { value }: ViewBinding) {
if (!value) return
viewTrack.remove(el, value)
},
}
}
// 使用上初始化分3步骤 ↓:
// 1. 创建spm基类,确定上报方向,公共参数..
const spm = new SpmStat(config)
// 2. 基于spm基类实例创建点击埋点上报实例
const viewTrack = new ViewTrack(spm)
// 3. 基于点击埋点上报实例创建点击埋点上报实例
createViewDirective(clickTrack)
在项目中使用:
<template>
<div
class="screen-saver-wrapper"
v-view-track="{
b: spmParams.b,
c: 0,
d: 0,
}"
>
...
</div>
</template>
五.全局安装指令
为了便于使用埋点,埋点指令推荐全局注册,这样就不需要在每一个.vue文件中引入了。
全局注册埋点指令的方式很简单,可以通过编写vue插件的方式,通过app.use()一次性安装。
directivesPlugin.ts
import vClickTrack from '@/directives/tracks/v-click-track'
import vClickExposureTrack from '@/directives/tracks/v-click-exposure-track'
import vExposureTrack from '@/directives/tracks/v-exposure-track'
import vViewTrack from '@/directives/tracks/v-view-track'
function installTrackDirectives(app: App) {
app.directive('click-track', vClickTrack)
app.directive('click-exposure-track', vClickExposureTrack)
app.directive('exposure-track', vExposureTrack)
app.directive('view-track', vViewTrack)
}
// 安装全局指令插件
export default function directives(app: App) {
installTrackDirectives(app)
}
main.ts
import directives from './directivesPlugin'
function initApp() {
createApp(App)
.use(directives) // app.use()将全部指令进行全局注册
.mount('#app');
}
(function init() {
// ...
initApp()
})()
之后就可以在自己的项目中便捷愉快的使用各种埋点了。感谢大家支持我的H5埋点方案~! 再次附上本篇的上篇 拥有设计理念的H5埋点方案实践(上篇)