文章

用户行为监控深度解析

用户行为监控深度解析

一句话概括

用户行为监控是通过埋点、采集和分析用户在页面上的浏览、点击、停留、搜索等交互数据,来还原用户行为轨迹的工程体系。它涵盖 PV/UV 统计、点击流追踪、热力图生成、事件埋点等核心技术,是产品数据驱动决策的基础设施。本文从埋点规范、SDK 设计到后端清洗全链路讲解,附完整可运行的埋点 SDK 与热力图生成代码。

背景与意义

“没有数据就没有发言权”——在互联网产品中,用户行为数据是产品经理做决策、运营做策略、开发做优化的唯一客观依据。一个典型的场景:上线一个新功能后转化率下降,是入口太深?按钮文案不对?加载太慢?没有行为数据,所有猜测都是空中楼阁。面试中,用户行为监控相关问题的出现频率仅次于性能优化,尤其是在数据和平台部门。典型问题如”设计一个前端埋点SDK”几乎是必考题,考察候选人对数据全链路的理解深度。

概念与定义

PV (Page View)

页面浏览量。用户每次打开一个页面记录一次 PV,同一用户重复打开累计。PV 反映页面被”看”的次数。

UV (Unique Visitor)

独立访客数。通过用户标识(Cookie、设备 ID、登录态)去重后的访问人数。UV 反映有多少个”人”来过。

点击流 (Clickstream)

用户从进入页面到离开的完整操作序列。点击流还原了用户的操作路径,是分析转化漏斗的基础。

热力图 (Heatmap)

通过颜色深浅可视化用户点击分布的工具。点击越密集的地方颜色越”热”(红/橙),越稀疏的地方越”冷”(蓝/绿)。

埋点 (Tracking/Event)

在代码中插入事件上报逻辑的行为。分为代码埋点、可视化埋点和无埋点三种方式。

核心知识点拆解

1. 用户标识与 PV/UV 精确统计

PV/UV 统计的核心挑战在于用户标识的准确性和跨设备识别。一个好的用户 ID 体系由多层组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// 用户身份标识系统 - 分层 ID 方案
class UserIdentity {
  constructor() {
    this.identities = {};
    this.init();
  }

  init() {
    // 1. 设备 ID(匿名标识,最底层)
    this.identities.deviceId = this.getOrCreateDeviceId();
    
    // 2. Session ID(一次会话,访问期间保持不变)
    this.identities.sessionId = this.generateSessionId();
    
    // 3. 登录用户 ID(实名标识,最高优先级)
    this.identities.userId = this.getUserId();
    
    // 4. 访问计数器
    this.visitCount = this.incrementVisitCount();
  }

  getOrCreateDeviceId() {
    const STORAGE_KEY = '_track_device_id';
    let deviceId = '';
    
    try {
      // 优先使用 localStorage
      deviceId = localStorage.getItem(STORAGE_KEY);
      if (!deviceId) {
        deviceId = this.generateUUID();
        localStorage.setItem(STORAGE_KEY, deviceId);
      }
    } catch (e) {
      // localStorage 不可用时(如 Safari 无痕模式),使用 cookie 兜底
      deviceId = this.getCookie(STORAGE_KEY);
      if (!deviceId) {
        deviceId = this.generateUUID();
        this.setCookie(STORAGE_KEY, deviceId, 365);
      }
    }
    
    return deviceId;
  }

  getUserId() {
    // 从业务系统中获取登录用户 ID
    try {
      // 检查全局变量
      if (window.__USER__ && window.__USER__.id) {
        return window.__USER__.id;
      }
      // 检查 cookie
      const uid = this.getCookie('user_id') ||
                  this.getCookie('uid') ||
                  this.getCookie('token');
      return uid || null;
    } catch (e) {
      return null;
    }
  }

