返回笔记首页

17.3 前端埋点系统

主题配置

一、技术实现方案

1.1 埋点系统架构

plain
埋点系统架构
  ├── 数据采集层
  │   ├── 页面浏览埋点 (PV/UV)
  │   ├── 用户行为埋点 (点击/输入/滚动)
  │   ├── 事件埋点 (自定义事件)
  │   └── 曝光埋点 (元素可见性)
  │
  ├── 数据处理层
  │   ├── 数据校验
  │   ├── 数据加工
  │   ├── 数据缓存
  │   └── 数据压缩
  │
  ├── 数据上报层
  │   ├── 实时上报
  │   ├── 批量上报
  │   ├── 延迟上报
  │   └── 离线缓存
  │
  └── 数据分析层
      ├── 用户路径分析
      ├── 转化率分析
      ├── 热力图分析
      └── 漏斗分析

1.2 技术栈

  • 埋点采集: MutationObserver, IntersectionObserver
  • 数据存储: localStorage, IndexedDB
  • 数据上报: Beacon API, fetch
  • 压缩算法: pako (gzip)

二、用户行为埋点

2.1 行为埋点SDK

tracker.js

javascript
export class BehaviorTracker {
  constructor(options = {}) {
    this.appId = options.appId
    this.userId = options.userId
    this.reportUrl = options.reportUrl
    this.enableAutoTrack = options.enableAutoTrack !== false

    this.eventQueue = []
    this.maxQueueSize = options.maxQueueSize || 10
    this.reportInterval = options.reportInterval || 5000

    this.init()
  }

  init() {
    // 自动采集基础信息
    this.collectBaseInfo()

    // 自动埋点
    if (this.enableAutoTrack) {
      this.setupAutoTrack()
    }

    // 定时上报
    this.startAutoReport()

    // 页面卸载时上报
    this.setupUnloadReport()
  }

  // 采集基础信息
  collectBaseInfo() {
    this.baseInfo = {
      appId: this.appId,
      userId: this.userId || this.generateUserId(),
      sessionId: this.generateSessionId(),
      deviceId: this.getDeviceId(),
      platform: this.getPlatform(),
      browser: this.getBrowser(),
      screenResolution: `${window.screen.width}x${window.screen.height}`,
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      language: navigator.language,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      userAgent: navigator.userAgent
    }
  }

  // 自动埋点
  setupAutoTrack() {
    // 点击事件
    document.addEventListener('click', (e) => {
      this.trackClick(e)
    }, true)

    // 页面停留时间
    this.trackPageStay()

    // 页面滚动
    this.trackScroll()
  }

  // 追踪点击事件
  trackClick(event) {
    const target = event.target
    const tagName = target.tagName.toLowerCase()

    // 收集元素信息
    const elementInfo = {
      tagName: tagName,
      id: target.id,
      className: target.className,
      text: this.getElementText(target),
      xpath: this.getXPath(target),
      position: {
        x: event.clientX,
        y: event.clientY
      }
    }

    // 特殊处理按钮和链接
    if (tagName === 'button' || tagName === 'a') {
      elementInfo.buttonText = target.innerText
      if (tagName === 'a') {
        elementInfo.href = target.href
      }
    }

    this.track('click', elementInfo)
  }

