站内搜索

输入关键词开始搜索

Vue 3 按钮埋点实现:v-track 指令与统一上报

Vue 3
文章目录

按钮埋点最直接的写法,是在业务点击方法里调用一次统计接口:

async function handleCreate() {
  trackClick('data_source_database_create')
  openCreateDialog()
}

按钮变多后,这种写法会让事件编码、用户信息、路由信息和异常处理散落在页面中。业务方法本来只负责“新建数据源”,现在还要了解埋点请求结构。

本文用 Vue 3 自定义指令实现一个全局 v-track:

<el-button
  v-track="{
    event: 'button',
    params: { moduleCode: 'data_source_database_create' },
  }"
  @click="openCreateDialog"
>
  新建数据库数据源
</el-button>

页面只声明“这是哪个操作”。点击监听、公共参数和接口异常由埋点模块处理。

一次点击是怎样完成上报的

v-track 的执行链如下:

按钮触发 click
  ↓
指令注册的事件处理函数读取最新 binding.value
  ↓
校验 event 和 moduleCode
  ↓
点击发生时生成用户、路由、终端和时间信息
  ↓
公共参数与页面参数合并
  ↓
调用统一埋点接口
  ↓
捕获异常,不改变原按钮业务

其中有三个容易忽略的点:

  1. 公共参数要在点击时生成,否则路由和时间可能是旧值。
  2. 指令绑定值要在 updated 中刷新,否则表格行复用后可能上报旧 ID。
  3. 埋点 Promise 必须在模块内部处理失败,不能把异常留给业务点击方法。

定义事件数据

埋点接口使用统一结构。event 区分事件类型,params 保存公共参数和业务参数。

// src/utils/track/types.ts
export type TrackEvent = 'button' | 'page_view' | 'custom'

export interface TrackParams {
  moduleCode?: string
  moduleName?: string
  bizType?: string
  bizId?: string | number
  [key: string]: unknown
}

export interface TrackOptions {
  event?: TrackEvent
  params: TrackParams
}

export interface TrackPayload {
  event: TrackEvent
  params: TrackParams
}

moduleCode 是后台识别具体操作的稳定编码。推荐使用“业务域_模块_操作”的形式:

data_source_database_query
data_source_database_create
data_source_database_edit
data_source_database_delete
data_collection_task_start

按钮文案可以变化,但同一个业务操作的编码不应随意变化。

封装接口

接口层只负责发送请求,不处理 Vue 指令和页面上下文:

// src/api/track.ts
import request from '@/utils/request'
import type { TrackPayload } from '@/utils/track/types'

export function createTrackLog(payload: TrackPayload) {
  return request.post('/track/log', payload)
}

后续增加页面浏览或自定义事件时,仍然可以使用同一个接口,通过 event 区分类型。

点击时生成公共参数

埋点模块需要读取当前路由和登录用户。这里通过插件注册时传入 router 和 getUser,避免埋点工具直接依赖某个 Pinia 仓库。

// src/utils/track/index.ts
import type { Router } from 'vue-router'

interface TrackUser {
  id?: string
  name?: string
  type?: number
}

interface TrackRuntime {
  router?: Router
  getUser?: () => TrackUser | null
}

let runtime: TrackRuntime = {}

function getDeviceType() {
  const userAgent = navigator.userAgent

  if (/ipad|tablet/i.test(userAgent)) return 'Tablet'
  if (/android|iphone|mobile/i.test(userAgent)) return 'Mobile'
  return 'Desktop'
}

function createCommonParams() {
  const route = runtime.router?.currentRoute.value
  const user = runtime.getUser?.()

  return {
    userId: user?.id || '',
    userName: user?.name || '',
    userType: user?.type,
    accessDate: new Date().toISOString(),
    appCode: import.meta.env.VITE_TRACK_APP_CODE || '',
    terminal: getDeviceType(),
    userAgent: navigator.userAgent,
    routePath: route?.path || window.location.pathname,
    routeFullPath:
      route?.fullPath || `${window.location.pathname}${window.location.search}`,
    pageTitle: String(route?.meta?.title || document.title || ''),
  }
}

createCommonParams 不能在模块加载时执行:

// 错误示例:只会记录应用启动时的路由和时间
const commonParams = createCommonParams()