  generateSessionId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  generateUUID() {
    // 简单的 UUID v4 实现
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
      const r = Math.random() * 16 | 0;
      const v = c === 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
    });
  }

  // PV 增量统计 - 包含页面路径和来源
  trackPageView(pageInfo) {
    const pvEvent = {
      event: 'pageview',
      deviceId: this.identities.deviceId,
      sessionId: this.identities.sessionId,
      userId: this.identities.userId,
      url: pageInfo.url || window.location.href,
      referrer: pageInfo.referrer || document.referrer,
      title: pageInfo.title || document.title,
      timestamp: Date.now(),
      visitCount: this.visitCount,
      // 页面来源类型
      sourceType: this.determineSourceType(),
    };
    
    return pvEvent;
  }

  determineSourceType() {
    const ref = document.referrer;
    if (!ref) return 'direct';    // 直接访问
    if (ref.includes(location.host)) return 'internal'; // 站内跳转
    if (ref.includes('google.')) return 'search-google';
    if (ref.includes('baidu.')) return 'search-baidu';
    if (ref.includes('zhihu.')) return 'social-zhihu';
    if (ref.includes('weibo.')) return 'social-weibo';
    return 'external';
  }

  incrementVisitCount() {
    const KEY = '_track_visit_count';
    let count = 1;
    try {
      const stored = localStorage.getItem(KEY);
      if (stored) {
        count = parseInt(stored, 10) + 1;
      }
      localStorage.setItem(KEY, count.toString());
    } catch (e) {
      // fallback: sessionStorage
      try {
        const stored = sessionStorage.getItem(KEY);
        if (stored) {
          count = parseInt(stored, 10) + 1;
        }
        sessionStorage.setItem(KEY, count.toString());
      } catch (e2) {}
    }
    return count;
  }

  getCookie(name) {
    const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
    return match ? match[2] : null;
  }

  setCookie(name, value, days) {
    const expires = new Date(Date.now() + days * 864e5).toUTCString();
    document.cookie = `${name}=${value};expires=${expires};path=/;SameSite=Lax`;
  }
}

// 使用示例
const uid = new UserIdentity();
const pageViewData = uid.trackPageView({
  url: '/products/123',
  referrer: '/category/phones',
  title: 'iPhone 16 Pro 详情页',
});
console.log('PV 数据:', pageViewData);

2. 事件埋点与点击流

事件埋点是最灵活的用户行为采集方式,能精确记录每一次操作。点击流则是事件的有序集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// 通用事件埋点系统
class EventTracker {
  constructor(options = {}) {
    this.appId = options.appId || 'default';
    this.version = options.version || '1.0.0';
    this.eventQueue = [];
    this.maxQueueSize = options.maxQueueSize || 20;
    this.reportInterval = options.reportInterval || 5000;
    this.autoFlushTimer = null;
    this.userIdentity = options.userIdentity || new UserIdentity();
    this.enabled = true;
    
    this.setupAutoFlush();
    this.setupBeforeUnload();
  }

  // 通用事件追踪
  track(eventName, properties = {}) {
    if (!this.enabled) return;
    
    const event = {
      event: eventName,
      appId: this.appId,
      version: this.version,
      deviceId: this.userIdentity.identities.deviceId,
      sessionId: this.userIdentity.identities.sessionId,
      userId: this.userIdentity.identities.userId,
      timestamp: Date.now(),
      pageUrl: window.location.href,
      pageTitle: document.title,
      // 设备与环境信息
      screen: `${window.screen.width}x${window.screen.height}`,
      lang: navigator.language,
      tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
      ua: navigator.userAgent.slice(0, 200),
      // 自定义属性
      ...properties,
    };
    
    this.eventQueue.push(event);
    
    // 达到批量阈值立即上报
    if (this.eventQueue.length >= this.maxQueueSize) {
      this.flush();
    }
    
    return event;
  }

  // 点击事件(自动采集元素信息)
  trackClick(element, customData = {}) {
    if (!element) return;
    
    // 获取元素的选择器路径
    const selector = this.getElementSelector(element);
    // 获取元素在页面中的位置
    const rect = element.getBoundingClientRect();
    
    return this.track('click', {
      targetTag: element.tagName.toLowerCase(),
      targetId: element.id || '',
      targetClass: element.className?.slice(0, 100) || '',
      targetText: element.textContent?.trim()?.slice(0, 50) || '',
      targetHref: element.href || '',
      selector,
      positionX: Math.round(rect.left + window.scrollX),
      positionY: Math.round(rect.top + window.scrollY),
      viewportX: Math.round(rect.left),
      viewportY: Math.round(rect.top),
      width: Math.round(rect.width),
      height: Math.round(rect.height),
      ...customData,
    });
  }

  // 获取元素的 CSS 选择器路径
  getElementSelector(element) {
    const parts = [];
    let current = element;
    
    while (current && current !== document.body && current !== document.documentElement) {
      let selector = current.tagName.toLowerCase();
      
      if (current.id) {
        selector = `#${current.id}`;
        parts.unshift(selector);
        break; // 有 ID 就足够定位了
      }
      
      if (current.className && typeof current.className === 'string') {
        const classes = current.className.trim().split(/\s+/).slice(0, 2);
        if (classes.length > 0 && classes[0] !== '') {
          selector += `.${classes.join('.')}`;
        }
      }
      
      // 添加兄弟元素索引
      const parent = current.parentElement;
      if (parent) {
        const siblings = Array.from(parent.children).filter(
          (s) => s.tagName === current.tagName
        );
        if (siblings.length > 1) {
          const index = siblings.indexOf(current) + 1;
          selector += `:nth-of-type(${index})`;
        }
      }
      
      parts.unshift(selector);
      current = current.parentElement;
    }
    
    return parts.join(' > ');
  }