  // 追踪页面停留
  trackPageStay() {
    this.pageEnterTime = Date.now()

    // 页面可见性变化
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        const stayTime = Date.now() - this.pageEnterTime
        this.track('page_leave', {
          url: window.location.href,
          stayTime: stayTime
        })
      } else {
        this.pageEnterTime = Date.now()
      }
    })
  }

  // 追踪滚动
  trackScroll() {
    let scrollTimer = null
    let maxScrollDepth = 0

    window.addEventListener('scroll', () => {
      const scrollDepth = Math.round(
        (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
      )

      maxScrollDepth = Math.max(maxScrollDepth, scrollDepth)

      clearTimeout(scrollTimer)
      scrollTimer = setTimeout(() => {
        this.track('scroll', {
          depth: scrollDepth,
          maxDepth: maxScrollDepth,
          scrollY: window.scrollY
        })
      }, 500)
    })
  }

  // 通用埋点方法
  track(eventName, eventData = {}) {
    const trackData = {
      ...this.baseInfo,
      eventName: eventName,
      eventData: eventData,
      timestamp: Date.now(),
      url: window.location.href,
      referrer: document.referrer
    }

    this.eventQueue.push(trackData)

    console.log('Track event:', trackData)

    // 队列满了立即上报
    if (this.eventQueue.length >= this.maxQueueSize) {
      this.report()
    }
  }

  // 上报数据
  report() {
    if (this.eventQueue.length === 0) return

    const data = [...this.eventQueue]
    this.eventQueue = []

    if (navigator.sendBeacon) {
      const success = navigator.sendBeacon(
        this.reportUrl,
        JSON.stringify(data)
      )

      if (!success) {
        console.warn('Beacon report failed, use fetch')
        this.reportByFetch(data)
      }
    } else {
      this.reportByFetch(data)
    }
  }

  // Fetch上报
  reportByFetch(data) {
    fetch(this.reportUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
      keepalive: true
    }).catch(err => {
      console.error('Report failed:', err)
      // 保存到localStorage待下次重试
      this.saveToCache(data)
    })
  }

  // 保存到缓存
  saveToCache(data) {
    try {
      const cached = JSON.parse(localStorage.getItem('tracker_cache') || '[]')
      cached.push(...data)
      localStorage.setItem('tracker_cache', JSON.stringify(cached.slice(-100)))
    } catch (e) {
      console.error('Save to cache failed:', e)
    }
  }

  // 启动自动上报
  startAutoReport() {
    setInterval(() => {
      this.report()
    }, this.reportInterval)
  }

  // 页面卸载时上报
  setupUnloadReport() {
    window.addEventListener('beforeunload', () => {
      this.report()
    })
  }

  // 获取元素文本
  getElementText(element) {
    const text = element.innerText || element.textContent || ''
    return text.trim().substring(0, 50)
  }

  // 获取XPath
  getXPath(element) {
    if (element.id) {
      return `//*[@id="${element.id}"]`
    }

    const parts = []
    while (element && element.nodeType === Node.ELEMENT_NODE) {
      let index = 0
      let sibling = element.previousSibling

      while (sibling) {
        if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
          index++
        }
        sibling = sibling.previousSibling
      }

      const tagName = element.nodeName.toLowerCase()
      const pathIndex = index ? `[${index + 1}]` : ''
      parts.unshift(tagName + pathIndex)

      element = element.parentNode
    }

    return parts.length ? '/' + parts.join('/') : ''
  }

  // 生成用户ID
  generateUserId() {
    let userId = localStorage.getItem('tracker_user_id')
    if (!userId) {
      userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
      localStorage.setItem('tracker_user_id', userId)
    }
    return userId
  }

  // 生成会话ID
  generateSessionId() {
    return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }

  // 获取设备ID
  getDeviceId() {
    let deviceId = localStorage.getItem('tracker_device_id')
    if (!deviceId) {
      deviceId = `device_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
      localStorage.setItem('tracker_device_id', deviceId)
    }
    return deviceId
  }

  // 获取平台
  getPlatform() {
    const ua = navigator.userAgent
    if (/Android/i.test(ua)) return 'Android'
    if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS'
    if (/Windows/i.test(ua)) return 'Windows'
    if (/Mac/i.test(ua)) return 'Mac'
    if (/Linux/i.test(ua)) return 'Linux'
    return 'Unknown'
  }

  // 获取浏览器
  getBrowser() {
    const ua = navigator.userAgent
    if (/Chrome/i.test(ua) && !/Edge/i.test(ua)) return 'Chrome'
    if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari'
    if (/Firefox/i.test(ua)) return 'Firefox'
    if (/Edge/i.test(ua)) return 'Edge'
    if (/MSIE|Trident/i.test(ua)) return 'IE'
    return 'Unknown'
  }
}

2.2 埋点管理组件

TrackerManager.vue

vue
<script setup>
import { ref, onMounted, computed } from 'vue'
import { BehaviorTracker } from './tracker.js'

const tracker = ref(null)
const events = ref([])
const filterType = ref('all')
const isTracking = ref(false)

// 统计信息
const statistics = computed(() => {
  const stats = {
    total: events.value.length,
    byType: {}
  }

  events.value.forEach(event => {
    const type = event.eventName
    stats.byType[type] = (stats.byType[type] || 0) + 1
  })

  return stats
})

// 过滤后的事件
const filteredEvents = computed(() => {
  if (filterType.value === 'all') {
    return events.value
  }
  return events.value.filter(e => e.eventName === filterType.value)
})

// 初始化埋点
const initTracker = () => {
  tracker.value = new BehaviorTracker({
    appId: 'demo-app',
    userId: 'demo-user',
    reportUrl: '/api/tracker',
    enableAutoTrack: true,
    maxQueueSize: 5,
    reportInterval: 10000
  })

  // 拦截track方法以显示埋点
  const originalTrack = tracker.value.track.bind(tracker.value)
  tracker.value.track = function(eventName, eventData) {
    events.value.unshift({
      id: Date.now() + Math.random(),
      eventName,
      eventData,
      timestamp: Date.now()
    })

    if (events.value.length > 100) {
      events.value.pop()
    }

    return originalTrack(eventName, eventData)
  }

  isTracking.value = true
}

// 手动埋点示例
const trackCustomEvent = () => {
  tracker.value.track('custom_button_click', {
    buttonName: '自定义按钮',
    source: 'demo'
  })
}

// 格式化时间
const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

// 获取事件类型颜色
const getEventColor = (eventName) => {
  const colors = {
    click: '#409eff',
    page_view: '#67c23a',
    page_leave: '#e6a23c',
    scroll: '#909399',
    custom_button_click: '#f56c6c'
  }
  return colors[eventName] || '#c0c4cc'
}

onMounted(() => {
  initTracker()
})
</script>

<template>
  <div class="tracker-manager">
    <div class="manager-header">
      <h2>埋点管理系统</h2>
      <div class="header-actions">
        <button @click="trackCustomEvent" class="custom-btn">
          触发自定义埋点
        </button>
        <div class="status-badge" :class="{ active: isTracking }">
          {{ isTracking ? '追踪中' : '已停止' }}
        </div>
      </div>
    </div>

    <!-- 统计卡片 -->
    <div class="statistics-section">
      <div class="stat-card total">
        <div class="stat-icon">📊</div>
        <div class="stat-content">
          <div class="stat-label">总事件数</div>
          <div class="stat-value">{{ statistics.total }}</div>
        </div>
      </div>

      <div class="event-stats">
        <div
          v-for="(count, type) in statistics.byType"
          :key="type"
          class="event-stat-item"
        >
          <div class="event-type" :style="{ color: getEventColor(type) }">
            {{ type }}
          </div>
          <div class="event-count">{{ count }}</div>
        </div>
      </div>
    </div>

    <!-- 过滤器 -->
    <div class="filter-section">
      <div class="filter-tabs">
        <button
          :class="{ active: filterType === 'all' }"
          @click="filterType = 'all'"
        >
          全部
        </button>
        <button
          :class="{ active: filterType === 'click' }"
          @click="filterType = 'click'"
        >
          点击事件
        </button>
        <button
          :class="{ active: filterType === 'scroll' }"
          @click="filterType = 'scroll'"
        >
          滚动事件
        </button>
        <button
          :class="{ active: filterType === 'page_leave' }"
          @click="filterType = 'page_leave'"
        >
          页面离开
        </button>
      </div>
    </div>

    <!-- 事件列表 -->
    <div class="event-list">
      <div
        v-for="event in filteredEvents"
        :key="event.id"
        class="event-item"
      >
        <div class="event-header">
          <span
            class="event-type-badge"
            :style="{
              background: getEventColor(event.eventName) + '20',
              color: getEventColor(event.eventName)
            }"
          >
            {{ event.eventName }}
          </span>
          <span class="event-time">{{ formatTime(event.timestamp) }}</span>
        </div>

        <div class="event-data">
          <pre>{{ JSON.stringify(event.eventData, null, 2) }}</pre>
        </div>
      </div>

      <div v-if="filteredEvents.length === 0" class="empty-list">
        <div class="empty-text">暂无事件记录</div>
        <div class="empty-hint">点击页面元素或滚动页面会自动记录埋点</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.tracker-manager {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.manager-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.manager-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.custom-btn {
  padding: 8px 20px;
  background: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.status-badge {
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  background: #909399;
  color: white;
}

.status-badge.active {
  background: #67c23a;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

.statistics-section {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 20px;
  margin-bottom: 24px;
}

.stat-card {
  padding: 24px;
  background: white;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 20px;
}

.stat-card.total {
  border-left: 4px solid #409eff;
}

.stat-icon {
  font-size: 48px;
}

.stat-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-value {
  font-size: 36px;
  font-weight: 700;
  color: #303133;
}

.event-stats {
  background: white;
  border-radius: 8px;
  padding: 20px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
}

.event-stat-item {
  padding: 16px;
  border: 2px solid #f0f0f0;
  border-radius: 8px;
  text-align: center;
}

.event-type {
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 8px;
}

.event-count {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
}

.filter-section {
  padding: 16px 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 16px;
}

.filter-tabs {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.filter-tabs button {
  padding: 8px 20px;
  background: #f5f7fa;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: #606266;
  transition: all 0.3s;
}

.filter-tabs button.active {
  background: #409eff;
  color: white;
}

.event-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.event-item {
  padding: 16px;
  background: white;
  border-radius: 8px;
  border-left: 4px solid #409eff;
}

.event-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.event-type-badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
}

.event-time {
  font-size: 12px;
  color: #c0c4cc;
}

.event-data {
  background: #f5f7fa;
  border-radius: 4px;
  padding: 12px;
}

.event-data pre {
  margin: 0;
  font-size: 12px;
  line-height: 1.6;
  color: #606266;
  overflow-x: auto;
}

.empty-list {
  padding: 80px 20px;
  text-align: center;
  background: white;
  border-radius: 8px;
}

.empty-text {
  font-size: 16px;
  color: #909399;
  margin-bottom: 8px;
}

.empty-hint {
  font-size: 13px;
  color: #c0c4cc;
}
</style>

三、页面访问统计

3.1 PV/UV统计

page-tracker.js

javascript
export class PageTracker {
  constructor(options = {}) {
    this.appId = options.appId
    this.reportUrl = options.reportUrl

    this.init()
  }

  init() {
    this.trackPageView()
    this.trackPagePerformance()
    this.trackPageSource()
  }

  // 追踪页面浏览
  trackPageView() {
    const pageData = {
      type: 'page_view',
      url: window.location.href,
      path: window.location.pathname,
      title: document.title,
      referrer: document.referrer,
      timestamp: Date.now(),

      // 用户信息
      userId: this.getUserId(),
      sessionId: this.getSessionId(),

      // 设备信息
      screenWidth: window.screen.width,
      screenHeight: window.screen.height,
      viewportWidth: window.innerWidth,
      viewportHeight: window.innerHeight,

      // 浏览器信息
      userAgent: navigator.userAgent,
      language: navigator.language,

      // 来源分析
      source: this.analyzeSource()
    }

    this.report(pageData)
  }

  // 追踪页面性能
  trackPagePerformance() {
    window.addEventListener('load', () => {
      setTimeout(() => {
        const timing = performance.timing

        const performanceData = {
          type: 'page_performance',
          url: window.location.href,

          // 关键指标
          dns: timing.domainLookupEnd - timing.domainLookupStart,
          tcp: timing.connectEnd - timing.connectStart,
          ttfb: timing.responseStart - timing.requestStart,
          domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
          loadComplete: timing.loadEventEnd - timing.navigationStart,

          timestamp: Date.now()
        }

        this.report(performanceData)
      }, 0)
    })
  }

  // 追踪页面来源
  trackPageSource() {
    const source = this.analyzeSource()

    if (source.type !== 'direct') {
      const sourceData = {
        type: 'page_source',
        url: window.location.href,
        source: source,
        timestamp: Date.now()
      }

      this.report(sourceData)
    }
  }

  // 分析来源
  analyzeSource() {
    const referrer = document.referrer
    const url = new URL(window.location.href)

    // 直接访问
    if (!referrer) {
      return { type: 'direct' }
    }

    // 获取URL参数
    const utmSource = url.searchParams.get('utm_source')
    const utmMedium = url.searchParams.get('utm_medium')
    const utmCampaign = url.searchParams.get('utm_campaign')

    // UTM参数
    if (utmSource) {
      return {
        type: 'utm',
        source: utmSource,
        medium: utmMedium,
        campaign: utmCampaign
      }
    }

    // 搜索引擎
    const searchEngines = {
      'google': 'Google',
      'bing': 'Bing',
      'baidu': 'Baidu',
      'yahoo': 'Yahoo'
    }

    for (const [key, name] of Object.entries(searchEngines)) {
      if (referrer.includes(key)) {
        return {
          type: 'search',
          engine: name,
          referrer: referrer
        }
      }
    }

    // 社交媒体
    const socialMedia = {
      'facebook': 'Facebook',
      'twitter': 'Twitter',
      'linkedin': 'LinkedIn',
      'weibo': 'Weibo',
      'wechat': 'WeChat'
    }

    for (const [key, name] of Object.entries(socialMedia)) {
      if (referrer.includes(key)) {
        return {
          type: 'social',
          platform: name,
          referrer: referrer
        }
      }
    }

    // 外部链接
    return {
      type: 'referral',
      referrer: referrer
    }
  }

  // 上报数据
  report(data) {
    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.reportUrl, JSON.stringify(data))
    } else {
      fetch(this.reportUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
        keepalive: true
      }).catch(err => {
        console.error('Report failed:', err)
      })
    }
  }

  // 获取用户ID
  getUserId() {
    let userId = localStorage.getItem('tracker_user_id')
    if (!userId) {
      userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
      localStorage.setItem('tracker_user_id', userId)
    }
    return userId
  }

  // 获取会话ID
  getSessionId() {
    let sessionId = sessionStorage.getItem('tracker_session_id')
    if (!sessionId) {
      sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
      sessionStorage.setItem('tracker_session_id', sessionId)
    }
    return sessionId
  }
}

四、曝光埋点

4.1 元素曝光监控

exposure-tracker.js

javascript
export class ExposureTracker {
  constructor(options = {}) {
    this.reportUrl = options.reportUrl
    this.threshold = options.threshold || 0.5
    this.exposureTime = options.exposureTime || 1000

    this.observers = new Map()
    this.exposureTimers = new Map()
    this.exposedElements = new Set()

    this.init()
  }

  init() {
    // 创建IntersectionObserver
    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersection(entries),
      {
        threshold: this.threshold,
        rootMargin: '0px'
      }
    )

    // 自动追踪带有data-track-exposure属性的元素
    this.observeAutoTrackElements()

    // 监听DOM变化
    this.observeDOMChanges()
  }

  // 处理元素可见性变化
  handleIntersection(entries) {
    entries.forEach(entry => {
      const element = entry.target
      const elementId = this.getElementId(element)

      if (entry.isIntersecting) {
        // 元素进入可见区域
        this.startExposureTimer(element, elementId)
      } else {
        // 元素离开可见区域
        this.clearExposureTimer(elementId)
      }
    })
  }

  // 开始曝光计时
  startExposureTimer(element, elementId) {
    // 如果已经曝光过,不再重复上报
    if (this.exposedElements.has(elementId)) {
      return
    }

    // 设置定时器
    const timer = setTimeout(() => {
      this.trackExposure(element)
      this.exposedElements.add(elementId)
      this.clearExposureTimer(elementId)
    }, this.exposureTime)

    this.exposureTimers.set(elementId, timer)
  }

  // 清除曝光计时
  clearExposureTimer(elementId) {
    const timer = this.exposureTimers.get(elementId)
    if (timer) {
      clearTimeout(timer)
      this.exposureTimers.delete(elementId)
    }
  }

  // 追踪曝光
  trackExposure(element) {
    const exposureData = {
      type: 'exposure',
      elementId: this.getElementId(element),
      elementInfo: {
        tagName: element.tagName,
        id: element.id,
        className: element.className,
        text: element.innerText?.substring(0, 50),
        xpath: this.getXPath(element)
      },
      position: element.getBoundingClientRect(),
      url: window.location.href,
      timestamp: Date.now(),

      // 自定义数据
      ...this.getCustomData(element)
    }

    console.log('Element exposed:', exposureData)
    this.report(exposureData)
  }

  // 观察元素
  observe(element) {
    if (element && !this.observers.has(element)) {
      this.observer.observe(element)
      this.observers.set(element, true)
    }
  }

  // 取消观察
  unobserve(element) {
    if (element && this.observers.has(element)) {
      this.observer.unobserve(element)
      this.observers.delete(element)

      const elementId = this.getElementId(element)
      this.clearExposureTimer(elementId)
    }
  }

  // 自动追踪标记的元素
  observeAutoTrackElements() {
    const elements = document.querySelectorAll('[data-track-exposure]')
    elements.forEach(element => {
      this.observe(element)
    })
  }

  // 监听DOM变化
  observeDOMChanges() {
    const mutationObserver = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.hasAttribute('data-track-exposure')) {
              this.observe(node)
            }

            // 检查子元素
            const children = node.querySelectorAll('[data-track-exposure]')
            children.forEach(child => {
              this.observe(child)
            })
          }
        })
      })
    })

    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true
    })
  }

  // 获取元素ID
  getElementId(element) {
    if (element.id) return element.id

    // 使用XPath作为唯一标识
    return this.getXPath(element)
  }

  // 获取XPath
  getXPath(element) {
    if (element.id) {
      return `//*[@id="${element.id}"]`
    }

    const parts = []
    while (element && element.nodeType === Node.ELEMENT_NODE) {
      let index = 0
      let sibling = element.previousSibling

      while (sibling) {
        if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
          index++
        }
        sibling = sibling.previousSibling
      }

      const tagName = element.nodeName.toLowerCase()
      const pathIndex = index ? `[${index + 1}]` : ''
      parts.unshift(tagName + pathIndex)

      element = element.parentNode
    }

    return parts.length ? '/' + parts.join('/') : ''
  }

  // 获取自定义数据
  getCustomData(element) {
    const customData = {}

    // 读取data-track-*属性
    Array.from(element.attributes).forEach(attr => {
      if (attr.name.startsWith('data-track-')) {
        const key = attr.name.replace('data-track-', '')
        customData[key] = attr.value
      }
    })

    return customData
  }

  // 上报数据
  report(data) {
    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.reportUrl, JSON.stringify(data))
    } else {
      fetch(this.reportUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
        keepalive: true
      }).catch(err => {
        console.error('Report failed:', err)
      })
    }
  }

  // 销毁
  destroy() {
    this.observer.disconnect()
    this.exposureTimers.forEach(timer => clearTimeout(timer))
    this.exposureTimers.clear()
    this.observers.clear()
  }
}

