13 KiB
13 KiB
Technical Research: 声明式埋点上报系统
Feature: 005-declarative-tracking Date: 2025-12-05 Status: Complete
Research Questions
本研究解决 Technical Context 中标记的关键技术决策和最佳实践。
1. Umami Analytics API 集成
Question
如何集成和扩展 Umami 的事件上报 API,以支持自定义元数据(version, location.href)?
Research Findings
Umami 事件上报机制:
- Umami 使用
/api/send端点接收事件 - 支持自定义事件属性(event data)
- 标准请求格式:
{ "type": "event", "payload": { "website": "website-id", "name": "event-name", "data": { // 自定义属性 "version": "1.0.0", "url": "https://example.com/page" } } }
现有集成分析:
- 项目可能已有 Umami tracker 脚本或 SDK
- 需要检查
apps/web/src/中的 Umami 初始化代码 - 常见集成方式:
umami.track('event-name', { custom: 'data' })
扩展方案:
- 方案 A: 直接调用 Umami SDK 的
track方法,传递自定义 data - 方案 B: 直接调用 Umami API (
/api/send),完全控制请求格式 - 方案 C: 包装现有 Umami 实例,自动注入 version 和 url
Decision
选择方案 A (包装 Umami SDK):
- 优点: 复用现有 Umami 配置(website ID, API endpoint)
- 优点: 自动处理会话、用户标识
- 优点: 利用 Umami 的内置重试和错误处理
- 实现: 创建
UmamiTrackingAdapter类,封装umami.track()调用
扩展元数据注入:
interface UmamiTrackingAdapter {
track(eventName: string, params?: Record<string, any>): void {
const enrichedData = {
...params,
version: this.getVersion(), // 从 package.json 读取
url: window.location.href, // 实时获取
timestamp: Date.now()
};
umami.track(eventName, enrichedData);
}
}
Alternatives Considered
- 方案 B (直接 API): 需要重新实现会话管理、用户标识,维护成本高
- 方案 C (修改全局 Umami): 侵入性强,可能影响其他埋点代码
2. 事件委托 vs MutationObserver
Question
对于动态添加的元素,应该使用事件委托还是 MutationObserver?
Research Findings
事件委托 (Event Delegation):
- 原理: 在父容器上监听事件,通过
event.target判断目标元素 - 优点: 性能高,不需要为每个元素添加监听器
- 优点: 自动处理动态元素,无需额外逻辑
- 缺点: 只能监听冒泡事件(click、input 等),无法监听非冒泡事件(focus、blur 需 capture 模式)
MutationObserver:
- 原理: 监听 DOM 树变更,检测新增元素并为其添加监听器
- 优点: 可以精确控制每个元素的行为
- 优点: 支持非冒泡事件
- 缺点: 性能开销较大(每次 DOM 变更都会触发回调)
- 缺点: 需要手动管理监听器的添加和移除
混合方案:
- 冒泡事件(click): 使用事件委托
- 非冒泡事件(focus, hover): 使用 MutationObserver + 事件捕获
Decision
选择混合方案:
- 核心功能(click 事件): 使用事件委托
- 在
document.body或应用根节点添加一个 click 监听器 - 通过
event.target.closest('[track]')查找带埋点属性的元素 - 性能最优,代码简洁
- 在
- 扩展功能(hover, focus): 使用 MutationObserver
- 仅在启用
track-hover或track-focus时才激活 MutationObserver - 监听新增元素,为其添加对应的事件监听器
- 使用 WeakMap 存储已添加监听器的元素,避免重复添加
- 仅在启用
实现示例:
// 事件委托 (click)
document.body.addEventListener('click', (event) => {
const target = event.target.closest('[track]');
if (target && !isExcluded(target)) {
const eventName = target.getAttribute('track');
trackingService.track(eventName, getParams(target));
event.stopPropagation(); // 阻止冒泡到父元素
}
}, { capture: false });
// MutationObserver (hover/focus)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
attachEventListeners(node as Element);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
Rationale
- 事件委托覆盖 90% 的使用场景(click 是最常见的交互)
- MutationObserver 仅在需要时启用,避免不必要的性能开销
- 符合渐进增强原则
3. 批量上报策略
Question
如何实现高效的批量上报机制(10 个事件或 5 秒)?
Research Findings
批量上报模式:
- 时间窗口模式: 固定时间间隔(如 5 秒)上报一次
- 简单但可能延迟较大
- 数量触发模式: 累积到固定数量(如 10 个)立即上报
- 响应快但可能频繁上报
- 混合模式: 先到达的条件触发(数量 OR 时间)
- 平衡延迟和网络开销
实现方案:
- 使用队列(数组)缓存待上报事件
- 使用
setTimeout实现时间窗口 - 使用数组长度判断数量阈值
Decision
选择混合模式:
class TrackingBatchService {
private queue: TrackEvent[] = [];
private timer: NodeJS.Timeout | null = null;
private readonly BATCH_SIZE = 10;
private readonly BATCH_TIMEOUT = 5000; // 5 秒
enqueue(event: TrackEvent): void {
this.queue.push(event);
// 启动定时器(如果未启动)
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.BATCH_TIMEOUT);
}
// 达到数量阈值,立即上报
if (this.queue.length >= this.BATCH_SIZE) {
this.flush();
}
}
private async flush(): Promise<void> {
if (this.queue.length === 0) return;
const batch = [...this.queue];
this.queue = [];
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
await this.sendBatch(batch);
}
// 页面卸载时立即上报
onBeforeUnload(): void {
if (this.queue.length > 0) {
navigator.sendBeacon(API_ENDPOINT, JSON.stringify(this.queue));
}
}
}
关键设计点:
- 清空队列时使用
[...this.queue]避免并发问题 - 页面卸载时使用
navigator.sendBeacon,确保数据不丢失 - 定时器在 flush 后清除,避免重复上报
4. 防抖实现
Question
如何实现 500ms 防抖,避免同一元素的重复上报?
Research Findings
防抖策略:
- 全局防抖: 任意事件触发后 500ms 内不上报任何事件
- 简单但可能误杀正常事件
- 元素级防抖: 每个元素单独计时,500ms 内同一元素的同一事件只上报一次
- 精确但需要存储状态
- 事件级防抖: 同一事件名防抖(不区分元素)
- 折中方案
Decision
选择元素级防抖:
class TrackingDebouncer {
private debounceMap = new WeakMap<Element, Map<string, number>>();
private readonly DEBOUNCE_TIME = 500;
shouldTrack(element: Element, eventName: string): boolean {
if (!this.debounceMap.has(element)) {
this.debounceMap.set(element, new Map());
}
const eventTimestamps = this.debounceMap.get(element)!;
const lastTimestamp = eventTimestamps.get(eventName) || 0;
const now = Date.now();
if (now - lastTimestamp < this.DEBOUNCE_TIME) {
return false; // 防抖中,不上报
}
eventTimestamps.set(eventName, now);
return true; // 可以上报
}
}
优势:
- WeakMap 自动清理已删除元素的引用,避免内存泄漏
- 支持不同事件名的独立防抖(click 和 hover 互不影响)
- 精确控制每个元素的上报频率
5. 自动埋点选择器
Question
如何高效识别应该自动埋点的元素(原生交互元素 + onClick + role)?
Research Findings
选择器策略:
- CSS 选择器:
button, a, input, select, [onclick], [role="button"]- 简单但不够精确(onclick 可能是字符串属性)
- DOM 属性检查: 遍历元素,检查
onclick是否为函数- 精确但性能较差
- 混合方案: CSS 选择器 + 属性验证
Decision
选择混合方案:
const AUTO_TRACK_SELECTOR = `
button:not([data-track-ignore]),
a:not([data-track-ignore]),
input[type="button"]:not([data-track-ignore]),
input[type="submit"]:not([data-track-ignore]),
select:not([data-track-ignore]),
[role="button"]:not([data-track-ignore]),
[role="link"]:not([data-track-ignore])
`.trim();
function shouldAutoTrack(element: Element): boolean {
// 1. 检查是否在排除区域
if (element.closest('nav, header, footer, [data-track-ignore]')) {
return false;
}
// 2. 检查是否匹配选择器
if (element.matches(AUTO_TRACK_SELECTOR)) {
return true;
}
// 3. 检查是否有 onClick 事件处理器(React/Vue 绑定)
if (element.hasAttribute('onclick') || hasEventListener(element, 'click')) {
return true;
}
return false;
}
// 辅助:检查元素是否有事件监听器(仅限 React Fiber)
function hasEventListener(element: Element, eventType: string): boolean {
// React Fiber 内部属性
const fiberKey = Object.keys(element).find(key =>
key.startsWith('__reactFiber') || key.startsWith('__reactProps')
);
if (fiberKey) {
const props = (element as any)[fiberKey]?.memoizedProps;
return props && typeof props[`on${capitalize(eventType)}`] === 'function';
}
return false;
}
排除逻辑:
- 使用
closest()检查父级,避免导航/工具栏/页脚中的元素 data-track-ignore属性优先级最高,强制排除
6. 缓存管理
Question
如何实现失败事件的缓存(100 个事件, 1 小时 TTL)?
Research Findings
存储方案:
- localStorage: 同步 API,5MB 限制,可能阻塞主线程
- IndexedDB: 异步 API,更大容量,但 API 复杂
- localforage: IndexedDB 的简化封装,降级到 localStorage
Decision
选择 localforage:
- 项目已使用 localforage(参考
media-cache-service.ts) - 自动选择最佳存储方案(IndexedDB > WebSQL > localStorage)
- API 简洁,Promise 友好
缓存结构:
interface CachedEvent {
event: TrackEvent;
cachedAt: number;
retryCount: number;
}
class TrackingStorageService {
private readonly STORAGE_KEY = 'tracking_cache';
private readonly MAX_CACHE_SIZE = 100;
private readonly CACHE_TTL = 60 * 60 * 1000; // 1 小时
async add(event: TrackEvent): Promise<void> {
const cached = await this.getAll();
// 清理过期事件
const now = Date.now();
const valid = cached.filter(c =>
now - c.cachedAt < this.CACHE_TTL
);
// 限制数量(FIFO)
if (valid.length >= this.MAX_CACHE_SIZE) {
valid.shift(); // 移除最旧的
}
valid.push({
event,
cachedAt: now,
retryCount: 0
});
await localforage.setItem(this.STORAGE_KEY, valid);
}
async getAll(): Promise<CachedEvent[]> {
return await localforage.getItem(this.STORAGE_KEY) || [];
}
async clear(): Promise<void> {
await localforage.removeItem(this.STORAGE_KEY);
}
}
重试逻辑:
- 定期从缓存读取失败事件(每 30 秒)
- 重试成功后从缓存移除
- 重试次数超过 3 次后丢弃
7. 项目版本号获取
Question
如何从 package.json 或应用配置读取项目版本号?
Research Findings
方案对比:
-
直接 import package.json: 在构建时将 version 注入代码
import { version } from '../../../package.json';- Vite/Webpack 支持 JSON import
- 缺点: 运行时无法动态更新
-
环境变量: 构建时注入
VITE_APP_VERSIONconst version = import.meta.env.VITE_APP_VERSION;- 灵活,支持不同环境的不同版本号
- 需要在 vite.config.ts 中配置
-
全局配置对象: 应用启动时从 API 或配置文件读取
const version = window.APP_CONFIG?.version || '0.0.0';- 动态,但需要额外的配置加载逻辑
Decision
选择方案 2 (环境变量):
// vite.config.ts
import { defineConfig } from 'vite';
import packageJson from './package.json';
export default defineConfig({
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)
}
});
// tracking-service.ts
export class TrackingService {
private getVersion(): string {
return import.meta.env.VITE_APP_VERSION || 'unknown';
}
}
优势:
- 构建时自动读取 package.json 的 version
- 支持不同环境(dev, staging, prod)的版本标识
- 无需手动维护版本号
Summary of Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Umami 集成 | 包装 Umami SDK | 复用现有配置,自动处理会话 |
| 动态元素监听 | 事件委托 + MutationObserver 混合 | click 用委托(性能),hover/focus 用 Observer |
| 批量上报 | 混合模式(10 事件 OR 5 秒) | 平衡延迟和网络开销 |
| 防抖 | 元素级防抖(WeakMap) | 精确控制,避免内存泄漏 |
| 自动埋点选择 | CSS 选择器 + React Fiber 检查 | 全面覆盖,支持框架绑定 |
| 缓存存储 | localforage (IndexedDB) | 项目标准,自动降级 |
| 版本号获取 | Vite 环境变量 | 构建时注入,无需手动维护 |
Next Steps: 进入 Phase 1,生成 data-model.md, contracts/, quickstart.md