Initial TrueGrowth source import
This commit is contained in:
472
specs/005-declarative-tracking/research.md
Normal file
472
specs/005-declarative-tracking/research.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user