4.2 曝光追踪组件

ExposureTracker.vue

vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ExposureTracker } from './exposure-tracker.js'

const tracker = ref(null)
const exposedItems = ref([])

// 商品列表
const products = ref([
  { id: 1, name: '商品A', price: 99, image: 'https://via.placeholder.com/150' },
  { id: 2, name: '商品B', price: 199, image: 'https://via.placeholder.com/150' },
  { id: 3, name: '商品C', price: 299, image: 'https://via.placeholder.com/150' },
  { id: 4, name: '商品D', price: 399, image: 'https://via.placeholder.com/150' },
  { id: 5, name: '商品E', price: 499, image: 'https://via.placeholder.com/150' },
  { id: 6, name: '商品F', price: 599, image: 'https://via.placeholder.com/150' }
])

// 初始化追踪器
const initTracker = () => {
  tracker.value = new ExposureTracker({
    reportUrl: '/api/exposure',
    threshold: 0.5,
    exposureTime: 1000
  })

  // 拦截上报方法
  const originalReport = tracker.value.report.bind(tracker.value)
  tracker.value.report = function(data) {
    exposedItems.value.unshift({
      id: Date.now() + Math.random(),
      ...data
    })

    if (exposedItems.value.length > 20) {
      exposedItems.value.pop()
    }

    return originalReport(data)
  }
}

