本文将分享一个从零设计前端埋点系统的过程,涵盖需求分析、字段规范、架构设计、性能优化以及实践落地的经验。
📖 需求背景
随着业务复杂度提升,光靠后端日志已经无法满足用户行为分析的需求。我们需要一个统一的前端埋点体系,能回答如下问题:
- 用户什么时候进入了某个页面?
- 在页面里点击了哪些按钮?
- 页面停留了多久?
- 应用运行期间,是否发生了充值、Socket 连接、系统错误等事件?
然而,在重构前,我们的埋点存在几个痛点:
- 字段没有统一标准,日志结构混乱。
- 缺少核心字段,后端无法高效建索引。
- 冗余字段过多,导致体积大、查询慢。
于是,我们从字段规范出发,重新设计了整个埋点体系。
🗂️ 字段规范设计
1. 可查询字段(核心维度)
这些字段会被后端建立索引,必须统一存在。
字段 | 含义 | 示例 |
---|---|---|
event |
事件大类 | 用户行为 / 充值 / Socket / 系统 / 融云 |
extData |
事件子类 | 页面展示 / 点击事件 / ws_connect / pay_success |
extData1 |
页面上下文信息 | UserPage |
后端可以高效地查出所有符合条件的数据
查询速度很快,不会因为日志量大而卡顿
- 查询 event = “user_behavior” → 会快速返回所有用户行为日志
- 查询 extData = “click_event” → 会快速返回所有点击事件
- 查询 extData1 = “首页” → 会快速返回该页面的所有操作日志
2. 不可查询字段(附加信息)
不会建索引,但在排查问题时很有价值。
字段 | 含义 | 示例 |
---|---|---|
extData2 |
应用运行时间(相对启动) | 3.24s |
extData3 |
操作描述 | 点击拨打按钮 / 点击金币浮窗 |
tm |
时间戳 | 1694567890000 |
这样的分层设计带来了两个好处:
- 查询层面:只依赖少量固定字段,保证高效。
- 分析层面:上下文信息保留完整,排查问题更方便。
🏗️ 架构设计
1. 统一事件枚举
用 enum
定义事件大类和子类,避免硬编码。
export enum LogEvent {
UserBehavior = 'user_behavior',
Recharge = 'recharge',
Socket = 'socket',
System = 'system',
RongCloud = 'rongcloud',
}
export enum UserBehaviorType {
PageView = 'page_view',
Click = 'click_event',
}
2. 队列批量调度
日志不会立即发送,而是进入队列,统一 flush
。
const queue: any[] = [];
let isFlushing = false;
// 发送队列中所有日志的函数
function flushQueue() {
// 如果正在发送中或者队列为空,直接返回
if (isFlushing || queue.length === 0) return;
isFlushing = true; // 标记正在发送中
// 取出当前队列内容
const toSend = queue.splice(0, queue.length);
// 调用接口发送日志(异步)
apis.logApi.liveChat({
data: toSend,
subtype: 'client',
}).finally(() => {
// 无论成功或失败,重置标记
isFlushing = false;
});
}
function scheduleFlush() {
if ('requestIdleCallback' in window) {
requestIdleCallback(flushQueue, { timeout: 2000 });
} else {
setTimeout(flushQueue, 2000);
}
}
// 日志入队(对外不暴露,外部用 useLogger)
function pushLog(event: LogEvent, type: string, description: string) {
const payload: LogPayload = {
event,
extData: type,
extData1: getCurrentTabAndPage(),
extData2: getElapsedTime(),
extData3: description,
tm: Date.now(),
// 可按需补充 userId/device/network 等
}
queue.push(payload)
scheduleFlush()
}
// 对外 API(开发只关心语义,不关心结构)
export function useLogger() {
return {
logUserBehavior(type: UserBehaviorType, desc: string) {
pushLog(LogEvent.UserBehavior, type, desc)
},
// logRecharge/logSocket/logSystem/logRongCloud ...
}
}
3. 相对时间计算
用 performance.now()
记录 App 启动时间,计算用户行为发生的相对时间:
let appStartTime: number | null = null;
export function initAppStartTime() {
appStartTime = performance.now();
}
function getElapsedTime() {
if (appStartTime === null) return '0s';
const elapsed = performance.now() - appStartTime;
return `${(elapsed / 1000).toFixed(2)}s`;
}
为什么不用绝对时间戳,而是用「相对启动时间」?它能帮助我们还原用户的完整操作轨迹,比如 进入页面 1 秒 -> 点击按钮 2 秒 -> 打开弹窗 3 秒,更直观。
4. 点击行为埋点指令
很多埋点是点击行为,把它封装成 Vue 指令,可以降低业务代码的侵入性。开发只需要在模板里写 v-log-click=“‘点击拨打按钮’”,就能自动打点。
import { useLogger } from '@/utils/eventTracker';
import { UserBehaviorType } from '@/enum/enum';
const logger = useLogger();
export default {
mounted(el: HTMLElement, binding: { value: string }) {
const desc = binding.value;
if (!desc || typeof desc !== 'string') return;
el.addEventListener('click', () => {
logger.logUserBehavior(UserBehaviorType.Click, desc);
});
},
};
使用方式:
<button v-log-click="'点击拨打按钮'">拨打</button>
5. 路由拦截统一打点
页面切换是非常重要的埋点(PageView),可以通过 uni.addInterceptor 或 Vue Router 的导航守卫统一记录「页面展示 / 页面隐藏」,而不是在每个页面手写埋点。
let url = '';
uni.addInterceptor("navigateTo", {
invoke(args) {
logger.logUserBehavior(UserBehaviorType.PageView, Util.getCurrentPagePath() + ' 页面隐藏');
url = args.url;
return args;
},
success() {
setTimeout(() => {
logger.logUserBehavior(UserBehaviorType.PageView, url + ' 页面展示');
}, 500);
}
});
uni.addInterceptor("navigateBack", {
invoke(args) {
logger.logUserBehavior(UserBehaviorType.PageView, Util.getCurrentPagePath() + ' 页面隐藏');
return args;
}
});
⚡ 性能优化
- 批量发送:减少请求次数,降低网络开销。
- 空闲调度:利用
requestIdleCallback
避免抢占主线程。 - 失败重试:日志发送失败时重新入队,保证不丢失。
- 轻量存储:可选持久化到
localStorage
,防止页面关闭前日志丢失。
✅ 总结
通过这次埋点系统重构,我们达成了以下目标:
- 字段层面规范化:区分可查询字段和附加信息,保证查询高效。
- 架构层面模块化:枚举管理事件,统一日志 API。
- 使用层面简化:自定义指令和路由拦截降低业务侵入性。
- 性能层面优化:队列批量发送,利用浏览器空闲时间调度。
最终,开发者只需要一句:
logger.logUserBehavior(UserBehaviorType.Click, '点击拨打按钮');
就能生成一条完整的日志,而日志结构在全局范围内保持一致,后端查询和 BI 分析也更加高效。