基于指令和混合的前端通用埋点方案

基于指令和混合的前端通用埋点方案

mohomoho
作者:朱翀宇
原创文章,转载请注明出处。

摘要

本文介绍了一种通用的前端埋点方案的设计和实现,具有适配项目广泛,易于使用,与业务逻辑解耦等优点,已经在外卖商业平台进行了一段时间的试用,并取得良好效果。

背景

销售CRM方向是外卖为销售人员提供各维度的工具和平台,以帮助提高销售人员工作的效率。在销售CRM方向的PC端,一直没有对用户行为的数据采集(即埋点数据采集),所以对于分析用户行为、观察产品使用状况、制定产品策略等都缺乏相关的数据支持。

所以在今年3月份销售CRM方向决定启动PC端的各方向的埋点,包括智子、任务制、HES、商机等多个系统。PM整理的埋点个数达到了100多个。

在埋点的后端方案采用DA的SAK。而在前端方向,这几个系统有使用jquery+widget的老方案,也有基于Vue的技术栈实现。需要如何埋点?怎样实现简单高效的埋点?这是需要我们解决的问题。

埋点方案的确定

业界的埋点方案主要分为以下三类:

  • 代码埋点:在需要埋点的节点调用接口,携带数据上传。如百度统计等;
  • 可视化埋点:使用可视化工具进行配置化的埋点,即所谓的「无痕埋点」,前端在页面加载时,可以读取配置数据,自动调用接口进行埋点。如开源的Mixpanel;
  • 无埋点:前端自动采集全部事件并上报埋点数据。如国内的神策数据等;

在当时排期紧凑,人力紧缺的情况下,显然不允许我们去开发可视化埋点方案和无埋点方案,所以只能采取代码埋点方案。


代码埋点分为 命令式埋点 与 声明式埋点 。

命令式埋点

命令式埋点,顾名思义,开发者需要手动在需要埋点的节点处进行埋点。如点击按钮或链接后的回调函数、页面ready时进行请求的发送。大家肯定都很熟悉这样的代码:

// 页面加载时发送埋点请求
$(document).ready(function(){
   // ... 这里存在一些业务逻辑
   sendRequest(params);
});
// 按钮点击时发送埋点请求
$('button').click(function(){
   // ... 这里存在一些业务逻辑
   sendRequest(params);
});

可以很容易发现,这样的做法很有可能会将埋点代码侵入业务代码,这使整体业务代码变得繁琐,容易出错,且后续代码会愈加膨胀,难以维护。所以,我们需要让埋点的代码与具体的业务逻辑解耦,即 声明式埋点 ,从而提高埋点的效率和代码的可维护性。

声明式埋点

理论上,声明式埋点只需要关注两个问题:

  • 需要埋点的DOM节点;
  • 所需携带的数据

因此,可以很快想出一个声明式埋点的方法:

// key表示埋点的唯一标识;act表示埋点方式
<button data-stat="{key:'111', act: 'click'}">埋点</button>

那么可以去遍历DOM树,找到 [data-stat] 的节点,给这个button绑上click事件,把这些参数在回调函数中通过请求发出去。

在DOM节点(html)上声明埋点,与业务逻辑(通常在Javascript文件中)就解耦了。调用也很方便。

看起来很美,但这样就能解决问题了吗?显然是不够的。还需要解决以下问题:

  • 遍历DOM树的时机问题,一个简单的例子,一个表格的行数据是通过异步加载,而表格行中的操作按钮需要埋点,那么在DOM ready的时候去遍历,显然是无法找到的
  • 绑定埋点事件次数的问题,怎样保证埋点事件不会被重复绑定到元素上,一次操作发了N个埋点请求?
  • 如何处理特有的埋点行为,如页面展现埋点,区域展现埋点?
  • 如何在解绑时,销毁已绑定的事件?

通用的解决方案

回顾一下,我们需要解决的问题是:

  • 通过声明式埋点来解耦业务代码
  • 埋点方案需要兼容Vue应用和jquery应用(甚至所有应用)
  • 需要支持页面展现埋点、区域展现埋点、点击埋点等多种埋点方式
  • 极端情况下需要支持命令式埋点

我们最终提出了一个基于Vue指令(Directive)和混合(Mixin)的解决方案:

基于Vue指令的声明式埋点