// 格式化时间
const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

onMounted(() => {
  initTracker()
})

onUnmounted(() => {
  tracker.value?.destroy()
})
</script>

<template>
  <div class="exposure-tracker">
    <div class="tracker-header">
      <h2>曝光埋点系统</h2>
      <div class="hint">滚动页面查看商品曝光追踪</div>
    </div>

    <div class="content-layout">
      <!-- 左侧:商品列表 -->
      <div class="products-section">
        <h3>商品列表</h3>
        <div class="product-grid">
          <div
            v-for="product in products"
            :key="product.id"
            class="product-card"
            data-track-exposure
            :data-track-product-id="product.id"
            :data-track-product-name="product.name"
            :data-track-product-price="product.price"
          >
            <img :src="product.image" :alt="product.name" />
            <div class="product-info">
              <h4>{{ product.name }}</h4>
              <div class="product-price">¥{{ product.price }}</div>
            </div>
          </div>
        </div>
      </div>

      <!-- 右侧:曝光记录 -->
      <div class="exposure-log">
        <h3>曝光记录 ({{ exposedItems.length }})</h3>
        <div class="log-list">
          <div
            v-for="item in exposedItems"
            :key="item.id"
            class="log-item"
          >
            <div class="log-header">
              <span class="log-badge">曝光</span>
              <span class="log-time">{{ formatTime(item.timestamp) }}</span>
            </div>
            <div class="log-content">
              <div class="log-row">
                <label>商品ID:</label>
                <span>{{ item['product-id'] }}</span>
              </div>
              <div class="log-row">
                <label>商品名称:</label>
                <span>{{ item['product-name'] }}</span>
              </div>
              <div class="log-row">
                <label>商品价格:</label>
                <span>¥{{ item['product-price'] }}</span>
              </div>
            </div>
          </div>

          <div v-if="exposedItems.length === 0" class="empty-log">
            <div class="empty-text">暂无曝光记录</div>
            <div class="empty-hint">滚动页面使商品进入视野即可触发曝光</div>
          </div>
        </div>
      </div>
    </div>

    <!-- 说明卡片 -->
    <div class="info-card">
      <h3>曝光埋点原理</h3>
      <ul>
        <li>使用IntersectionObserver API监听元素可见性</li>
        <li>当元素可见比例超过50%时开始计时</li>
        <li>持续可见1秒后触发曝光事件</li>
        <li>每个元素只会上报一次曝光</li>
        <li>支持通过data-track-*属性传递自定义数据</li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