  // 曝光事件(埋点元素进入视口)
  trackExposure(element, options = {}) {
    const { threshold = 0.5, once = true, name = 'exposure' } = options;
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
          this.track(name, {
            targetId: element.id || '',
            targetTag: element.tagName.toLowerCase(),
            intersectionRatio: Math.round(entry.intersectionRatio * 100),
            boundingRect: JSON.stringify(entry.boundingClientRect),
            ...options.properties,
          });
          
          if (once) {
            observer.unobserve(element);
          }
        }
      });
    }, { threshold });
    
    observer.observe(element);
    return observer;
  }

  // 页面停留时间追踪
  trackPageDuration() {
    const startTime = Date.now();
    
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        const duration = Date.now() - startTime;
        if (duration > 1000) { // 过滤掉小于 1s 的"闪退"
          this.track('page_duration', {
            duration,
            durationReadable: `${Math.floor(duration / 1000)}s`,
          });
        }
      }
    });
  }

  // 自动绑定全局点击
  autoBindClicks(options = {}) {
    const { filterFn, excludeSelectors } = options;
    const excludeSet = new Set(excludeSelectors || ['html', 'body']);
    
    document.addEventListener('click', (e) => {
      const target = e.target;
      
      // 过滤条件检查
      if (excludeSet.has(target.tagName.toLowerCase())) return;
      if (filterFn && !filterFn(target)) return;
      
      this.trackClick(target);
    }, true); // 使用捕获阶段以确保在冒泡前采集
  }

  setupAutoFlush() {
    this.autoFlushTimer = setInterval(() => {
      this.flush();
    }, this.reportInterval);
  }

  setupBeforeUnload() {
    window.addEventListener('beforeunload', () => {
      this.flush(true);
    });
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush(true);
      }
    });
  }

  // 上报事件队列
  flush(immediate = false) {
    if (this.eventQueue.length === 0) return;
    
    const batch = this.eventQueue.splice(0, this.maxQueueSize);
    const payload = {
      appId: this.appId,
      events: batch,
      batchId: `${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
    };
    
    this.send(payload, immediate);
  }

  send(data, immediate) {
    const body = JSON.stringify(data);
    
    if (immediate && navigator.sendBeacon) {
      navigator.sendBeacon('/api/track', body);
    } else {
      // 使用 fetch 上报,图片兜底
      fetch('/api/track', {
        method: 'POST',
        body,
        headers: { 'Content-Type': 'application/json' },
        keepalive: true,
      }).catch(() => {
        // fallback: gif 上报(1x1 透明图)
        const img = new Image();
        img.src = `/api/track.gif?data=${encodeURIComponent(body)}`;
      });
    }
  }

  destroy() {
    this.flush(true);
    clearInterval(this.autoFlushTimer);
    this.enabled = false;
  }
}

// 使用示例
const tracker = new EventTracker({ appId: 'mall' });
tracker.autoBindClicks({
  excludeSelectors: ['html', 'body', 'header'],
});
tracker.trackPageDuration();

3. 热力图生成原理

热力图的核心是采集用户的点击坐标,然后在后端聚合渲染。采集端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// 热力图数据采集模块
class HeatmapCollector {
  constructor(options = {}) {
    this.clicks = [];
    this.sampleRate = options.sampleRate || 0.3; // 30% 用户采集
    this.maxClicks = options.maxClicks || 1000;  // 单次上报上限
    this.scrollPositions = [];
    this.sessionStart = Date.now();
    this.pageWidth = document.documentElement.scrollWidth;
    this.pageHeight = document.documentElement.scrollHeight;
    
    // 是否采样
    if (Math.random() > this.sampleRate) {
      return; // 不记录热力图
    }
    
    this.bindEvents();
  }

  bindEvents() {
    // 点击坐标采集 - 使用捕获阶段确保拿到所有点击
    document.addEventListener('click', (e) => {
      if (this.clicks.length >= this.maxClicks) return;
      
      this.clicks.push({
        x: Math.round(e.pageX),   // 相对文档的坐标(含滚动)
        y: Math.round(e.pageY),
        vx: Math.round(e.clientX), // 相对视口的坐标
        vy: Math.round(e.clientY),
        t: Date.now() - this.sessionStart, // 距离会话开始的时间偏移
        w: window.innerWidth,     // 当前视口宽度
        h: window.innerHeight,    // 当前视口高度
        // 点击的目标元素信息(便于筛选分析)
        tag: e.target.tagName.toLowerCase(),
        id: e.target.id || '',
        cls: e.target.className?.slice(0, 60) || '',
      });
    }, true);

    // 滚动深度采集 - 记录页面的最大滚动深度
    let maxScroll = 0;
    const scrollThrottle = this.throttle(() => {
      const scrollTop = window.scrollY || document.documentElement.scrollTop;
      const scrollHeight = document.documentElement.scrollHeight;
      const viewportHeight = window.innerHeight;
      const scrollPercent = Math.round((scrollTop + viewportHeight) / scrollHeight * 100);
      
      if (scrollPercent > maxScroll) {
        maxScroll = scrollPercent;
      }
    }, 200);
    
    window.addEventListener('scroll', scrollThrottle);
    
    // 页面关闭时上报最大滚动深度
    window.addEventListener('beforeunload', () => {
      this.scrollPositions.push({
        maxScrollPercent: maxScroll,
        scrollHeight: document.documentElement.scrollHeight,
      });
    });
  }

  // 获取热力图上报数据
  getHeatmapData() {
    return {
      pageUrl: window.location.href,
      pageWidth: document.documentElement.scrollWidth,
      pageHeight: document.documentElement.scrollHeight,
      viewportWidth: window.innerWidth,
      viewportHeight: window.innerHeight,
      clicks: this.clicks,
      scrolls: this.scrollPositions,
      deviceType: this.getDeviceType(),
      timestamp: Date.now(),
    };
  }

  getDeviceType() {
    const ua = navigator.userAgent;
    if (/Mobile|Android|iPhone|iPad/i.test(ua)) return 'mobile';
    if (/Tablet|iPad/i.test(ua)) return 'tablet';
    return 'desktop';
  }

  throttle(fn, delay) {
    let timer = null;
    return function(...args) {
      if (timer) return;
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    };
  }
}

// 服务端热力图渲染伪代码(Node.js + Canvas)
// 这部分通常在后端运行,聚合所有用户的点击数据生成热力图图片
async function renderHeatmapImage(clickData, outputPath) {
  const { createCanvas, loadImage } = require('canvas');
  const width = clickData.pageWidth > 1920 ? 1920 : clickData.pageWidth;
  const height = Math.min(clickData.pageHeight, 10000); // 限制高度
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext('2d');
  
  // 1. 创建点击密度矩阵(高斯卷积用)
  const kernelSize = 40;
  const sigma = 15;
  const density = new Float32Array(width * height);
  
  // 将点击坐标映射到密度矩阵
  for (const click of clickData.clicks) {
    const px = Math.round(click.x * (width / clickData.pageWidth));
    const py = Math.round(click.y * (height / clickData.pageHeight));
    if (px >= 0 && px < width && py >= 0 && py < height) {
      density[py * width + px] += 1;
    }
  }
  
  // 2. 高斯模糊 - 简化的卷积实现
  const blurred = new Float32Array(width * height);
  const halfK = Math.floor(kernelSize / 2);
  
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let sum = 0;
      let weightSum = 0;
      
      for (let ky = -halfK; ky <= halfK; ky++) {
        for (let kx = -halfK; kx <= halfK; kx++) {
          const px = x + kx;
          const py = y + ky;
          if (px >= 0 && px < width && py >= 0 && py < height) {
            const dist = (kx * kx + ky * ky) / (2 * sigma * sigma);
            const weight = Math.exp(-dist);
            sum += density[py * width + px] * weight;
            weightSum += weight;
          }
        }
      }
      
      blurred[y * width + x] = weightSum > 0 ? sum / weightSum : 0;
    }
  }
  
  // 3. 颜色映射:低→蓝绿 → 中等→黄 → 高→红
  const maxVal = Math.max(...blurred) || 1;
  
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const normalized = blurred[y * width + x] / maxVal;
      if (normalized < 0.01) continue; // 跳过几乎无点击区域
      
      // 从蓝到红的热力图色阶
      let r, g, b;
      if (normalized < 0.25) {
        // 蓝 → 青
        const t = normalized / 0.25;
        r = 0;
        g = Math.round(t * 200);
        b = Math.round(200 + t * 55);
      } else if (normalized < 0.5) {
        // 青 → 黄
        const t = (normalized - 0.25) / 0.25;
        r = Math.round(t * 255);
        g = 200;
        b = Math.round(255 * (1 - t));
      } else if (normalized < 0.75) {
        // 黄 → 橙
        const t = (normalized - 0.5) / 0.25;
        r = 255;
        g = Math.round(200 * (1 - t * 0.5));
        b = 0;
      } else {
        // 橙 → 红
        const t = (normalized - 0.75) / 0.25;
        r = 255;
        g = Math.round(100 * (1 - t));
        b = 0;
      }
      
      ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.5)`;
      ctx.fillRect(x, y, 1, 1);
    }
  }
  
  // 保存为 PNG
  const fs = require('fs');
  const buffer = canvas.toBuffer('image/png');
  fs.writeFileSync(outputPath, buffer);
  console.log(`热力图已生成: ${outputPath}`);
}