由于在埋点的需求中有部分项目使用了Vue作为基础框架,结合上面声明式埋点的例子,很容易就联想到 Vue自定义指令。Vue自定义指令提供了一种机制,将数据的变化映射为 DOM 行为。以 Vue 1.x 版本为例,自定义指令提供了几个钩子函数:

  • bind:只调用一次,在指令第一次绑定到元素上时调用。
  • update: 在 bind 之后立即以初始值为参数第一次调用,之后每当绑定值变化时调用,参数为新值与旧值
  • unbind:只调用一次,在指令从元素上解绑时调用

这样的特性可以很好的解决以上的一些问题。我们只需要像这样:

Vue.directive('stat', {
  bind: function () {
    // 准备工作
  },
  update: function (newValue, oldValue) {
    // 值更新时的工作
    // 也会以初始值为参数调用一次, 此时可以根据传值类型来进行相应埋点行为的请求处理
  },
  unbind: function () {
    // 清理工作
  }
})

在一个Vue应用中,不需要再去遍历DOM树,因为在Vue应用中基本所有DOM操作都是使用数据的变更结合Vue的内置指令实现,Vue可以感知到这些变更。在指令从元素上解绑时我们也可以去销毁已经绑定的事件。

那么接下来的问题是,还有一些项目基于 jquery + widget 的老方案实现,那么在这些项目中的DOM操作是jquery甚至原生DOM API来实现,Vue的自定义指令就无法工作。举个例子:

<div id="container">
  <button id="btn">click</button>
</div>
<script>
  new Vue({
     el: '#container',
     directives: {stat}
  })
  $('#btn').click(function() {
      $('#container').append('<button v-stat="{key: '3', act: 'click'}">click</button>')
  })
</script>

在上面例子中,虽然Vue已经挂载到 container 容器上,引入了自定义指令stat, #btn 这个按钮点击时插入了一段带有指令v-stat的按钮,因为Vue无法感知这个DOM变更,所以该指令不能被解析。这样的方式就会失效。

之前在外卖运营平台方向有基于 jquery 的DOM劫持操作的实现,在所有DOM操作中加入埋点相关的逻辑;因为无法保证所有的DOM操作都使用 jquery , 且不能保证所有埋点逻辑完全一致,所以也无法通用。

那么,怎样保证在任意库,包括原生API的DOM操作下都感知到DOM的变更并且通知Vue重新解析指令呢?这里就需要引入 MutationObserver。

基于MutationObserver API的Mixin

MutationObserver是在DOM3标准中提出的标准API,提供让开发者感知到在某一个DOM节点变更的能力。可以监听以下场景:

  • childList: 目标节点的子节点插入删除引起的变更
  • attributes: 目标节点属性改变引起的变更
  • characterData: 目标节点的文本节点改变引起的变更,如通过appendData()等
  • subtree: 目标节点的子孙节点改变引起的变更
  • attributeOldValue:当attribute监听被设定为true时,可以记录改变前的属性值
  • characterDataOldValue:当characterData监听被设定为true时,可以记录改变前的属性值
  • attributeFilter:可以设定需要监听的属性列表

MutationObserver的浏览器支持情况已经比较好了.

但为了保证MutationObserver可以在所有浏览器上正常工作,我们仍然引入了这个API的polyfill,详情可见这里

在此能力的前提下,我们就可以在任意的DOM操作下触发Vue进行重新解析指令。

我们将 MutationObserver 封装进一个 Vue mixin , 非Vue应用的业务代码只需要引入这个mixin,这样也可以很好地解耦。

详细的实现原理可以见以下伪代码:

let observer;
export default {
  ready() {
    // 开启监听
    observer = new MutationObserver(mutations => {
      this.$compile(this.$el);
    });
    observer.observe(this.$el, config);
  },
  destroyed() {
    // 清理工作
    observer.disconnect();
    observer.takeRecords();
  }
}
关于MutationObserver的详细介绍请见标准文献

埋点行为的处理

埋点库另一部分主要的逻辑是处理埋点行为。

页面展现埋点(ready)

Ready事件的处理,在页面根元素绑定指令后,在指令第一次update钩子调用时即可认为该元素ready, 直接发起请求埋点即可;

点击统计埋点(click)

click事件的处理,在该节点上绑定click事件,在指令解绑时销毁该事件。

区域展现埋点(show)

区域展现埋点即:当区域为可见状态变更时进行埋点。

那么,我们同样需要监听节点的可见状态变更。

理论上,DOM可见状态的变更也在MutationObserver的监听范围内,最初的一种思路是:

  • 先设定MutationObserver的配置,开启attributeFilter和attributeOldValue,监听style的改变
  • 看oldValue的值是否包含display: none, 和新状态比对
  • 如成立,发送埋点
let observer = new MutationObserver((mutations) => {
      if (mutations[0].oldValue.indexOf('display: none') > -1 
      && mutations[0].target.style.display !== 'none') {
        sendRequest();
      }
  })
  let config = { 
      attributes: true,
      attributeOldValue: true,
      attributeFilter: ['style']
  };
  observer.observe(el, config);

但是这种思路很快被否决,因为很显然,可见状态还有可能是被节点类名class控制的。而具体节点上的类名是无法预期的,因此这种方案行不通。

最终我们使用了开源库 VisSense。VisSense提供了监听可见状态变更的能力,具体请见这里,本文不进行详细描述。

VisSense 实际使用了消息订阅模式和setInterval来进行周期性的节点状态检查,感兴趣的同学可以看看它的源码。

于是在这里我们就可以进行很方便的可见状态监听:

function handleShow(el) {
    var visMonitor = VisSense(el).monitor({
        visible: function() { 
            sendRequest();
        }
    });
    visMonitor.start();
}

眼球曝光埋点(collect)

眼球曝光埋点标识用户是否「看到」了某个区域,那么用前端的方式来解释就是:

  • 该区域是可见状态
  • 用户页面的滚动条位置与该区域的实际位置相匹配

主要的实现思路就是监听scroll事件,与当前节点的scrollTop进行对比。
由于本次需求未涉及眼球曝光,本部分不再赘述。

极端情况下的命令式埋点支持

上面的声明式埋点方案已经可以解决大多数问题。

但是,不是100%的情况都适用声明式埋点,主要发生在 DOM操作不受开发者完全控制 的情况。

举个例子,在使用百度地图API时,在地图上打一些POI点(markPoint), 或者一些蒙层(如Polygon), 再在点击这些覆盖物时埋点,由于这些DOM操作是百度地图API完成的,无法预期插入了哪些DOM,自然就不能在这些DOM上插入指令。所以只能在调用API时进行命令式埋点。需要我们也提供命令式埋点支持。

命令式埋点的大部分逻辑实际已经包含在指令中,于是我们在指令中提供了这样的接口方式:

export default {
  bind() {...},
  update() {...},
  unbind() {...},
  sendStat(val) {
    // 命令式埋点接口
  }
}

引入此模块后,即可以当作Vue指令使用,也可以当做一个API来使用。

其他的一些处理

此外,埋点方案还提供了可配置能力,可以设定测试环境还是生产环境的规则(根据URL匹配),设定埋点请求的URL地址,是否开启debug模式等。

在测试环境下,埋点请求的时机只会在浏览器中进行console.log并打印出触发埋点的节点,不会实际发送请求,可以支持测试环境下的正常开发,又可以避免埋点出现脏数据。

使用方式

  • 在Vue项目中,直接使用自定义指令即可
  • 在非Vue项目中,需要引入Mixin。如下
new Vue({
    el: '#app', // 根节点
    directives: {stat},
    mixins: [observerMixin] // 非Vue项目需要引入
})

然后在页面相应节点进行声明式埋点即可:

<div id="app" v-stat="{'act':'ready',' key':'samplepg'}"></div> // 页面展现埋点
<button v-stat="{'act':'click', 'key':'samplebtn'}"></button> // 点击统计埋点
<div id="container" v-stat="{'act':'show',' key':'samplepn'}"></div> // 区域展现埋点

这样的埋点方式十分简便快捷。

实际的使用情况

在实际业务开发过程中,本埋点方案平滑适配了Vue项目和jquery等开发的一些老项目,可以很好地和业务代码解耦,只需要在需要埋点的DOM节点上进行声明式埋点,开发简单高效,在排期人力紧张的情况下,很好地支持了100余个埋点数据统计。

总结

前端的数据采集和上报是构建数据平台的重要环节,而前端如何进行埋点也是值得深究的。为了快速满足业务的大量埋点需求,我们使用了本文的埋点方案,而且已经大量在商业平台部开发中使用,无论从FE同学的开发反馈、实际产出数据的结果来看都达到我们的预期,后续会继续在一些业务上进行持续迭代和优化。

文章被以下专栏收录
6 条评论