文章目录
按钮埋点最直接的写法,是在业务点击方法里调用一次统计接口:
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
↓
点击发生时生成用户、路由、终端和时间信息
↓
公共参数与页面参数合并
↓
调用统一埋点接口
↓
捕获异常,不改变原按钮业务
其中有三个容易忽略的点:
- 公共参数要在点击时生成,否则路由和时间可能是旧值。
- 指令绑定值要在
updated中刷新,否则表格行复用后可能上报旧 ID。 - 埋点 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 在事件发生时生成上下文,最后由接口层完成上报。业务代码不需要知道公共参数和请求异常,埋点模块也不参与业务流程控制。