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

10 KiB
Raw Permalink Blame History

Bug Fix V2: 重复事件上报 - 单例模式修复

日期: 2025-12-05 版本: V2 (最终修复) 问题: 同一事件仍然上报2次toolbar_click_handchat_click_drawer_close

问题根因

V1 修复的不足

在 V1 修复中,我们:

  1. 移除了 stopPropagation(),解决了 onClick 失效
  2. 添加了全局防抖机制100ms

仍然有重复上报,根本原因是:

多个 TrackingService 实例

// ❌ 问题代码withTracking.ts
export function withTracking<T>(editor: T, config?: Partial<TrackConfig>): T {
  // 每次调用都创建新实例!
  const trackingService = new TrackingService(config);

  // 每个实例都添加自己的事件监听器
  trackingService.initialize();
  // document.body.addEventListener('click', this.clickListener, true);

  return editor;
}

触发场景:

  1. React 组件每次重新渲染
  2. Plugins 数组重新创建
  3. withTracking 被多次调用
  4. 每次调用都创建新的 TrackingService 实例
  5. 每个实例都在 document.body 上添加 click 事件监听器
  6. 结果: 有 N 个监听器,每次点击触发 N 次上报

验证:

// 在控制台运行
const listeners = getEventListeners(document.body);
console.log('Click listeners count:', listeners.click?.length);
// 如果有重复上报,这里会显示 > 1

V2 解决方案

修复 1: 单例模式

修改文件: packages/drawnix/src/plugins/tracking/withTracking.ts

// ✅ 修复后:单例模式
let globalTrackingService: TrackingService | null = null;

export function withTracking<T>(editor: T, config?: Partial<TrackConfig>): T {
  // 只在第一次调用时创建实例
  if (!globalTrackingService) {
    globalTrackingService = new TrackingService(config);

    if (typeof window !== 'undefined') {
      setTimeout(() => {
        globalTrackingService?.initialize();
      }, 0);
    }

    // 热重载支持
    if (typeof module !== 'undefined' && (module as any).hot) {
      (module as any).hot.dispose(() => {
        resetGlobalTrackingService();
      });
    }
  }

  // 所有 editor 实例共享同一个 tracking service
  (editor as any).trackingService = globalTrackingService;

  return editor;
}

// 重置函数(用于开发环境热重载)
export function resetGlobalTrackingService(): void {
  if (globalTrackingService) {
    globalTrackingService.destroy(); // 移除事件监听器
    globalTrackingService = null;
  }
}

改进:

  • 确保整个应用只有 一个 TrackingService 实例
  • 确保 document.body 上只有 一个 click 事件监听器
  • 热重载时自动清理旧实例
  • 所有 editor 实例共享同一个 tracking service

修复 2: 增加全局防抖时间

修改文件: packages/drawnix/src/services/tracking/tracking-utils.ts

// ✅ 修复后
export class TrackingDebouncer {
  private globalDebounceTime: number = 200; // 从 100ms 增加到 200ms

  shouldTrack(element: Element, eventName: string, devMode: boolean = false): boolean {
    const now = Date.now();

    // 第一层全局事件名称防抖200ms窗口
    const lastGlobalTimestamp = this.globalDebounceMap.get(eventName);
    if (lastGlobalTimestamp && now - lastGlobalTimestamp < this.globalDebounceTime) {
      if (devMode) {
        console.warn(`[Tracking] 🚫 Global debounce: ${eventName} (${now - lastGlobalTimestamp}ms ago)`);
      }
      return false; // 拦截重复事件
    }

    // ... 第二层防抖
  }
}

改进:

  • 200ms 窗口更可靠V1 是 100ms
  • 添加 devMode 调试日志
  • 双层防抖:全局 + 元素特定

修复 3: 调试日志支持

修改文件: packages/drawnix/src/services/tracking/tracking-service.ts

// ✅ 所有防抖检查都传入 devMode
private trackClick(element: Element, eventName: string): void {
  if (!this.debouncer.shouldTrack(element, eventName, this.config.devMode)) {
    // ...
  }
}

调试模式输出:

[Tracking] ✅ Track: toolbar_click_hand
[Tracking] 🚫 Global debounce: toolbar_click_hand (15ms ago)  // 拦截重复事件

修复对比

维度 V1 修复 V2 修复
stopPropagation 移除
全局防抖 100ms 200ms ⬆️
单例模式
事件监听器数量 N 个 1 个
调试日志
热重载支持
重复上报 仍存在 完全解决

测试验证

方法 1: 控制台检查监听器数量

// 在浏览器控制台运行
const listeners = getEventListeners(document.body);
console.log('Click listeners:', listeners.click?.length);
// 预期输出: 1

方法 2: 启用调试模式

withTracking 配置中启用 devMode:

const plugins: PlaitPlugin[] = [
  // ...
  (editor) => withTracking(editor, {
    devMode: true,  // ⬅️ 启用调试日志
    logLevel: 'debug'
  }),
];