4. 无埋点方案设计

无埋点(也称为全埋点/自动埋点)试图通过全局代理来捕获几乎所有用户交互,避免手动埋点的高成本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// 无埋点方案 - 自动采集一切用户行为
class AutoCapture {
  constructor(options = {}) {
    this.options = Object.assign({
      captureClicks: true,
      captureScroll: true,
      captureInput: true,
      captureRoute: true,
      captureResize: true,
      maxEvents: 500,
    }, options);
    
    this.eventCount = 0;
    this.listeners = [];
  }

  start() {
    if (this.options.captureClicks) this.captureClicks();
    if (this.options.captureScroll) this.captureScroll();
    if (this.options.captureInput) this.captureInput();
    if (this.options.captureRoute) this.captureRoute();
    if (this.options.captureResize) this.captureResize();
  }

  captureClicks() {
    const handler = (e) => {
      if (this.eventCount >= this.options.maxEvents) return;
      this.eventCount++;
      
      // 采集点击路径(从 target 到根节点的所有元素路径)
      const path = [];
      let element = e.target;
      while (element && element !== document) {
        path.push({
          tag: element.tagName.toLowerCase(),
          id: element.id || '',
          cls: element.className?.slice(0, 80) || '',
          text: element.textContent?.trim()?.slice(0, 30) || '',
          idx: this.getIndexAmongSiblings(element),
        });
        element = element.parentElement;
      }
      
      this.report('auto.click', {
        path,
        x: e.clientX,
        y: e.clientY,
        depth: path.length,
      });
    };
    
    document.addEventListener('click', handler, true);
    this.listeners.push(['click', handler, true]);
  }

