拥有设计理念的H5埋点方案实践(下篇)

494 阅读13分钟

书接上回《拥有设计理念的H5埋点方案实践(上篇)》

在上文中,我们已经实现了“埋点”实例——SpmStat的创建,具备了最基本的指令式上报的能力。

本文基于上文的解决方案,主要讲解如何将埋点设计为指令,以及详细谈到了我的设计思想。并讲解了埋点指令在项目中的使用方式。

本篇文章是下篇,没看过的朋友们可先见拥有设计理念的H5埋点方案实践(上篇)

一. 用指令去承载埋点

在前文中,我们实现了各种埋点的上报行为,在元素的埋点事件触发时,只需要调用spm.<埋点类型>(<参数>)即可。但这样还不够,当埋点触发行为很多,参数个性化程度高时,如果我们手动的为每一个元素编写js事件,再去整理个性化参数,实在折磨人。

除了手动为每一个元素编写JS事件的麻烦外,还有三个弊端:

  1. 哪些元素绑定了埋点不清晰: 开发者需要从各种元素的DOM事件中寻找哪些元素绑定了埋点。
  2. 分解开发者对业务逻辑的注意力: 开发者往往关注的是点击行为背后的业务逻辑,而不是和业务无关的数据上报逻辑。这种埋点的数据上报逻辑和业务逻辑混杂的模式,使开发者不得不去阅读和业务无关的代码。
  3. 造成大量代码冗余: 点击上报的行为统一性很高,差异点很可能只是参数的整理,其他部分的逻辑很相似。

正因为这些不利问题的存在,我才选择了将埋点的触发逻辑借用vue指令来承载,这样做有几个好处:

  1. 简单方便: 只需在元素上填上埋点指令 + 带入参数,即可。如同使用v-click一样简单。
  2. 容易发现哪些元素绑定了哪些埋点: 埋点都在vue template(或说View层)中,开发者可以清晰方便的看到到底有哪些元素被绑定了埋点。
  3. 业务逻辑和埋点触发逻辑最大化分割:有了指令式的埋点,大多数情况下你不再需要在script中去写埋点的上报和参数整理逻辑。譬如元素的点击埋点上报,你不需要在click事件的业务逻辑中混入埋点相关逻辑。开发者看到的click事件只会是业务逻辑。
  4. 没有冗余问题: 上报行为的统一逻辑都在指令中承载,处处复用。

二. 如何设计指令式的埋点?

我习惯将埋点触发的相关逻辑封装成一个“埋点触发类”,然后实例化得到“埋点触发对象”

然后通过在指令中调用埋点触发对象暴露的api,这样做有两个好处, 以点击埋点为例:

  1. 分离出“埋点触发类”,即使脱离了指令,我们依旧可以在业务逻辑中,用一行代码解决点击埋点的维护和上报全功能。 比如:你希望在 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)这样一行代码就能搞定。

  1. 指令的mounted和unmounted + “埋点触发对象”的组合使得逻辑自然被分割为 “埋点启动/移除时机” + “埋点触发/注销逻辑” 这两个部分。凡是和“埋点启动时机”相关的,都放到指令这一层,和“埋点触发/注销行为”相关的,都放在“埋点触发类”这一层。

  2. 分离出“埋点触发类”,相当于将埋点触发逻辑和数据上报逻辑分开,埋点库进而可以实例化出不同数据平台的专用vue上报指令!做到一套埋点库对接同个项目中的多个数据上报平台!

三. 点击埋点指令实现(v-click-track)

“点击埋点触发类”重点是以下几个逻辑:

  1. 原生click事件的绑定与回调
  2. 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节点出现在用户可见视野内。 需要注意的是,节点如果被其他节点遮挡,那么即使这节点在视口内,也不能算是曝光。

“曝光埋点触发类”重点是以下几个逻辑:

  1. 判断元素出现在可见视野内, 核心api可见IntersectionObserver - Web API | MDN
  2. 在组件销毁前,组件中的曝光节点只能上报一次,不能重复上报
  3. 曝光埋点需要定期合并上报,防止上报行为过于频繁(这一点我已经在上篇 spm.exporsure() 的逻辑中实现)。
  4. 对于需要加载的元素,如:图片,需要等待他们加载完成后才进行上报。

看看“曝光埋点触发类”的实现,伴随注释进行解释:

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)

什么是浏览埋点?所谓浏览埋点是指一个页面(或者页面的某一部分)自进入开始收集信息,直到销毁时上报。

在我的需求中,收集的信息是浏览时间。

“曝光埋点触发类”重点是以下几个逻辑:

  1. 组件挂载时开始计时,组件销毁时终止计时并上报。
  2. 通过WeakMap维护组件浏览时间,组件DOM删除后,自动垃圾清除计时,防止内存泄露.
  3. 页面浏览时间小于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埋点方案实践(上篇)