前端埋点
1. 埋点定义
埋点是数据采集领域(尤其是用户行为数据采集领域)的术语,指的是针对特定用户行为或事件进行捕获、处理和发送的相关技术及其实施过程。
如用户某个icon点击次数、观看某个视频的时长等。埋点的技术实质,是先监听软件应用运行过程中的事件,当需要关注的事件发生时进行判断和捕获。
2. 埋点的重要性
数据从产生到应用于产品优化整个流程大概如下所示:数据生产➡️数据采集➡️数据处理➡️数据分析➡️数据驱动➡️产品优化或迭代,埋点是整个流程的开始点,产品优化是整个流程的终点。
3. 埋点类型
埋点主要分为3类:展现埋点 + 曝光埋点 + 交互埋点
- 展现埋点
服务端被触发后,用户侧将会展现什么内容。展现埋点需要记录页面展现的内容信息,即服务端下发的内容是什么(不包含一些交互信息)
- 曝光埋点
屏幕有限,但内容可以无限,哪些内容被用户侧实际看到(曝光),需要记录的是单个“内容”被看到,一系列被下发的内容,可以触发多次曝光埋点
- 交互埋点
交互埋点表明的是功能或内容被用户“点击”了。从埋点时机来说,这个是展现和曝光的下游,记录用户对可交互功能或信息的“消费”情况
🎯 埋点的三种主流方式
1. 代码埋点(手动挡)
在需要监听的位置手动插入追踪代码
// 按钮点击埋点
const handleClick = () => {
track('button_click', {
page: 'home',
button_id: 'submit_btn'
})
}
将信息以属性的形式添加到DOM元素上,统一在body上面挂载事件,利用冒泡机制实现数据捕获和上报
<span
data-tpm='vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0'
data-tpm-args='{"pid":1, "uid":2}'
>
登录
</span>
document.body.addEventListener('click', function(e) {
const el = e.target;
// 获取数据
const dataTpm = getNodeAttr(el,'data-tpm');
const dataTpmArgs = getNodeAttr(el,'data-tpm-args');
if (dataTpm || dataTpmArgs) {
// 上报到服务器
const data = { type: 'click',ec: dataTpm,ea: dataTpmArgs};
sendToWeb(data);
}
}, false);
✅ 精准控制,数据丰富 ❌ 侵入性强,维护成本高
2. 可视化埋点(自动挡)
通过圈选工具配置埋点,如GrowingIO、神策
✅ 无需发版,运营可自助 ❌ 只能采集UI层级,无法获取深层业务数据
3. 无埋点(全自动)
全量采集所有用户行为
// 改写原型链实现自动采集
const originalClick = HTMLElement.prototype.click;
HTMLElement.prototype.click = function() {
track('element_click', {
tagName: this.tagName,
innerText: this.innerText.slice(0, 20)
});
return originalClick.apply(this, arguments);
}
✅ 无侵入,覆盖全面 ❌ 数据量大,传输和存储成本高
💾 数据存储
1. 内存存储(最常见)
class MemoryStorage {
queue = [] // 直接存储在内存
// 控制内存占用
MAX_MEMORY_SIZE = 1000
add(item) {
if (this.queue.length >= this.MAX_MEMORY_SIZE) {
// 策略1:丢弃最老数据
this.queue.shift()
// 策略2:强制发送(但可能失败)
// this.forceFlush()
}
this.queue.push(item)
}
}
性能影响:
- 普通场景下完全OK(1000条埋点 ≈ 100KB)
- 极端情况(快速滚动、疯狂点击)可能内存飙升
- 解决方案:设置上限 + 降级策略
2. localStorage - 持久化存储
class LocalStorageStore {
STORAGE_KEY = 'tracker_queue'
MAX_SIZE = 5 * 1024 * 1024 // 5MB限制
add(event) {
const queue = this.getQueue()
queue.push(event)
// 估算大小,超限则丢弃
const size = new Blob([JSON.stringify(queue)]).size
if (size > this.MAX_SIZE) {
queue.shift() // 丢弃最早的一条
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(queue))
}
getQueue() {
try {
return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || []
} catch {
return []
}
}
}
适用场景:
- 需要断网续传
- 页面关闭后不丢数据
- 跨页面共享埋点队列
⚠️ 注意:同步操作可能阻塞渲染,建议用 setTimeout 包装
3. IndexedDB - 大型存储
class IndexedDBStore {
db = null
async init() {
this.db = await new Promise((resolve, reject) => {
const request = indexedDB.open('TrackerDB', 1)
request.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains('events')) {
// 创建对象仓库,使用时间戳作为索引
const store = db.createObjectStore('events', {
keyPath: 'id',
autoIncrement: true
})
store.createIndex('timestamp', 'timestamp')
}
}
request.onsuccess = () => resolve(request.result)
})
}
async add(event) {
const tx = this.db.transaction(['events'], 'readwrite')
const store = tx.objectStore('events')
await store.add({
...event,
timestamp: Date.now(),
status: 'pending'
})
}
async getBatch(limit = 20) {
const tx = this.db.transaction(['events'], 'readonly')
const store = tx.objectStore('events')
const index = store.index('timestamp')
// 获取最早的一批待发送数据
return await index.getAll(IDBKeyRange.lowerBound(0), limit)
}
}
✅ 优点:存储空间大(250MB+),支持复杂查询
❌ 缺点:API复杂,异步操作,学习成本高
💎建议
- 中小型应用:内存存储 + requestIdleCallback
- 需要断网续传:IndexedDB + 定时器兜底
- 超高并发:Web Worker + 内存队列
- 既要又要:混合存储方案
💡 批量上报方案
1. 定时器方案(最常用)
class BatchTracker {
queue = []
constructor(config) {
this.maxSize = config.maxSize || 10 // 条数阈值
this.timeWindow = config.timeWindow || 2000 // 时间阈值
this.initTimer()
}
initTimer() {
setInterval(() => {
if (this.queue.length > 0) {
this.flush()
}
}, this.timeWindow)
}
track(event) {
this.queue.push(event)
// 条数达到阈值立即发送
if (this.queue.length >= this.maxSize) {
this.flush()
}
}
}
优点:实现简单,控制精准
缺点:一直占用定时器,即使没有数据也在跑
2. requestIdleCallback - 浏览器空闲时发送
class IdleTracker {
queue = []
isScheduled = false
track(event) {
this.queue.push(event)
this.scheduleIdleFlush()
}
scheduleIdleFlush() {
if (this.isScheduled) return
this.isScheduled = true
requestIdleCallback(
(deadline) => {
// 在空闲时间发送,但不超过50ms
while (this.queue.length > 0 && deadline.timeRemaining() > 5) {
this.sendBatch(this.queue.splice(0, 5))
}
this.isScheduled = false
// 如果还有剩余,继续调度
if (this.queue.length > 0) {
this.scheduleIdleFlush()
}
},
{ timeout: 2000 } // 最多等2秒
)
}
}
✅ 优点:不抢占主线程,对性能影响最小
❌ 缺点:兼容性问题(Safari不支持),发送时机不可控
3. IntersectionObserver - 利用元素曝光触发
元素出现在可视区域时上报数据
class ObserverTracker {
queue = []
observer = null
constructor() {
// 创建一个隐藏的发送触发器
const trigger = document.createElement('div')
trigger.style.height = '1px'
trigger.style.opacity = '0'
document.body.appendChild(trigger)
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && this.queue.length > 0) {
// console.log('元素出现啦!')
// 只触发一次!
this.flush()
}
},
{ threshold: 0.1 }
)
this.observer.observe(trigger)
}
track(event) {
this.queue.push(event)
}
}
✅ 优点:利用浏览器原生机制,不额外占用资源
❌ 缺点:依赖DOM,发送时机被动
4. Web Worker - 独立线程处理
// tracker-worker.js
self.addEventListener('message', (e) => {
const { type, data } = e.data
const queue = []
if (type === 'track') {
queue.push(data)
// 在worker里做批量处理
if (queue.length >= 10) {
self.postMessage({ type: 'flush', data: [...queue] })
queue.length = 0
}
}
})
// main.js
const worker = new Worker('tracker-worker.js')
const tracker = {
track(event) {
worker.postMessage({ type: 'track', data: event })
}
}
worker.addEventListener('message', (e) => {
if (e.data.type === 'flush') {
// 主线程发送数据
navigator.sendBeacon('/api/track', JSON.stringify(e.data.data))
}
})
✅ 优点:完全不阻塞主线程
❌ 缺点:通信开销,实现复杂
🚀 高级埋点实践技巧
1. 批量上报与防抖
class Tracker {
queue = []
timer = null
track(event) {
this.queue.push(event)
this.schedule()
}
schedule() {
if (this.timer) return
this.timer = setTimeout(() => {
this.flush()
this.timer = null
}, 2000)
}
async flush() {
if (this.queue.length === 0) return
const events = [...this.queue]
this.queue = []
try {
await navigator.sendBeacon('/api/track', JSON.stringify(events))
} catch {
// 降级方案
this.queue.unshift(...events)
}
}
}
2. 用户行为串联
// 生成唯一session ID
const sessionId = uuidv4()
// 维护用户行为栈
const behaviorStack = []
const MAX_STACK_SIZE = 50
function pushBehavior(action) {
behaviorStack.push({
...action,
timestamp: Date.now(),
sessionId
})
if (behaviorStack.length > MAX_STACK_SIZE) {
behaviorStack.shift()
}
}
3. 隐私数据脱敏
function sanitizeData(data) {
const sensitiveKeys = ['phone', 'email', 'idCard']
return Object.keys(data).reduce((acc, key) => {
if (sensitiveKeys.includes(key)) {
acc[key] = '***'
} else {
acc[key] = data[key]
}
return acc
}, {})
}
⚠️ 埋点常见问题与解决方案
1. 数据重复上报
- 原因:组件重复渲染、路由切换未清理
- 解决:使用WeakMap缓存已上报元素
const reportedElements = new WeakMap()
function trackIfNotReported(element, event) {
if (reportedElements.has(element)) return
reportedElements.set(element, true)
track(event)
}
2. 异步加载场景丢失
// 监听动态内容加载
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
initTrackingOnElement(node)
}
})
})
})
observer.observe(document.body, {
childList: true,
subtree: true
})
3. 页面卸载数据丢失
window.addEventListener('beforeunload', () => {
// 同步发送剩余数据
if (tracker.queue.length > 0) {
navigator.sendBeacon('/api/track', JSON.stringify(tracker.queue))
}
})
⚡️ 前端性能指标采集
📊 核心性能指标全景图
时间轴 →
┌─────────┬─────────┬──────────┬──────────┬──────────┐
│ FP │ FCP │ LCP │ FID │ TTI │
├─────────┼─────────┼──────────┼──────────┼──────────┤
│ 白屏时间 │ 内容绘制 │ 最大内容 │ 交互延迟 │ 可交互时间 │
└─────────┴─────────┴──────────┴──────────┴──────────┘
1. Paint Timing API - 绘制指标
class PaintMetricsCollector {
metrics = {
fp: null, // First Paint
fcp: null, // First Contentful Paint
lcp: null // Largest Contentful Paint (通过PerformanceObserver单独获取)
}
constructor() {
this.initPaintTiming()
this.initLCP()
}
initPaintTiming() {
// 获取首次绘制时间
const paintEntries = performance.getEntriesByType('paint')
paintEntries.forEach(entry => {
if (entry.name === 'first-paint') {
this.metrics.fp = entry.startTime
}
if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime
}
})
// 或者用Observer监听(更实时)
const paintObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log(`🎨 ${entry.name}: ${entry.startTime}ms`)
this.metrics[entry.name === 'first-paint' ? 'fp' : 'fcp'] = entry.startTime
})
})
paintObserver.observe({ entryTypes: ['paint'] })
}
initLCP() {
// LCP需要持续监听,因为最大元素可能变化
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.metrics.lcp = lastEntry.startTime
console.log(`🖼️ LCP: ${lastEntry.startTime}ms`, lastEntry.element)
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
}
}
2. First Input Delay (FID) - 首次输入延迟
class FIDCollector {
fid = null
constructor() {
this.initFID()
}
initFID() {
// 方案1:使用PerformanceObserver
if (PerformanceObserver.supportedEntryTypes?.includes('first-input')) {
const fidObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
// FID = 处理延迟时间(processingStart) - 时间戳(startTime)
this.fid = entry.processingStart - entry.startTime
console.log(`👆 FID: ${this.fid}ms`)
// 上报数据
this.reportFID({
fid: this.fid,
target: entry.target?.tagName,
type: entry.name
})
})
})
fidObserver.observe({ entryTypes: ['first-input'] })
}
// 方案2:手动监听(兼容性更好)
this.manualFID()
}
manualFID() {
let firstInputTime = null
const handleFirstInput = (event) => {
if (firstInputTime) return
firstInputTime = Date.now()
const inputDelay = event.timeStamp // 事件发生时间
// 计算从事件发生到开始处理的时间差
setTimeout(() => {
const processingStart = Date.now()
const fid = processingStart - firstInputTime
console.log(`👆 [手动] FID: ${fid}ms`)
}, 0)
// 移除监听
['click', 'touchstart', 'keydown'].forEach(type => {
window.removeEventListener(type, handleFirstInput, true)
})
}
// 在捕获阶段监听,确保最早获取
['click', 'touchstart', 'keydown'].forEach(type => {
window.addEventListener(type, handleFirstInput, true)
})
}
}
3. TTI (Time to Interactive) - 可交互时间
class TTICollector {
tti = null
async calculateTTI() {
// 获取关键时间点
const fcp = this.getFCP()
const domInteractive = performance.timing.domInteractive
// 方案:寻找5秒内没有长任务的时间窗口
const longTasks = await this.getLongTasks()
let tti = Math.max(fcp, domInteractive)
// 从FCP之后找第一个安静窗口
for (let i = 0; i < longTasks.length; i++) {
const task = longTasks[i]
if (task.startTime < tti) continue
// 检查这个长任务之后是否有5秒安静期
const nextTask = longTasks[i + 1]
if (!nextTask || nextTask.startTime - task.startTime > 5000) {
tti = task.startTime + task.duration
break
}
}
this.tti = tti
console.log(`⏱️ TTI: ${tti}ms`)
return tti
}
getLongTasks() {
return new Promise(resolve => {
const tasks = []
if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
tasks.push({
startTime: entry.startTime,
duration: entry.duration,
name: entry.name
})
})
})
observer.observe({ entryTypes: ['longtask'] })
// 页面加载完成后停止收集
setTimeout(() => {
observer.disconnect()
resolve(tasks)
}, 10000)
} else {
resolve([])
}
})
}
}
4. 资源加载监控
class ResourceMetricsCollector {
resources = []
constructor() {
this.initResourceObserver()
this.calculateCriticalResources()
}
initResourceObserver() {
const resourceObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
// 过滤掉跨域资源(如果没有Timing-Allow-Origin头)
if (!entry.transferSize) return
this.resources.push({
name: entry.name,
type: entry.initiatorType,
duration: entry.duration,
size: entry.transferSize,
protocol: entry.nextHopProtocol,
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ttfb: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart
})
})
})
resourceObserver.observe({ entryTypes: ['resource'] })
}
// 计算关键资源加载时间
calculateCriticalResources() {
window.addEventListener('load', () => {
const criticalResources = this.resources.filter(r => {
// 首屏关键资源判断
return r.type === 'script' ||
r.type === 'link' ||
(r.type === 'img' && this.isAboveTheFold(r.name))
})
console.log('📦 关键资源统计:', {
count: criticalResources.length,
totalSize: criticalResources.reduce((sum, r) => sum + r.size, 0),
maxLoadTime: Math.max(...criticalResources.map(r => r.duration))
})
})
}
}
5. CLS (Cumulative Layout Shift) - 布局偏移
class CLSCollector {
clsValue = 0
sessionWindows = []
constructor() {
this.initCLSObserver()
}
initCLSObserver() {
let cls = 0
let sessionWindow = {
start: performance.now(),
entries: []
}
const clsObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) { // 忽略500ms内的输入
const currentTime = performance.now()
// 检查是否应该开始新的会话窗口
if (currentTime - sessionWindow.start > 5000) {
// 记录上一个窗口
this.sessionWindows.push({
...sessionWindow,
value: cls
})
// 开始新窗口
sessionWindow = {
start: currentTime,
entries: [entry]
}
cls = entry.value
} else {
// 当前窗口累加
sessionWindow.entries.push(entry)
cls += entry.value
}
this.clsValue = Math.max(this.clsValue, cls)
console.log('📐 CLS:', this.clsValue)
}
})
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
}
}
🚀 综合性能监控系统
class PerformanceMonitor {
constructor(reportCallback) {
this.report = reportCallback
this.metrics = {}
this.initAllObservers()
this.calculateCustomMetrics()
}
initAllObservers() {
// 绘制指标
this.observePaint()
// 加载指标
this.observeNavigation()
// 资源指标
this.observeResources()
// 交互指标
this.observeInteractions()
// 布局指标
this.observeLayout()
}
observeNavigation() {
const navObserver = new PerformanceObserver((list) => {
const [entry] = list.getEntries()
this.metrics.navigation = {
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ssl: entry.secureConnectionStart ?
entry.connectEnd - entry.secureConnectionStart : 0,
ttfb: entry.responseStart - entry.requestStart,
domLoad: entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart,
load: entry.loadEventEnd - entry.loadEventStart,
type: entry.type
}
})
navObserver.observe({ entryTypes: ['navigation'] })
}
observeInteractions() {
// 记录所有交互延迟
const interactionObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
const delay = entry.duration // 事件处理耗时
const target = entry.target?.tagName
console.log(`🎯 交互延迟 [${target}]: ${delay}ms`)
// 上报慢交互(大于100ms)
if (delay > 100) {
this.report({
type: 'slow-interaction',
data: {
delay,
target,
event: entry.name,
timestamp: Date.now()
}
})
}
})
})
interactionObserver.observe({
entryTypes: ['event']
})
}
observeLayout() {
// 检测强制同步布局
let layoutCount = 0
const layoutObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
layoutCount++
console.warn('⚠️ 强制同步布局:', {
script: entry.name,
duration: entry.duration,
stack: this.getStackTrace()
})
this.report({
type: 'forced-layout',
data: {
duration: entry.duration,
script: entry.name
}
})
})
})
layoutObserver.observe({
entryTypes: ['layout-shift']
})
}
calculateCustomMetrics() {
// 首屏时间
this.calculateFirstScreenTime()
// 可交互时间
this.calculateTimeToInteractive()
// 资源加载完成率
this.calculateResourceLoadRate()
// 内存使用(如果支持)
if (performance.memory) {
setInterval(() => {
this.metrics.memory = {
used: performance.memory.usedJSHeapSize,
total: performance.memory.totalJSHeapSize,
limit: performance.memory.jsHeapSizeLimit
}
}, 30000)
}
}
calculateFirstScreenTime() {
// 首屏时间:视口内元素绘制完成的时间
const viewportElements = this.getViewportElements()
let maxTime = 0
viewportElements.forEach(el => {
const time = this.getElementPaintTime(el)
maxTime = Math.max(maxTime, time)
})
this.metrics.firstScreenTime = maxTime
}
// 上报所有指标
reportAllMetrics() {
// 等待所有指标收集完成
setTimeout(() => {
this.report({
type: 'performance-summary',
data: {
...this.metrics,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
}
})
}, 5000)
}
}
📈 数据上报策略
class PerformanceReporter {
constructor() {
this.queue = []
this.initialized = false
}
init() {
if (this.initialized) return
this.initialized = true
// 页面隐藏时上报(确保数据不丢失)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flushImmediately()
}
})
// 定期上报
setInterval(() => {
this.flush()
}, 10000)
}
// 普通上报
report(metric) {
this.queue.push(metric)
if (this.queue.length >= 10) {
this.flush()
}
}
// 立即上报(重要指标)
reportImmediately(metric) {
this.sendBeacon([metric])
}
flush() {
if (this.queue.length === 0) return
const metrics = [...this.queue]
this.queue = []
this.sendBeacon(metrics)
}
flushImmediately() {
if (this.queue.length === 0) return
this.sendBeacon(this.queue)
this.queue = []
}
sendBeacon(metrics) {
try {
navigator.sendBeacon('/api/performance', JSON.stringify({
metrics,
timestamp: Date.now()
}))
} catch (e) {
// 降级方案
fetch('/api/performance', {
method: 'POST',
body: JSON.stringify(metrics),
headers: { 'Content-Type': 'application/json' },
keepalive: true // 关键:确保页面关闭时也能发送
}).catch(console.error)
}
}
}
监控分级策略
const monitoringStrategy = {
critical: ['LCP', 'FID', 'CLS', 'JS_ERROR'], // 立即上报
important: ['FP', 'FCP', 'TTFB'], // 批量上报
normal: ['resource_load', 'api_latency'], // 抽样上报
verbose: ['long_task', 'layout_shift'] // 本地存储,按需拉取
}