  captureScroll() {
    let lastScrollY = 0;
    let scrollDepth = 0;
    let startTime = performance.now();
    
    const handler = () => {
      if (this.eventCount >= this.options.maxEvents) return;
      
      const now = performance.now();
      const currentY = window.scrollY;
      const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
      const percent = maxScroll > 0 ? Math.round((currentY / maxScroll) * 100) : 0;
      
      if (percent > scrollDepth && now - startTime > 200) {
        scrollDepth = percent;
        this.eventCount++;
        this.report('auto.scroll', {
          scrollPercent: percent,
          scrollY: currentY,
          scrollHeight: document.documentElement.scrollHeight,
          viewportHeight: window.innerHeight,
        });
        startTime = now;
      }
    };
    
    window.addEventListener('scroll', this.throttle(handler, 500), { passive: true });
  }

  captureInput() {
    const handler = (e) => {
      if (this.eventCount >= this.options.maxEvents) return;
      const tag = e.target.tagName.toLowerCase();
      if (tag !== 'input' && tag !== 'textarea' && tag !== 'select') return;
      
      // 采集表单交互(不包含密码等敏感字段)
      const inputType = e.target.type || '';
      if (inputType === 'password' || inputType === 'hidden') return;
      
      this.eventCount++;
      this.report('auto.input', {
        tag,
        name: e.target.name || '',
        id: e.target.id || '',
        type: inputType,
        value: tag === 'select' ? e.target.value : e.target.value?.slice(0, 100),
        charCount: e.target.value?.length || 0,
      });
    };
    
    document.addEventListener('change', handler, true);
    document.addEventListener('blur', handler, true);
    this.listeners.push(['change', handler, true]);
    this.listeners.push(['blur', handler, true]);
  }

  captureRoute() {
    // SPA 路由变化监听
    let lastUrl = location.href;
    
    // 1. popstate 事件(浏览器的前进/后退)
    window.addEventListener('popstate', () => {
      this.onRouteChange(lastUrl, location.href);
      lastUrl = location.href;
    });
    
    // 2. 劫持 pushState 和 replaceState
    const origPushState = history.pushState;
    const origReplaceState = history.replaceState;
    
    history.pushState = (...args) => {
      origPushState.apply(history, args);
      this.onRouteChange(lastUrl, location.href);
      lastUrl = location.href;
    };
    
    history.replaceState = (...args) => {
      origReplaceState.apply(history, args);
      this.onRouteChange(lastUrl, location.href);
      lastUrl = location.href;
    };
    
    // 3. hashchange
    window.addEventListener('hashchange', () => {
      this.onRouteChange(lastUrl, location.href);
      lastUrl = location.href;
    });
  }

