一、技术实现方案
1.1 埋点系统架构
埋点系统架构
├── 数据采集层
│ ├── 页面浏览埋点 (PV/UV)
│ ├── 用户行为埋点 (点击/输入/滚动)
│ ├── 事件埋点 (自定义事件)
│ └── 曝光埋点 (元素可见性)
│
├── 数据处理层
│ ├── 数据校验
│ ├── 数据加工
│ ├── 数据缓存
│ └── 数据压缩
│
├── 数据上报层
│ ├── 实时上报
│ ├── 批量上报
│ ├── 延迟上报
│ └── 离线缓存
│
└── 数据分析层
├── 用户路径分析
├── 转化率分析
├── 热力图分析
└── 漏斗分析
1.2 技术栈
- 埋点采集: MutationObserver, IntersectionObserver
- 数据存储: localStorage, IndexedDB
- 数据上报: Beacon API, fetch
- 压缩算法: pako (gzip)
二、用户行为埋点
2.1 行为埋点SDK
tracker.js
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
<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
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
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
<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
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: 如何精确计算元素曝光时长?
问题场景: 元素可能多次进入和离开视野,需要累计有效曝光时长。
解决方案
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%重复上报
实现
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
- 批量压缩效果更好
实现
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
}
}