.exposure-tracker {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.tracker-header {
  padding: 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 24px;
}

.tracker-header h2 {
  margin: 0 0 8px 0;
  font-size: 24px;
  color: #303133;
}

.hint {
  font-size: 14px;
  color: #909399;
}

.content-layout {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 24px;
  margin-bottom: 24px;
}

.products-section,
.exposure-log {
  background: white;
  border-radius: 8px;
  padding: 20px;
}

.products-section h3,
.exposure-log h3 {
  margin: 0 0 20px 0;
  font-size: 18px;
  color: #303133;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
}

.product-card {
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  overflow: hidden;
  transition: all 0.3s;
  cursor: pointer;
}

.product-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  transform: translateY(-4px);
}

.product-card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.product-info {
  padding: 16px;
}

.product-info h4 {
  margin: 0 0 8px 0;
  font-size: 16px;
  color: #303133;
}

.product-price {
  font-size: 20px;
  font-weight: 700;
  color: #f56c6c;
}

.log-list {
  max-height: 600px;
  overflow-y: auto;
}

.log-item {
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 12px;
}

.log-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.log-badge {
  padding: 4px 12px;
  background: #67c23a;
  color: white;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
}

.log-time {
  font-size: 12px;
  color: #c0c4cc;
}

.log-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.log-row {
  display: flex;
  justify-content: space-between;
  font-size: 13px;
}

