Files
TrueGrowth/specs/005-declarative-tracking/research.md

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-hovertrack-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

批量上报模式:

  1. 时间窗口模式: 固定时间间隔(如 5 秒)上报一次
    • 简单但可能延迟较大
  2. 数量触发模式: 累积到固定数量(如 10 个)立即上报
    • 响应快但可能频繁上报
  3. 混合模式: 先到达的条件触发(数量 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

防抖策略:

  1. 全局防抖: 任意事件触发后 500ms 内不上报任何事件
    • 简单但可能误杀正常事件
  2. 元素级防抖: 每个元素单独计时,500ms 内同一元素的同一事件只上报一次
    • 精确但需要存储状态
  3. 事件级防抖: 同一事件名防抖(不区分元素)
    • 折中方案

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

选择器策略:

  1. CSS 选择器: button, a, input, select, [onclick], [role="button"]
    • 简单但不够精确(onclick 可能是字符串属性)
  2. DOM 属性检查: 遍历元素,检查 onclick 是否为函数
    • 精确但性能较差
  3. 混合方案: 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

存储方案:

  1. localStorage: 同步 API,5MB 限制,可能阻塞主线程
  2. IndexedDB: 异步 API,更大容量,但 API 复杂
  3. 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

方案对比:

  1. 直接 import package.json: 在构建时将 version 注入代码

    import { version } from '../../../package.json';
    
    • Vite/Webpack 支持 JSON import
    • 缺点: 运行时无法动态更新
  2. 环境变量: 构建时注入 VITE_APP_VERSION

    const version = import.meta.env.VITE_APP_VERSION;
    
    • 灵活,支持不同环境的不同版本号
    • 需要在 vite.config.ts 中配置
  3. 全局配置对象: 应用启动时从 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