  onRouteChange(from, to) {
    if (from === to) return;
    this.eventCount++;
    this.report('auto.route', {
      from,
      to,
      routeType: to.includes('#') ? 'hash' : 'history',
      timestamp: Date.now(),
    });
  }

  captureResize() {
    let lastWidth = window.innerWidth;
    let lastHeight = window.innerHeight;
    
    const handler = this.throttle(() => {
      this.eventCount++;
      this.report('auto.resize', {
        fromWidth: lastWidth,
        fromHeight: lastHeight,
        toWidth: window.innerWidth,
        toHeight: window.innerHeight,
        deviceType: window.innerWidth < 768 ? 'mobile' : 
                    window.innerWidth < 1024 ? 'tablet' : 'desktop',
      });
      lastWidth = window.innerWidth;
      lastHeight = window.innerHeight;
    }, 1000);
    
    window.addEventListener('resize', handler);
  }

  getIndexAmongSiblings(el) {
    const parent = el.parentElement;
    if (!parent) return 0;
    return Array.from(parent.children).indexOf(el);
  }

  report(eventName, data) {
    // 组装并发送
    const payload = {
      event: eventName,
      timestamp: Date.now(),
      url: window.location.href,
      data,
    };
    
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/auto-capture', JSON.stringify(payload));
    }
  }

  throttle(fn, delay) {
    let timer = null;
    return (...args) => {
      if (timer) return;
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    };
  }

  destroy() {
    this.listeners.forEach(([event, handler, capture]) => {
      document.removeEventListener(event, handler, capture);
    });
  }
}

实战案例:完整的行为监控 SDK