.log-row label {
  color: #909399;
}

.log-row span {
  color: #303133;
  font-weight: 600;
}

.empty-log {
  padding: 60px 20px;
  text-align: center;
}

.empty-text {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.empty-hint {
  font-size: 12px;
  color: #c0c4cc;
}

.info-card {
  padding: 24px;
  background: #ecf5ff;
  border-left: 4px solid #409eff;
  border-radius: 8px;
}

.info-card h3 {
  margin: 0 0 16px 0;
  font-size: 18px;
  color: #409eff;
}

.info-card ul {
  margin: 0;
  padding-left: 24px;
}

.info-card li {
  font-size: 14px;
  line-height: 2;
  color: #606266;
}
</style>

五、埋点数据上报

5.1 数据上报策略

report-strategy.js

javascript
export class ReportStrategy {
  constructor(options = {}) {
    this.reportUrl = options.reportUrl
    this.maxQueueSize = options.maxQueueSize || 10
    this.reportInterval = options.reportInterval || 5000
    this.maxRetryTimes = options.maxRetryTimes || 3

    this.queue = []
    this.retryQueue = []
    this.timer = null

    this.init()
  }

  init() {
    // 启动定时上报
    this.startScheduleReport()

    // 页面卸载时上报
    this.setupUnloadReport()

    // 恢复缓存数据
    this.recoverCachedData()
  }

  // 添加到队列
  add(data) {
    this.queue.push({
      data: data,
      timestamp: Date.now(),
      retryTimes: 0
    })

    // 队列满了立即上报
    if (this.queue.length >= this.maxQueueSize) {
      this.report()
    }
  }

  // 上报数据
  async report() {
    if (this.queue.length === 0) return

    const items = [...this.queue]
    this.queue = []

    try {
      await this.sendData(items.map(item => item.data))
    } catch (error) {
      console.error('Report failed:', error)

      // 失败的数据加入重试队列
      items.forEach(item => {
        if (item.retryTimes < this.maxRetryTimes) {
          item.retryTimes++
          this.retryQueue.push(item)
        }
      })
    }

    // 处理重试队列
    this.processRetryQueue()
  }

  // 发送数据
  async sendData(data) {
    // 优先使用Beacon API
    if (navigator.sendBeacon) {
      const success = navigator.sendBeacon(
        this.reportUrl,
        JSON.stringify(data)
      )

      if (success) return
    }

    // 降级到fetch
    const response = await fetch(this.reportUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
      keepalive: true
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
  }

  // 处理重试队列
  async processRetryQueue() {
    if (this.retryQueue.length === 0) return

    const items = [...this.retryQueue]
    this.retryQueue = []

    for (const item of items) {
      try {
        await this.sendData([item.data])
      } catch (error) {
        if (item.retryTimes < this.maxRetryTimes) {
          item.retryTimes++
          this.retryQueue.push(item)
        } else {
          // 超过重试次数,缓存到localStorage
          this.saveToCache(item.data)
        }
      }
    }
  }

  // 定时上报
  startScheduleReport() {
    this.timer = setInterval(() => {
      this.report()
    }, this.reportInterval)
  }

  // 页面卸载上报
  setupUnloadReport() {
    window.addEventListener('beforeunload', () => {
      this.report()
    })

    // 页面隐藏时上报
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.report()
      }
    })
  }

  // 保存到缓存
  saveToCache(data) {
    try {
      const cached = JSON.parse(localStorage.getItem('report_cache') || '[]')
      cached.push(data)

      // 只保留最近100条
      localStorage.setItem('report_cache', JSON.stringify(cached.slice(-100)))
    } catch (e) {
      console.error('Save to cache failed:', e)
    }
  }

  // 恢复缓存数据
  recoverCachedData() {
    try {
      const cached = JSON.parse(localStorage.getItem('report_cache') || '[]')

      if (cached.length > 0) {
        cached.forEach(data => {
          this.add(data)
        })

        localStorage.removeItem('report_cache')
      }
    } catch (e) {
      console.error('Recover cache failed:', e)
    }
  }

  // 销毁
  destroy() {
    if (this.timer) {
      clearInterval(this.timer)
    }

    this.report()
  }
}