控制台输出示例:

[Tracking] ✅ Track: chat_click_drawer_close
// 没有第二次上报!

方法 3: Umami 后台验证

  1. 打开 Umami Analytics 后台
  2. 实时查看事件流
  3. 点击任意按钮
  4. 确认每次点击只上报 1 次事件

技术细节

为什么单例模式有效?

┌─────────────────────────────────────────┐
│ Application Lifecycle                   │
├─────────────────────────────────────────┤
│                                         │
│  Component Render 1                     │
│    └─ withTracking() called             │
│        └─ Create TrackingService        │ ⬅️ 第1次
│           └─ addEventListener()         │
│                                         │
│  Component Re-render (state change)     │
│    └─ withTracking() called again      │
│        └─ ❌ V1: Create new instance   │ ⬅️ 第2个监听器
│        └─ ✅ V2: Reuse existing        │ ⬅️ 仍然是1个
│                                         │
│  Component Re-render (props change)     │
│    └─ withTracking() called again      │
│        └─ ❌ V1: Create new instance   │ ⬅️ 第3个监听器
│        └─ ✅ V2: Reuse existing        │ ⬅️ 仍然是1个
│                                         │
└─────────────────────────────────────────┘

Result:
  V1: 3 个监听器 → 每次点击上报 3 次 ❌
  V2: 1 个监听器 → 每次点击上报 1 次 ✅

为什么 200ms 而不是 100ms

  1. 事件冒泡时间:

    • 实测TDesign Tooltip 的事件处理大约需要 50-150ms
    • 100ms 窗口可能不够覆盖所有情况
  2. React 合成事件:

    • React 的事件系统可能在多个阶段触发事件
    • 200ms 足够覆盖一次完整的事件周期
  3. 用户体验:

    • 人类反应时间 > 250ms
    • 200ms 不会影响正常点击
    • 但足够过滤技术性重复事件

热重载支持的必要性

// 开发环境场景
if (module.hot) {
  module.hot.dispose(() => {
    resetGlobalTrackingService(); // 清理旧实例
  });
}

为什么需要:

  1. 开发时热重载会重新执行模块代码
  2. 如果不清理,会累积多个实例
  3. dispose 钩子确保旧实例被销毁

后续优化

1. 性能监控

// tracking-service.ts
private stats = {
  totalEvents: 0,
  globalDebounced: 0,
  elementDebounced: 0,
};

// 添加到 getStats() 输出

2. 配置化防抖时间

interface TrackConfig {
  globalDebounceTime?: number; // 默认 200ms
  elementDebounceTime?: number; // 默认 500ms
}

3. 单元测试

describe('TrackingService Singleton', () => {
  it('should create only one instance', () => {
    const editor1 = withTracking(createEditor());
    const editor2 = withTracking(createEditor());

    expect(editor1.trackingService).toBe(editor2.trackingService);
  });

  it('should have only one event listener', () => {
    withTracking(createEditor());
    const listeners = getEventListeners(document.body);
    expect(listeners.click.length).toBe(1);
  });
});

常见问题

Q: 单例模式会不会导致配置无法更新?

A: 当前实现中,第一次调用的配置会被使用。如果需要动态配置:

// 方案1: 重置再初始化
resetGlobalTrackingService();
withTracking(editor, newConfig);

// 方案2: 动态更新配置(待实现)
trackingService.updateConfig(newConfig);

Q: 多个应用实例怎么办?

A: 当前单例是模块级别的,适用于单页应用。如果需要支持多应用实例:

// 使用 Symbol 作为唯一标识
const TRACKING_SERVICE_KEY = Symbol.for('global.trackingService');
(window as any)[TRACKING_SERVICE_KEY] = globalTrackingService;

Q: 测试环境如何处理?

A: 每个测试前调用 resetGlobalTrackingService():

beforeEach(() => {
  resetGlobalTrackingService();
});

总结

此次 V2 修复通过 单例模式 彻底解决了重复上报问题:

  1. 根本原因: 多个 TrackingService 实例 → 单例模式
  2. 增强防抖: 100ms → 200ms 窗口
  3. 调试支持: devMode 日志输出
  4. 热重载: 自动清理旧实例
  5. 性能优化: 1 个监听器 vs N 个

测试结果:

  • 每次点击只上报 1 次
  • onClick 功能正常工作
  • 无性能问题
  • 热重载正常

修改文件:

  • packages/drawnix/src/plugins/tracking/withTracking.ts (单例模式)
  • packages/drawnix/src/services/tracking/tracking-utils.ts (200ms 防抖 + 日志)
  • packages/drawnix/src/services/tracking/tracking-service.ts (传入 devMode)

修复类型: Critical Bug Fix 影响范围: 所有埋点事件 向后兼容: 完全兼容 破坏性变更: