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

366 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bug Fix V2: 重复事件上报 - 单例模式修复
**日期**: 2025-12-05
**版本**: V2 (最终修复)
**问题**: 同一事件仍然上报2次`toolbar_click_hand``chat_click_drawer_close`
## 问题根因
### V1 修复的不足
在 V1 修复中,我们:
1. ✅ 移除了 `stopPropagation()`,解决了 onClick 失效
2. ✅ 添加了全局防抖机制100ms
**仍然有重复上报**,根本原因是:
### 多个 TrackingService 实例
```typescript
// ❌ 问题代码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 次上报
**验证**:
```javascript
// 在控制台运行
const listeners = getEventListeners(document.body);
console.log('Click listeners count:', listeners.click?.length);
// 如果有重复上报,这里会显示 > 1
```
## V2 解决方案
### 修复 1: 单例模式
**修改文件**: `packages/drawnix/src/plugins/tracking/withTracking.ts`
```typescript
// ✅ 修复后:单例模式
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`
```typescript
// ✅ 修复后
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`
```typescript
// ✅ 所有防抖检查都传入 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: 控制台检查监听器数量
```javascript
// 在浏览器控制台运行
const listeners = getEventListeners(document.body);
console.log('Click listeners:', listeners.click?.length);
// 预期输出: 1
```
### 方法 2: 启用调试模式
`withTracking` 配置中启用 devMode:
```typescript
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 不会影响正常点击
- 但足够过滤技术性重复事件
### 热重载支持的必要性
```typescript
// 开发环境场景
if (module.hot) {
module.hot.dispose(() => {
resetGlobalTrackingService(); // 清理旧实例
});
}
```
**为什么需要**:
1. 开发时热重载会重新执行模块代码
2. 如果不清理,会累积多个实例
3. `dispose` 钩子确保旧实例被销毁
## 后续优化
### 1. 性能监控
```typescript
// tracking-service.ts
private stats = {
totalEvents: 0,
globalDebounced: 0,
elementDebounced: 0,
};
// 添加到 getStats() 输出
```
### 2. 配置化防抖时间
```typescript
interface TrackConfig {
globalDebounceTime?: number; // 默认 200ms
elementDebounceTime?: number; // 默认 500ms
}
```
### 3. 单元测试
```typescript
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: 当前实现中,第一次调用的配置会被使用。如果需要动态配置:
```typescript
// 方案1: 重置再初始化
resetGlobalTrackingService();
withTracking(editor, newConfig);
// 方案2: 动态更新配置(待实现)
trackingService.updateConfig(newConfig);
```
### Q: 多个应用实例怎么办?
A: 当前单例是模块级别的,适用于单页应用。如果需要支持多应用实例:
```typescript
// 使用 Symbol 作为唯一标识
const TRACKING_SERVICE_KEY = Symbol.for('global.trackingService');
(window as any)[TRACKING_SERVICE_KEY] = globalTrackingService;
```
### Q: 测试环境如何处理?
A: 每个测试前调用 `resetGlobalTrackingService()`:
```typescript
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
**影响范围**: 所有埋点事件
**向后兼容**: ✅ 完全兼容
**破坏性变更**: ❌ 无