vue 项目声明式主动埋点

909 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 6 天,点击查看活动详情

公司系统需求加上埋点功能,用来统计各页面功能的使用情况。于是,结合网上资料以及之前使用埋点系统的经历,仔细研究研究。

调研

埋点分类

常见的埋点类型有三种

  • 代码埋点
    • 通过 JavaScript 代码主动将所需要的信息上报给服务器。
    • 优点:可以精确的上报所需的数据,对于少量埋点需求较为合适。
    • 缺点:代码遍布项目各处,不好维护管理。且埋点只能通过开发人员手动完成。
  • 可视化埋点
    • 需要另外一个可视化埋点圈选系统来圈选需要埋点的 DOM 元素。然后通过在系统中集成 SDK 来主动上报这些区域的埋点信息。其实算是另一种意义上的代码埋点。
    • 优点:有圈选系统可以让产品、运维同学自行决定埋点区域。
    • 缺点:适用范围有限,如内网系统、移动端 Hybrid 页面这些就很难用外部的可视化埋点来做。
  • 无埋点
    • 其实也叫全量埋点,即全局监听系统事件,把用户所有行为都进行上报。
    • 优点:行为数据记录全面,无需增加或维护埋点代码。
    • 缺点:上报数据量大,对服务器有一定压力。且无法精确上报某一功能的特停数据。

埋点目标

  • 数据监控:通过埋点让产品运维同学知道项目当前的具体情况,从而有针对性的去优化项目。
  • 异常监控:从开发角度去收集项目中发生的 JS 报错、接口报错等异常情况。发现问题、解决问题、优化项目。
  • 性能监控:收集项目运行中的各种性能指标,如白屏时间、首屏加载时间、接口请求时间等等。

埋点 SDK 实现猜想