以下是一个集成了 PV/UV、事件埋点、点击流和热力图采集的生产级 SDK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// BehaviorAnalytics - 用户行为监控全栈 SDK
(function (global) {
  'use strict';

  class BehaviorAnalytics {
    constructor(config) {
      this.config = Object.assign({
        appId: 'default',
        apiEndpoint: '/api/behavior',
        pvSampleRate: 1,          // PV 100% 采集
        eventSampleRate: 0.5,     // 事件 50% 采样
        heatmapEnabled: true,
        heatmapSampleRate: 0.1,   // 热力图 10% 采样(数据量大)
        autoCapture: true,
        maxEventsPerBatch: 30,
        reportInterval: 10000,
        debug: false,
      }, config);

      this.sessionId = this.generateId();
      this.startTime = Date.now();
      this.eventBuffer = [];
      this.heatmapCollector = null;
      this.isActive = true;
      
      this.init();
    }

    init() {
      this.log('[BA] 初始化行为监控, appId:', this.config.appId);
      
      // 1. 采集设备与环境信息
      this.deviceInfo = this.collectDeviceInfo();
      
      // 2. 上报 PV
      this.trackPV();
      
      // 3. 初始化热力图(如果启用)
      if (this.config.heatmapEnabled && Math.random() < this.config.heatmapSampleRate) {
        this.heatmapCollector = new HeatmapCollector({ sampleRate: 1 });
      }
      
      // 4. 自动事件采集
      if (this.config.autoCapture) {
        this.setupAutoCapture();
      }
      
      // 5. 定时与卸载上报
      this.flushTimer = setInterval(() => this.flush(), this.config.reportInterval);
      window.addEventListener('beforeunload', () => this.flush(true));
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') this.flush(true);
      });
    }

    collectDeviceInfo() {
      return {
        screenWidth: window.screen.width,
        screenHeight: window.screen.height,
        viewportWidth: window.innerWidth,
        viewportHeight: window.innerHeight,
        devicePixelRatio: window.devicePixelRatio,
        colorDepth: window.screen.colorDepth,
        language: navigator.language,
        platform: navigator.platform || '',
        connection: navigator.connection?.effectiveType || 'unknown',
        touchPoints: navigator.maxTouchPoints || 0,
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      };
    }

    trackPV() {
      if (Math.random() > this.config.pvSampleRate) return;
      
      const pvData = {
        type: 'pv',
        appId: this.config.appId,
        sessionId: this.sessionId,
        url: window.location.href,
        referrer: document.referrer || '',
        title: document.title,
        timestamp: Date.now(),
        screenWidth: window.innerWidth,
        screenHeight: window.innerHeight,
        deviceInfo: this.deviceInfo,
        source: this.getSource(),
      };
      
      this.eventBuffer.push(pvData);
      this.log('[BA] PV:', pvData.url);
    }

    getSource() {
      const ref = document.referrer;
      if (!ref) return 'direct';
      if (ref.includes(location.host)) return 'internal';
      const params = new URLSearchParams(location.search);
      if (params.get('utm_source')) return params.get('utm_source');
      if (params.get('source')) return params.get('source');
      if (params.get('from')) return params.get('from');
      return 'external';
    }

    // 手动事件追踪
    trackEvent(category, action, label, value, extras) {
      if (!this.isActive) return;
      if (Math.random() > this.config.eventSampleRate) return;
      
      const event = {
        type: 'event',
        appId: this.config.appId,
        sessionId: this.sessionId,
        category,
        action,
        label: label || '',
        value: value || 0,
        url: window.location.href,
        timestamp: Date.now(),
        pageLoadTime: Date.now() - this.startTime,
        extras: extras || {},
      };
      
      this.eventBuffer.push(event);
      this.log('[BA] 事件:', category, action, label);
      
      if (this.eventBuffer.length >= this.config.maxEventsPerBatch) {
        this.flush();
      }
    }

    setupAutoCapture() {
      // 自动捕获点击事件
      document.addEventListener('click', (e) => {
        if (!this.isActive) return;
        
        const target = e.target;
        // 排除 body/html
        if (['html', 'body', 'script', 'style'].includes(target.tagName.toLowerCase())) return;
        
        this.trackEvent('auto_click', target.tagName.toLowerCase(), 
          target.textContent?.trim()?.slice(0, 30) || target.id || '',
          0, {
            href: target.href || '',
            className: target.className?.slice(0, 50) || '',
            x: e.clientX,
            y: e.clientY,
          });
      }, { capture: true });

      // 自动捕获表单提交
      document.addEventListener('submit', (e) => {
        if (!this.isActive) return;
        const form = e.target;
        this.trackEvent('auto_form_submit', form.id || form.name || 'unknown', 
          form.action || '', 0, {
            fields: form.elements.length,
          });
      }, true);
    }

    // 用户画像更新
    setUserProfile(profile) {
      this.userProfile = {
        ...profile,
        updatedAt: Date.now(),
      };
      this.log('[BA] 用户画像更新:', profile);
    }

    // 批量上报
    flush(immediate = false) {
      if (this.eventBuffer.length === 0) return;
      
      const batch = this.eventBuffer.splice(0, this.config.maxEventsPerBatch);
      const payload = {
        appId: this.config.appId,
        sessionId: this.sessionId,
        events: batch,
        batchId: this.generateId(),
        timestamp: Date.now(),
        userProfile: this.userProfile || null,
        // 附带热力图数据
        heatmap: this.heatmapCollector?.getHeatmapData() || null,
      };
      
      // 控制 payload 大小(限制 50KB)
      const body = JSON.stringify(payload);
      if (body.length > 50 * 1024) {
        this.log('[BA] ⚠️ 数据超限,丢弃部分数据');
        return;
      }
      
      if (immediate && navigator.sendBeacon) {
        navigator.sendBeacon(this.config.apiEndpoint, body);
      } else {
        fetch(this.config.apiEndpoint, {
          method: 'POST',
          body,
          keepalive: immediate,
          headers: { 'Content-Type': 'application/json' },
        }).catch(() => {});
      }
      
      this.log(`[BA] 📤 已上报 ${batch.length} 条事件`);
    }

    generateId() {
      return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    }

    log(...args) {
      if (this.config.debug) console.log(...args);
    }

    destroy() {
      this.flush(true);
      clearInterval(this.flushTimer);
      this.isActive = false;
    }
  }

  // 导出
  global.BehaviorAnalytics = BehaviorAnalytics;
})(window);

底层原理

用户追踪的标识断裂问题