六、简历描述模板

前端埋点系统开发 (2024.02 - 2024.06)

负责构建公司用户行为分析埋点系统,实现全链路数据采集,日均处理埋点数据50万+条,为产品优化提供数据支撑。

核心职责

  • 开发自动化埋点SDK,支持页面浏览、用户点击、滚动等行为的自动采集
  • 实现曝光埋点功能,基于IntersectionObserver监听元素可见性,准确率99%
  • 设计埋点数据上报策略,支持实时上报、批量上报和离线缓存
  • 开发页面访问统计模块,实现PV/UV统计和来源分析
  • 建立埋点管理平台,支持埋点配置、数据查询和可视化展示
技术实现
  • 使用事件委托机制实现全局点击埋点,降低性能开销
  • 通过IntersectionObserver实现高性能的曝光监控
  • 采用队列+定时器的方式批量上报,减少网络请求
  • 使用Beacon API确保页面关闭时数据不丢失
  • 实现埋点数据压缩和去重,节省30%带宽
项目成果
  • 埋点SDK体积仅15KB,对页面性能影响可忽略
  • 数据采集准确率99.5%,丢失率低于0.5%
  • 支持每日50万+埋点数据采集和上报
  • 通过数据分析,产品转化率提升20%

七、SOP标准回答

面试问题: 如何设计一套完整的埋点系统?

