Files

473 lines
13 KiB
Markdown

# 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)
- 标准请求格式:
```json
{
"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()` 调用
**扩展元数据注入**:
```typescript
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 存储已添加监听器的元素,避免重复添加
**实现示例**:
```typescript
// 事件委托 (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
**批量上报模式**:
1. **时间窗口模式**: 固定时间间隔(如 5 秒)上报一次
- 简单但可能延迟较大
2. **数量触发模式**: 累积到固定数量(如 10 个)立即上报
- 响应快但可能频繁上报
3. **混合模式**: 先到达的条件触发(数量 OR 时间)
- 平衡延迟和网络开销
**实现方案**:
- 使用队列(数组)缓存待上报事件
- 使用 `setTimeout` 实现时间窗口
- 使用数组长度判断数量阈值
### Decision
**选择混合模式**:
```typescript
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
**防抖策略**:
1. **全局防抖**: 任意事件触发后 500ms 内不上报任何事件
- 简单但可能误杀正常事件
2. **元素级防抖**: 每个元素单独计时,500ms 内同一元素的同一事件只上报一次
- 精确但需要存储状态
3. **事件级防抖**: 同一事件名防抖(不区分元素)
- 折中方案
### Decision
**选择元素级防抖**:
```typescript
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
**选择器策略**:
1. **CSS 选择器**: `button, a, input, select, [onclick], [role="button"]`
- 简单但不够精确(onclick 可能是字符串属性)
2. **DOM 属性检查**: 遍历元素,检查 `onclick` 是否为函数
- 精确但性能较差
3. **混合方案**: CSS 选择器 + 属性验证
### Decision
**选择混合方案**:
```typescript
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
**存储方案**:
1. **localStorage**: 同步 API,5MB 限制,可能阻塞主线程
2. **IndexedDB**: 异步 API,更大容量,但 API 复杂
3. **localforage**: IndexedDB 的简化封装,降级到 localStorage
### Decision
**选择 localforage**:
- 项目已使用 localforage(参考 `media-cache-service.ts`)
- 自动选择最佳存储方案(IndexedDB > WebSQL > localStorage)
- API 简洁,Promise 友好
**缓存结构**:
```typescript
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
**方案对比**:
1. **直接 import package.json**: 在构建时将 version 注入代码
```typescript
import { version } from '../../../package.json';
```
- Vite/Webpack 支持 JSON import
- 缺点: 运行时无法动态更新
2. **环境变量**: 构建时注入 `VITE_APP_VERSION`
```typescript
const version = import.meta.env.VITE_APP_VERSION;
```
- 灵活,支持不同环境的不同版本号
- 需要在 vite.config.ts 中配置
3. **全局配置对象**: 应用启动时从 API 或配置文件读取
```typescript
const version = window.APP_CONFIG?.version || '0.0.0';
```
- 动态,但需要额外的配置加载逻辑
### Decision
**选择方案 2 (环境变量)**:
```typescript
// 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