跨域跟踪的挑战在于:浏览器 SameSite Cookie 默认 Lax 模式,导致第三方嵌入场景下无法读取上层 cookie。解决方案包括:

  • First-Party Cookie + 子域名共置:将埋点域名与业务域名置于同一主域下(如 track.example.com vs www.example.com
  • URL 参数传递:页面间通过 URL 参数传递用户标识
  • 浏览器 Fingerprint 方案:利用 Canvas Fingerprint / AudioContext / WebGL 等生成设备指纹

IntersectionObserver 与曝光埋点的性能优势

传统曝光检测通过 getBoundingClientRect() + scroll 事件实现,每次滚动都要进行大量的 DOM 计算。IntersectionObserver 由浏览器底层实现,在合成线程层面进行交叉计算,完全不占用主线程。当 target 进入视口时,回调才在主线程上触发。

GIF 上报的妙用

new Image().src = '/track.gif?data=...' 上报数据之所以流行,是因为 Image 请求不受跨域限制(允许跨域 GET),且不会被浏览器拦截。缺点是只能发送 GET 请求,payload 长度受 URL 长度限制(约 2KB)。因此 GIF 上报适合轻量级事件,大 payload 应使用 sendBeacon 或 fetch POST。

高频面试题解析

面试题 1:前端埋点 SDK 设计需要考虑哪些核心要素?

答案要点: 1)用户标识体系(设备 ID + Session ID + User ID 三层);2)事件模型(事件名、属性、时间戳、公共属性);3)采样策略(按事件类型差异化采样);4)上报策略(批量聚合 + 定时上报 + 卸载前保活);5)去重机制(基于事件哈希防止重复上报);6)错误处理(网络失败重试、队列溢出降级);7)性能开销控制(内存限制、主线程影响最小化)。

面试题 2:如何区分 PV 和 UV?UV 的精确度受什么影响?

答案要点: PV 是页面浏览次数,UV 是独立访客数——通常通过 Cookie 或 localStorage 生成唯一设备 ID 来识别。UV 精确度受以下因素影响:1)用户清除 Cookie/localStorage;2)浏览器无痕模式下 localStorage 不可用;3)同一用户多设备访问(PC + 手机算两个 UV);4)不同浏览器的 Fingerprint 差异。提升 UV 准确度的方法包括:登录态用户通过 UserID 去重 + 非登录态通过设备指纹做模糊匹配。

面试题 3:如何实现 SPA 的 PV 统计?

答案要点: SPA 的 PV 统计不能依赖 window.onload,因为页面只加载一次。方案:1)劫持 history.pushStatehistory.replaceState;2)监听 popstatehashchange 事件;3)与前端路由库集成(React Router 的 onRouteChange、Vue Router 的 afterEach 钩子)。每次路由变更时主动调用 trackPV(),并将 referrer 设为当前 url。

面试题 4:埋点数据量太大怎么办?如何控制对用户性能的影响?

答案要点: 1)前端采样——按事件类型配置采样率(关键事件 100%,日志事件 1%);2)批量合并——积累到一定数量再上报,减少 HTTP 请求数;3)数据压缩——上报前对 JSON 做字段精简(短字段名);4)sendBeacon + requestIdleCallback——在浏览器空闲时上报,不影响交互响应;5)队列上限控制——设置最大缓存事件数,超限时丢弃最早的事件(LRU 策略)。

面试题 5:点击流还原的难度在哪里?如何保证事件的有序性?

答案要点: 难度在于:1)时间戳精度——同一用户同时产生多个事件时,JavaScript 的 Date.now() 在微任务中可能相同,需要增加序号计数器(Sequence ID);2)客户端时间偏移——用户系统时间不准时,需服务端统一时间戳;3)事件丢失——网络抖动导致部分事件未到达,造成”路径断裂”。解决方案:每个事件带客户端序号(seq)+ 服务端接收时间戳(serverTime),排序时先按 seq 排序,若缺失则根据 serverTime 插值。

总结与扩展

知识体系图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
用户行为监控
├── 基础统计
│   ├── PV(页面浏览量,可累加)
│   └── UV(独立访客数,去重)
├── 事件追踪
│   ├── 代码埋点(手动 insert track 代码)
│   ├── 可视化埋点(后台圈选)
│   └── 无埋点(全量自动采集)
├── 点击流
│   ├── 事件序列(按时间排序的用户行为)
│   ├── 漏斗分析(完成每一步的用户占比)
│   └── 路径分析(用户常见操作模式)
├── 热力图
│   ├── 点击热力图(点击坐标聚合)
│   ├── 注意力热力图(视线追踪降级方案)
│   └── 滚动热力图(折叠页浏览深度)
└── 数据管道
    ├── 采集端(SDK + 采样 + 压缩)
    ├── 传输层(批量 + 保活 + 压缩)
    ├── 服务端(校验 + 清洗 + 存储)
    └── 分析层(聚合 + 可视化 + 报表)

延伸阅读

  1. Google Analytics 4 埋点文档: 理解行业标准的埋点模型设计
  2. GrowingIO 无埋点技术原理: 国内无埋点方案的先驱
  3. Sensors Analytics 埋点规范: 神策数据的埋点设计最佳实践
  4. Canvas 热力图渲染: MDN Canvas API 与高斯模糊算法
  5. clickhouse 行为数据分析: OLAP 引擎在海量行为数据中的应用
本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量