以我之前工作中用到过的埋点系统 GrowingIO 为例。我们可以通过它的 SDK 文档 来验证上面的理论。

  • 它通过全局引入 JS 代码的方式来进行集成,它会在 window 全局对象下加上一个 gio 函数处理各种埋点行为。
  • 由于埋点系统会为很多项目服务,所以需要初始化的时候加上 gio('init', 'your projectId', {})
  • 它要求在需要圈选的 DOM 元素上 data-growing-container 属性,这其实是 HTML 元素的 dataset 属性,可以用来对元素进行自定义数据属性的读写操作。有了圈选标记,埋点事件拦截的时候就可以指哪打哪了。
  • 它通过 gio('track', eventId, eventLevelVariables); 函数实现了主动埋点行为,这个自然是必不可少的。总有埋点需求是自动埋点做不了的。
  • 它的无埋点记录的是所有元素的点击量和浏览量,应该是全局监听了元素的点击和可视事件。
  • 它的可视化圈选是通过 XPath 来唯一定位一个元素的,那么可视化圈选其实就是将目标 DOM 的 xPath 保存起来,在埋点的时候去获取指定 DOM 元素的点击量和访问量。(关于 xpath 的使用可以看 Introduction to using XPath in JavaScript - XPath | MDN

我的埋点

方案选择

由于项目的埋点只需要记录一些指定的行为,所以全埋点方案被我 PASS 了。同时也没有必要另外写一个页面去做埋点的圈选,最终,选择了最简单粗暴地主动埋点。

主动埋点 1.0

一开始埋点其实很简单,通过在 JavaScript 代码中写埋点代码来进行实现。

定义一个埋点工具对象。

// logger.js
export default {
  ...,
  track(data) {
    const configInfo = this.getConfigInfo() // 一些公共配置信息,如用户名、token、时间、url 等
    return fetch.post('/api/v1/web/log', {
      ...data,
      ...configInfo,
    })
  },
}

将 logger 对象绑到 Vue 的原型中。

// main.js
Vue.prototype.$logger = logger

在需要的地方主动埋点。

<template>
  <div>
    <el-button @click="download">download</el-button>
  </div>
</template>

<script>
  export default {
    name: 'demo',
    methods: {
      download() {
        window.open('file url')
        this.trackLogger()
      },
      trackLogger() {
        this.$logger.track({
          component_id: '2',
          component_name: '下载按钮',
        })
      },
    },
  }
</script>

<style lang="scss" scoped></style>

遇到的问题

其实主动埋点应该就是如此,但随着埋点代码的逐渐增多(已经从起初的 20 条增加到 203 条了……)。看代码的时候就非常难受了。描述一个场景:

  • 需要检查同事代码中的埋点情况,由于不清楚他的代码,就需要一点点找了。
  • 全局搜索埋点代码 $logger.track(),得到 n 个包含有埋点代码的函数。
  • 再逐个跟踪这些包含埋点代码的函数的触发位置(有时候还会是函数嵌函数),最终找到绑定函数的 DOM 元素。
  • 如此才算是确定了一个元素拥有埋点行为。

声明式 vs 命令式

面对上面的场景,我在想有没有办法能够省去逐个查函数的步骤,让主动埋点代码更加直观呢。这里就得提到另外一个点了:声明式代码与命令式代码的区别了。

  • 声明式代码:如 HTML、XML、CSS,它的特点是可读性更强,描述的时候更符合直觉、更形象。
  • 命令式代码:如 JavaScript,它的特点是更符合行为步骤的思考模式,适合处理一些逻辑性强的功能。

举几个栗子

比如画一幅画,用声明式的方式来描述是“我要画一幅画,它有青草、大树和天空”;而用命令式的方式描述是"我要画一幅画,首先需要画青草,然后再画大树,最后加上蓝色的天空。"

还有一个例子,在 vue 中有一个 createElement 函数,它可以在 vue 的 render 函数中命令式的创建 DOM 元素。

createElement(
  'anchored-heading',
  {
    props: {
      level: 1,
    },
  },
  [createElement('span', 'Hello'), ' world!'],
)

但这种命令式的写法可读性很差。vue 官方也发现了这个问题,于是引进了 JSX 来弥补这个缺陷。

import AnchoredHeading from './AnchoredHeading.vue'

new Vue({
  el: '#demo',
  render: function (h) {
    return (
      <AnchoredHeading level={1}>
        <span>Hello</span> world!
      </AnchoredHeading>
    )
  },
})

JSX 的写法明显就更偏向于声明式。

那么回过来复习下主动埋点的目的:通过代码主动上报指定 DOM 元素的行为事件。所以个人感觉用声明式写法会更好一些。

主动埋点 2.0

说干就干,我试着将命令式埋点改为声明式埋点。

首先在入口文件 main.js 中引入全局注册逻辑。

// 事件名称
const COMPONENT_MAP = {
  1: '图表切换',
  2: '下载按钮',
}

// 修复点击子元素不上报埋点信息的问题
function bindDataset(el, value) {
  el.dataset.loggerId = value
  // 递归绑定 dataset 到所有子集上
  el.children.forEach((child) => {
    bindDataset(child, value)
  })
}

// 全局注册指令,在需要埋点的 DOM 上加上 dataset
Vue.directive('logger', {
  bind: function (el, binding) {
    const { value } = binding
    bindDataset(el, value)
  },
})

// 全局监听组件点击事件,加入防抖是为了避免短时间内快速重复点击
document.addEventListener(
  'click',
  throttle((e) => {
    if (e.target.dataset.loggerId) {
      this.$logger.track({
        component_id: e.target.dataset.loggerId,
        component_name: COMPONENT_MAP[e.target.dataset.loggerId],
      })
    }
  }, 2000),
)

在上面代码中,我将埋点通过vue 指令的方式将埋点信息绑定到目标 DOM 的 dateset 上面。然后通过全局 click 事件拦截来获得目标元素的点击行为,并上报埋点信息。

  • 由于没有找到如何直接在 Vue 组件上直接操作 DOM 的方式(ref 不算,那个需要写很多的 ref='xxx' 很不划算),所以想到了 Vue 指令。
  • 在点击 DOM 元素的时候,如果元素中有子节点那么全局 click 事件只能捕获到子节点的事件,于是我偷懒将子节点都加上了 dataset。(组件的子元素不会太多,偷个懒了)

以上遇到的两个问题个人感觉不是最佳方案,如果有好的解决方案欢迎讨论呀!

使用方式如下,可读性上强了不少。

<div class="filter-wrap" @click="setFilterPopupVisible(true)" v-logger="1">
  <img class="filter-icon" :src="filterIconUrl" />
  <img
    class="filter-icon-checked"
    :src="filterSelectedIconUrl"
    v-show="isFilterActive"
  />
</div>

如此,以后在看埋点代码的时候只要全局搜索 v-logger 就可以很方便的看到有哪些 DOM 元素或者 vue 组件是进行了埋点的了。不需要反复去查各种事件了。

最后

折腾了一圈,主要就是想解决看主动埋点代码太恶心的问题。然后顺便复习一些知识点。

  • 声明式编程和命令式编程
  • 埋点相关知识
  • dataset
  • xpath

参考资料