用户可能已经切换页面,登录状态也可能发生变化。正确做法是在每次 sendTrack 时调用它。

客户端不应该尝试生成可信的 IP。浏览器无法稳定获取公网 IP,前端字段也可以被修改。IP、服务端时间和审计身份应由后端根据请求补充。

统一 sendTrack 上报入口

模板指令和脚本调用最终都进入 sendTrack:

import { createTrackLog } from '@/api/track'
import type {
  TrackEvent,
  TrackParams,
  TrackPayload,
} from './types'

function isTrackEnabled() {
  return import.meta.env.VITE_TRACK_ENABLED === 'true'
}

export async function sendTrack(
  event: TrackEvent = 'button',
  params: TrackParams = {},
): Promise<void> {
  if (!isTrackEnabled()) return

  const payload: TrackPayload = {
    event,
    params: {
      ...createCommonParams(),
      ...params,
    },
  }

  try {
    await createTrackLog(payload)
  } catch {
    // 埋点失败不能中断按钮原有业务。
  }
}

export function trackClick(params: TrackParams = {}) {
  return sendTrack('button', params)
}

合并时业务参数放在后面:

{
  ...createCommonParams(),
  ...params,
}

这样页面可以补充 moduleName、bizType 和 bizId。用户身份和权限仍应由后端校验,不能因为前端可以传同名字段就直接信任。

环境变量需要与字符串 "true" 比较。直接判断 import.meta.env.VITE_TRACK_ENABLED 会把字符串 "false" 也视为真值。

v-track 指令核心实现

指令在元素挂载时增加一个独立的 click 监听。为了让事件处理函数始终读取最新参数,将当前绑定值保存在元素上:

import type { ObjectDirective } from 'vue'
import type { TrackOptions } from './types'

interface TrackElement extends HTMLElement {
  __trackValue__?: TrackOptions
  __trackHandler__?: () => void
}

function normalizeTrackOptions(value?: TrackOptions) {
  if (!value?.params?.moduleCode) return null

  return {
    event: value.event || 'button',
    params: value.params,
  }
}

const trackDirective: ObjectDirective<TrackElement, TrackOptions> = {
  mounted(el, binding) {
    el.__trackValue__ = binding.value

    el.__trackHandler__ = () => {
      const options = normalizeTrackOptions(el.__trackValue__)
      if (!options) return

      void sendTrack(options.event, options.params)
    }

    el.addEventListener('click', el.__trackHandler__)
  },

  updated(el, binding) {
    el.__trackValue__ = binding.value
  },

  unmounted(el) {
    if (el.__trackHandler__) {
      el.removeEventListener('click', el.__trackHandler__)
    }

    delete el.__trackValue__
    delete el.__trackHandler__
  },
}

这段代码的真实执行顺序是:

mounted:安排点击监听

mounted(el, binding) {
  el.__trackValue__ = binding.value
  el.__trackHandler__ = () => {
    const options = normalizeTrackOptions(el.__trackValue__)
    if (!options) return
    void sendTrack(options.event, options.params)
  }
  el.addEventListener('click', el.__trackHandler__)
}

mounted 只负责保存初始配置并注册监听,此时还没有发送埋点。真正上报发生在用户点击后。

处理函数读取 el.__trackValue__,而不是闭包中的 binding.value。这是为了让后续的 updated 能替换当前值。

void sendTrack(...) 表示按钮业务不等待埋点完成。请求失败已经由 sendTrack 内部捕获,不会产生未处理的 Promise 拒绝。

updated:替换参数快照

updated(el, binding) {
  el.__trackValue__ = binding.value
}

表格通常会复用按钮 DOM。当前行从 ID 10 更新成 ID 11 后,指令不会重新挂载,但会进入 updated。这里替换参数,下一次点击才能上报 ID 11。

unmounted:解除监听

unmounted(el) {
  if (el.__trackHandler__) {
    el.removeEventListener('click', el.__trackHandler__)
  }
  delete el.__trackValue__
  delete el.__trackHandler__
}

元素离开页面后移除监听,并删除附加属性。这个阶段不发送任何请求,只做资源清理。

注册全局插件

插件负责写入运行时依赖并注册指令:

import type { App, Plugin } from 'vue'