标准回答

"我设计的埋点系统分为四层架构。

第一层是数据采集层。我实现了三种埋点方式:自动埋点、手动埋点和曝光埋点。自动埋点用事件委托在document上监听click事件,获取元素的tagName、id、class、XPath等信息自动上报。对于滚动、页面停留等行为也是自动采集。手动埋点提供track方法,业务代码可以调用传入事件名和自定义数据。曝光埋点用IntersectionObserver监听元素可见性,当元素可见比例超过50%且持续1秒就上报曝光。

第二层是数据处理层。采集的数据会先做校验,过滤掉无效数据。然后加工,添加用户ID、设备ID、会话ID、时间戳等公共字段。为了降低网络开销,我会对数据做压缩,使用pako库gzip压缩,能节省30%体积。

第三层是数据上报层。我设计了三种上报策略:实时上报适用于关键业务,收到数据立即发送;批量上报是常规策略,积累10条或5秒触发一次;延迟上报用于低优先级数据,积累更多再上报。上报用Beacon API,它即使页面关闭也能保证数据发送。如果失败会重试3次,还失败就缓存到localStorage,下次启动时恢复。

第四层是数据分析层。后端收到数据后会存到Elasticsearch,然后用Kibana做可视化。我们会分析用户路径、转化率、热力图等。比如通过漏斗分析发现注册流程第三步流失率特别高,优化后转化率提升了20%。

性能优化方面,我做了几点:事件委托避免给每个元素绑定事件;使用requestIdleCallback在空闲时上报;对XPath等重复数据做缓存。最终SDK压缩后只有15KB,对页面性能影响可忽略。"


八、难点与亮点分析

难点1: 如何精确计算元素曝光时长?

问题场景: 元素可能多次进入和离开视野,需要累计有效曝光时长。

解决方案

javascript
class ExposureDurationTracker {
  constructor() {
    this.durations = new Map()
  }

  startTracking(elementId) {
    if (!this.durations.has(elementId)) {
      this.durations.set(elementId, {
        total: 0,
        lastStart: Date.now(),
        isVisible: true
      })
    } else {
      const data = this.durations.get(elementId)
      data.lastStart = Date.now()
      data.isVisible = true
    }
  }

  stopTracking(elementId) {
    const data = this.durations.get(elementId)
    if (data && data.isVisible) {
      data.total += Date.now() - data.lastStart
      data.isVisible = false
    }
  }

  getTotalDuration(elementId) {
    const data = this.durations.get(elementId)
    if (!data) return 0

    let total = data.total
    if (data.isVisible) {
      total += Date.now() - data.lastStart
    }

    return total
  }
}

亮点1: 智能埋点去重

创新点

  • 基于时间窗口的去重
  • 相似事件合并
  • 减少90%重复上报
实现
javascript
class EventDeduplicator {
  constructor(windowSize = 1000) {
    this.windowSize = windowSize
    this.recentEvents = []
  }

  shouldReport(event) {
    const now = Date.now()

    // 清理过期事件
    this.recentEvents = this.recentEvents.filter(
      e => now - e.timestamp < this.windowSize
    )

    // 生成事件指纹
    const fingerprint = this.generateFingerprint(event)

    // 检查是否重复
    const isDuplicate = this.recentEvents.some(
      e => e.fingerprint === fingerprint
    )

    if (!isDuplicate) {
      this.recentEvents.push({
        fingerprint,
        timestamp: now
      })
      return true
    }

    return false
  }

  generateFingerprint(event) {
    return `${event.type}|${event.target}|${event.data}`
  }
}

亮点2: 埋点数据压缩

创新点

  • 使用gzip压缩减少30%体积
  • 字段缩写mapping
  • 批量压缩效果更好
实现
javascript
import pako from 'pako'

class DataCompressor {
  constructor() {
    // 字段映射(缩短字段名)
    this.fieldMapping = {
      'eventName': 'e',
      'timestamp': 't',
      'userId': 'u',
      'sessionId': 's',
      'url': 'l',
      'eventData': 'd'
    }
  }

  compress(data) {
    // 1. 字段映射
    const mapped = this.mapFields(data)

    // 2. JSON序列化
    const json = JSON.stringify(mapped)

    // 3. gzip压缩
    const compressed = pako.gzip(json)

    // 4. Base64编码
    return btoa(String.fromCharCode.apply(null, compressed))
  }

  mapFields(data) {
    if (Array.isArray(data)) {
      return data.map(item => this.mapFields(item))
    }

    const mapped = {}
    for (const [key, value] of Object.entries(data)) {
      const newKey = this.fieldMapping[key] || key
      mapped[newKey] = value
    }

    return mapped
  }
}