interface TrackPluginOptions extends TrackRuntime {}

export function createTrackPlugin(
  options: TrackPluginOptions = {},
): Plugin {
  return {
    install(app: App) {
      runtime = options
      app.directive('track', trackDirective)
      app.config.globalProperties.$track = sendTrack
      app.config.globalProperties.$trackClick = trackClick
    },
  }
}

在应用入口传入路由和真实用户状态:

// src/main.ts
import { createTrackPlugin } from '@/utils/track'
import { useUserStore } from '@/stores/user'

app.use(router)
app.use(
  createTrackPlugin({
    router,
    getUser: () => {
      const userStore = useUserStore()

      return {
        id: userStore.userId,
        name: userStore.userName,
        type: userStore.userType,
      }
    },
  }),
)

注册完成后,所有组件模板都可以使用 v-track。

传入动态业务参数

删除按钮通常需要携带当前行 ID:

<el-button
  v-track="{
    event: 'button',
    params: {
      moduleCode: 'data_source_database_delete_click',
      moduleName: '删除数据库数据源',
      bizType: 'database',
      bizId: row.id,
    },
  }"
  @click="onDelete(row)"
>
  删除
</el-button>

这条埋点统计的是“用户点击删除”。用户可能取消确认框,也可能遇到接口失败,因此它不等于删除成功次数。

接口成功后手动埋点

需要统计操作结果时,应在业务接口成功后调用 trackClick:

import { trackClick } from '@/utils/track'

async function onDelete(row: DatabaseRow) {
  await ElMessageBox.confirm(`确定删除「${row.name}」?`, '提示', {
    type: 'warning',
  })
  await deleteDatabase(row.id)

  void trackClick({
    moduleCode: 'data_source_database_delete_success',
    moduleName: '删除数据库数据源成功',
    bizType: 'database',
    bizId: row.id,
  })
}

点击和成功是两个不同事件:

编码含义
data_source_database_delete_click用户尝试删除
data_source_database_delete_success删除接口已经成功

如果只关心成功次数,就不需要在按钮上同时配置点击埋点。

配置开关

VITE_TRACK_ENABLED=false
VITE_TRACK_APP_CODE=your_app

开发环境默认关闭埋点,避免本地操作污染正式统计。不同部署环境使用各自的环境变量,不在页面中写死开关。

实现中的几个边界

埋点失败不等于什么都不处理

初版可以静默失败,但至少应在开发环境输出调试信息,或者在监控系统记录埋点接口自身的错误率。完全忽略失败会让数据缺失长期不可见。

不要上传完整业务对象

下面的写法会把表格行中的所有字段交给埋点接口:

trackClick({ moduleCode: 'database_edit', row })

应改成只发送统计需要的标识:

trackClick({
  moduleCode: 'database_edit',
  bizId: row.id,
  bizType: 'database',
})

密码、Token、身份证号、手机号和连接串不能进入埋点参数。

页面浏览不能照搬按钮点击

按钮点击时页面仍然存活,普通异步请求通常足够。页面关闭或跳转时,普通请求可能被浏览器取消。后续实现页面离开埋点时,需要考虑 navigator.sendBeacon 或 fetch(..., { keepalive: true }),这不是按钮指令本身要解决的问题。

验证结果

完成实现后,至少验证下面几条路径:

操作预期结果
关闭 VITE_TRACK_ENABLED不发送埋点请求
点击普通埋点按钮发送一次 button 事件
切换路由后点击上报最新路由信息
表格行更新后点击上报最新 bizId
埋点接口返回错误原按钮业务继续执行
取消删除确认框不发送成功事件
删除接口成功发送对应成功事件
组件卸载点击监听被移除

这个实现的核心不是自定义指令本身,而是把埋点变成独立的数据通道:页面声明操作编码,指令捕获点击,sendTrack 在事件发生时生成上下文,最后由接口层完成上报。业务代码不需要知道公共参数和请求异常,埋点模块也不参与业务流程控制。

相关文章

Vue 3 多页签 keep-alive:缓存失效与重复请求排查

从嵌套 keep-alive 的 deactivate 报错和非活跃组件重复请求入手,梳理多页签后台的缓存身份与副作用管理。

前端渲染大型表格方法总结

前端渲染大型表格方法总结