前端埋点与数据分析
What — 是什么
埋点
前端埋点(Tracking / Instrumentation)是在应用代码中植入数据采集逻辑的技术手段,目的是在用户与页面交互时自动记录行为数据,为后续分析提供原始素材。埋点是前端数据采集的核心手段,是整个数据分析链路的起点。
核心职责:
- 用户行为采集:点击、滚动、输入、页面跳转等操作
- 业务事件记录:下单、注册、分享、支付等关键业务动作
- 性能数据收集:页面加载耗时、接口响应时间、资源体积等
- 异常信息捕获:JS 错误、接口失败、白屏等故障数据
- 设备与环境信息:浏览器、操作系统、分辨率、网络状态等
数据分析
数据分析是埋点数据的消费端,通过对采集到的原始数据进行清洗、聚合、建模和可视化,驱动产品决策与业务增长。
分析维度:
| 维度 | 说明 | 典型指标 |
|---|---|---|
| 流量分析 | 了解用户从哪来、到哪去 | PV、UV、来源渠道、跳出率 |
| 行为分析 | 了解用户在做什么 | 点击率、停留时长、操作路径 |
| 转化分析 | 了解用户是否完成目标 | 漏斗转化率、归因分析 |
| 留存分析 | 了解用户是否持续回来 | 次日/7日/30日留存率 |
| 性能分析 | 了解应用运行是否流畅 | FCP、LCP、FID、CLS |
埋点三大方式
| 方式 | 原理 | 代表方案 |
|---|---|---|
| 代码埋点 | 开发者在代码中手动调用埋点 SDK 提供的 API | 神策、GrowingIO SDK |
| 可视化埋点 | 运营通过可视化圈选工具配置埋点规则,无需发版 | Mixpanel、神策可视化埋点 |
| 无痕埋点(全埋点) | SDK 全局代理事件,自动采集所有用户行为 | GrowingIO、Heap |
核心概念
| 概念 | 说明 |
|---|---|
| Event | 埋点的基本单位,描述”谁在什么时间做了什么事” |
| PV(Page View) | 页面浏览量,每次页面加载或路由切换计一次 |
| UV(Unique Visitor) | 独立访客数,按唯一标识去重统计 |
| 埋点 SDK | 封装数据采集、缓存、上报逻辑的前端库 |
| ETL | Extract-Transform-Load,数据清洗与入仓流程 |
| 埋点治理 | 管理埋点的生命周期:设计、开发、测试、上线、下线 |
数据流转架构
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 用户交互 │────▶│ 埋点 SDK │────▶│ 数据上报 │
│ 点击/输入 │ │ 事件采集 │ │ 批量/离线 │
│ 页面跳转 │ │ 数据组装 │ │ sendBeacon │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 数据可视化 │◀────│ 数据分析 │◀────│ 数据接收 │
│ Dashboard │ │ 漏斗/留存 │ │ 日志服务 │
│ 热力图 │ │ 路径/分群 │ │ ETL 清洗 │
└──────────────┘ └──────────────┘ └──────────────┘
Why — 为什么用
三种埋点方式对比
| 维度 | 代码埋点 | 可视化埋点 | 无痕埋点(全埋点) |
|---|---|---|---|
| 侵入性 | 高(需修改业务代码) | 低(配置化) | 极低(SDK 自动采集) |
| 灵活性 | 高(可采集任意自定义数据) | 中(受圈选能力限制) | 低(只能采集标准事件) |
| 开发成本 | 高(每个埋点需编码) | 低(运营自行配置) | 极低(接入 SDK 即可) |
| 数据精度 | 高(业务语义明确) | 中(依赖元素定位准确性) | 低(需后期人工映射事件含义) |
| 数据量 | 可控(按需采集) | 可控(按需圈选) | 极大(全量采集) |
| 生效方式 | 需发版上线 | 配置即生效 | SDK 接入即生效 |
| 维护成本 | 高(埋点与业务耦合) | 中(需维护圈选规则) | 低(无需维护) |
| 适用场景 | 核心业务事件、精确数据需求 | 运营驱动的快速验证 | 初期探索、流量概览 |
产品决策需要数据支撑
没有埋点数据,产品决策只能靠”拍脑袋”:
- 功能优先级:埋点数据显示某功能使用率仅 2%,不应继续投入
- 用户流失点:漏斗分析发现注册第三步流失率 60%,需优化
- 界面改版效果:AB 测试数据显示新版本转化率提升 15%
典型使用场景
- 用户行为分析:了解用户如何使用产品,哪些功能受欢迎
- AB 测试:对比不同方案的效果差异,用数据做决策
- 转化率优化:定位漏斗中的流失环节,针对性优化
- 运营决策:活动效果评估、渠道质量对比、用户分群运营
- 性能监控:页面加载速度、接口响应时间影响用户体验
- 异常发现:某个页面错误率飙升,及时告警修复
- 合规审计:关键操作留痕,满足监管要求
How — 怎么做
1. 埋点架构设计
SDK 整体架构
┌─────────────────────────────────────────────────┐
│ 埋点 SDK │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 事件采集 │ │ 数据组装 │ │ 上报策略 │ │
│ │ │ │ │ │ │ │
│ │ ·手动埋点│ │ ·事件模型│ │ ·批量上报 │ │
│ │ ·自动采集│ │ ·公共属性│ │ ·离线缓存 │ │
│ │ ·曝光监听│ │ ·数据校验│ │ ·页面卸载上报 │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 插件系统 │ │ 配置中心 │ │ 调试工具 │ │
│ │ │ │ │ │ │ │
│ │ ·生命周期│ │ ·采样率 │ │ ·事件日志 │ │
│ │ ·中间件 │ │ ·开关 │ │ ·数据校验 │ │
│ │ ·扩展钩子│ │ ·灰度 │ │ ·埋点检测 │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
└─────────────────────────────────────────────────┘
事件模型设计
// tracker/types.ts
interface TrackEvent {
// 事件基础信息
eventId: string; // 事件唯一 ID(UUID)
eventType: string; // 事件类型:click / pageview / expose / custom
eventName: string; // 事件名称:button_click / page_view / element_expose
timestamp: number; // 事件发生时间戳
// 用户信息
userId?: string; // 登录用户 ID
deviceId: string; // 设备唯一标识(UV 识别)
sessionId: string; // 会话 ID
// 页面信息
pageUrl: string; // 当前页面 URL
pagePath: string; // 路由路径
referrer: string; // 来源页面
pageTitle: string; // 页面标题
// 业务数据
properties: Record<string, unknown>; // 自定义属性
// 环境信息
environment: {
userAgent: string; // 浏览器 UA
viewport: string; // 视口尺寸
screen: string; // 屏幕分辨率
language: string; // 语言
platform: string; // 操作系统
networkType: string; // 网络类型
cookieEnabled: boolean; // Cookie 是否可用
};
}
SDK 核心类设计
// tracker/Tracker.ts
type EventName = string;
type Properties = Record<string, unknown>;
type Plugin = {
name: string;
beforeTrack?: (event: TrackEvent) => TrackEvent | null;
afterTrack?: (event: TrackEvent) => void;
};
interface TrackerConfig {
appId: string; // 应用标识
endpoint: string; // 上报地址
sampleRate?: number; // 采样率 0-1
batchSize?: number; // 批量上报条数
batchInterval?: number; // 批量上报间隔(ms)
enableAutoTrack?: boolean; // 启用自动采集
enableSPA?: boolean; // SPA 路由监听
debug?: boolean; // 调试模式
}
class Tracker {
private config: Required<TrackerConfig>;
private queue: TrackEvent[] = [];
private plugins: Plugin[] = [];
private commonProperties: Properties = {};
private timer: ReturnType<typeof setInterval> | null = null;
private userId: string = '';
private deviceId: string = '';
private sessionId: string = '';
constructor(config: TrackerConfig) {
this.config = {
sampleRate: 1,
batchSize: 10,
batchInterval: 5000,
enableAutoTrack: false,
enableSPA: false,
debug: false,
...config,
};
this.deviceId = this.getOrCreateDeviceId();
this.sessionId = this.createSessionId();
this.initAutoTrack();
this.startBatchTimer();
this.bindUnloadEvent();
}
// 手动埋点
track(eventName: EventName, properties: Properties = {}): void {
// 采样率控制
if (Math.random() > this.config.sampleRate) return;
const event = this.buildEvent(eventName, properties);
const processedEvent = this.runPlugins(event);
if (!processedEvent) return;
this.enqueue(processedEvent);
}
// 页面浏览
pageView(properties: Properties = {}): void {
this.track('page_view', {
url: location.href,
path: location.pathname,
referrer: document.referrer,
title: document.title,
...properties,
});
}
// 设置用户 ID
identify(userId: string): void {
this.userId = userId;
}
// 设置公共属性
setCommonProperties(properties: Properties): void {
Object.assign(this.commonProperties, properties);
}
// 注册插件
use(plugin: Plugin): void {
this.plugins.push(plugin);
}
// 构建事件对象
private buildEvent(eventName: EventName, properties: Properties): TrackEvent {
return {
eventId: this.generateId(),
eventType: 'custom',
eventName,
timestamp: Date.now(),
userId: this.userId || undefined,
deviceId: this.deviceId,
sessionId: this.sessionId,
pageUrl: location.href,
pagePath: location.pathname,
referrer: document.referrer,
pageTitle: document.title,
properties: { ...this.commonProperties, ...properties },
environment: this.collectEnvironment(),
};
}
// 执行插件链
private runPlugins(event: TrackEvent): TrackEvent | null {
let result = event;
for (const plugin of this.plugins) {
if (plugin.beforeTrack) {
result = plugin.beforeTrack(result);
if (!result) return null; // 插件拦截,不上报
}
}
return result;
}
// 入队
private enqueue(event: TrackEvent): void {
this.queue.push(event);
if (this.config.debug) {
console.log('[Tracker] event:', event);
}
if (this.queue.length >= this.config.batchSize) {
this.flush();
}
}
// 批量上报
flush(): void {
if (this.queue.length === 0) return;
const events = this.queue.splice(0);
this.send(events);
}
// 发送数据
private send(events: TrackEvent[]): void {
const data = JSON.stringify({
appId: this.config.appId,
events,
});
// 优先 sendBeacon
if (navigator.sendBeacon) {
const blob = new Blob([data], { type: 'application/json' });
const success = navigator.sendBeacon(this.config.endpoint, blob);
if (success) return;
}
// 降级 XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('POST', this.config.endpoint, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(data);
}
// 启动批量定时器
private startBatchTimer(): void {
this.timer = setInterval(() => this.flush(), this.config.batchInterval);
}
// 绑定页面卸载事件
private bindUnloadEvent(): void {
const handler = () => this.flush();
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') handler();
});
window.addEventListener('pagehide', handler);
}
// 设备 ID(UV 识别)
private getOrCreateDeviceId(): string {
const KEY = '__tracker_device_id__';
let id = localStorage.getItem(KEY);
if (!id) {
id = this.generateId();
localStorage.setItem(KEY, id);
}
return id;
}
// 会话 ID
private createSessionId(): string {
const KEY = '__tracker_session_id__';
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 分钟
const stored = sessionStorage.getItem(KEY);
if (stored) {
const { id, timestamp } = JSON.parse(stored);
if (Date.now() - timestamp < SESSION_TIMEOUT) {
sessionStorage.setItem(KEY, JSON.stringify({ id, timestamp: Date.now() }));
return id;
}
}
const id = this.generateId();
sessionStorage.setItem(KEY, JSON.stringify({ id, timestamp: Date.now() }));
return id;
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
private collectEnvironment(): TrackEvent['environment'] {
const conn = (navigator as any).connection;
return {
userAgent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
screen: `${screen.width}x${screen.height}`,
language: navigator.language,
platform: navigator.platform,
networkType: conn?.effectiveType || 'unknown',
cookieEnabled: navigator.cookieEnabled,
};
}
// SPA 自动采集初始化
private initAutoTrack(): void {
if (this.config.enableAutoTrack) {
this.setupAutoClickTracking();
}
if (this.config.enableSPA) {
this.setupSPARouting();
}
}
private setupAutoClickTracking(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const xpath = this.getXPath(target);
this.track('auto_click', {
tagName: target.tagName,
xpath,
text: target.innerText?.slice(0, 50),
className: target.className,
});
}, true);
}
// 获取元素 XPath
private getXPath(element: HTMLElement): string {
if (element.id) return `//*[@id="${element.id}"]`;
const parts: string[] = [];
let node: HTMLElement | null = element;
while (node && node.nodeType === Node.ELEMENT_NODE) {
let selector = node.tagName.toLowerCase();
if (node.id) {
selector += `#${node.id}`;
parts.unshift(selector);
break;
}
const parent = node.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(c) => c.tagName === node!.tagName
);
if (siblings.length > 1) {
const index = siblings.indexOf(node) + 1;
selector += `[${index}]`;
}
}
parts.unshift(selector);
node = node.parentElement;
}
return '/' + parts.join('/');
}
private setupSPARouting(): void {
// 见 PV/UV 统计章节
}
destroy(): void {
this.flush();
if (this.timer) clearInterval(this.timer);
}
}
// 使用示例
const tracker = new Tracker({
appId: 'my-app',
endpoint: 'https://track.example.com/collect',
batchSize: 20,
batchInterval: 5000,
enableAutoTrack: true,
enableSPA: true,
debug: process.env.NODE_ENV === 'development',
});
tracker.identify('user_12345');
tracker.setCommonProperties({ appVersion: '2.1.0', channel: 'official' });
2. 代码埋点实现
手动埋点函数
// tracker/manual.ts
class ManualTracker {
// 按钮点击埋点
trackButtonClick(buttonName: string, extra?: Properties): void {
tracker.track('button_click', {
button_name: buttonName,
page_module: this.getCurrentModule(),
...extra,
});
}
// 页面停留时长
private enterTime: Record<string, number> = {};
trackPageEnter(pageName: string): void {
this.enterTime[pageName] = Date.now();
tracker.track('page_enter', { page_name: pageName });
}
trackPageLeave(pageName: string): void {
const enterTime = this.enterTime[pageName];
if (enterTime) {
const duration = Date.now() - enterTime;
tracker.track('page_leave', {
page_name: pageName,
duration_ms: duration,
duration_sec: Math.round(duration / 1000),
});
delete this.enterTime[pageName];
}
}
// 接口请求埋点
trackApiRequest(apiName: string, duration: number, success: boolean, extra?: Properties): void {
tracker.track('api_request', {
api_name: apiName,
duration_ms: duration,
success,
...extra,
});
}
private getCurrentModule(): string {
const path = location.pathname;
const segments = path.split('/').filter(Boolean);
return segments[0] || 'home';
}
}
装饰器埋点(TypeScript)
// tracker/decorator.ts
function track(eventName: string, getProperties?: (...args: any[]) => Properties) {
return function (
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const result = originalMethod.apply(this, args);
const properties = getProperties ? getProperties(...args) : {};
tracker.track(eventName, properties);
return result;
};
return descriptor;
};
}
// 使用示例
class OrderService {
@track('order_submit', (order) => ({
order_id: order.id,
amount: order.amount,
item_count: order.items.length,
}))
submitOrder(order: Order): Promise<Result> {
return api.post('/orders', order);
}
@track('order_cancel', (orderId, reason) => ({
order_id: orderId,
cancel_reason: reason,
}))
cancelOrder(orderId: string, reason: string): Promise<Result> {
return api.delete(`/orders/${orderId}`);
}
@track('order_pay', (orderId, method) => ({
order_id: orderId,
payment_method: method,
}))
payOrder(orderId: string, method: string): Promise<Result> {
return api.post(`/orders/${orderId}/pay`, { method });
}
}
React Hook 埋点
// tracker/react.ts
import { useEffect, useRef, useCallback } from 'react';
// 点击埋点 Hook
function useTrackClick(eventName: string, properties?: Properties) {
return useCallback(() => {
tracker.track(eventName, properties);
}, [eventName, properties]);
}
// 使用示例
function BuyButton({ productId, price }: { productId: string; price: number }) {
const handleClick = useTrackClick('buy_button_click', {
product_id: productId,
price,
});
return <button onClick={handleClick}>立即购买</button>;
}
// 页面浏览埋点 Hook
function useTrackPageView(pageName: string, properties?: Properties) {
useEffect(() => {
tracker.pageView({ page_name: pageName, ...properties });
}, [pageName]); // 路由变化时重新触发
}
// 页面停留时长 Hook
function useTrackPageDuration(pageName: string) {
const enterTimeRef = useRef(Date.now());
useEffect(() => {
enterTimeRef.current = Date.now();
tracker.track('page_enter', { page_name: pageName });
return () => {
const duration = Date.now() - enterTimeRef.current;
tracker.track('page_leave', {
page_name: pageName,
duration_ms: duration,
});
};
}, [pageName]);
}
// 曝光埋点 Hook
function useTrackExposure(
ref: React.RefObject<HTMLElement>,
eventName: string,
properties?: Properties,
options?: { threshold?: number; trackOnce?: boolean }
) {
const trackedRef = useRef(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
if (options?.trackOnce && trackedRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
if (options?.trackOnce && trackedRef.current) return;
trackedRef.current = true;
tracker.track(eventName, properties);
}
},
{ threshold: options?.threshold ?? 0.5 }
);
observer.observe(element);
return () => observer.disconnect();
}, [ref, eventName]);
}
// 使用示例
function ProductCard({ product }: { product: Product }) {
const cardRef = useRef<HTMLDivElement>(null);
useTrackExposure(cardRef, 'product_card_expose', {
product_id: product.id,
product_name: product.name,
});
return (
<div ref={cardRef}>
<h3>{product.name}</h3>
<p>¥{product.price}</p>
</div>
);
}
// 组合使用
function ProductPage() {
useTrackPageView('product_page');
useTrackPageDuration('product_page');
return <ProductList />;
}
Vue 指令埋点
// tracker/vue.ts
import type { App, Directive, DirectiveBinding } from 'vue';
// v-track 点击埋点指令
const trackDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { arg, value } = binding;
switch (arg) {
case 'click':
el.addEventListener('click', () => {
tracker.track('element_click', typeof value === 'object' ? value : { name: value });
});
break;
case 'expose':
setupExposure(el, value);
break;
case 'input':
el.addEventListener('change', () => {
tracker.track('input_change', typeof value === 'object' ? value : { name: value });
});
break;
}
},
};
// 曝光指令
function setupExposure(el: HTMLElement, value: any) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
tracker.track('element_expose', typeof value === 'object' ? value : { name: value });
observer.disconnect(); // 只曝光一次
}
},
{ threshold: 0.5 }
);
observer.observe(el);
}
// 注册插件
export const TrackerPlugin = {
install(app: App) {
app.directive('track', trackDirective);
},
};
// 使用示例
// <button v-track:click="{ button_name: 'submit', module: 'form' }">提交</button>
// <div v-track:expose="{ element_name: 'banner', position: 'top' }">广告位</div>
// <input v-track:input="{ input_name: 'search' }" />
3. 无痕埋点
全局事件代理
// tracker/auto-track.ts
class AutoTracker {
private trackedElements = new WeakSet<Element>();
init(): void {
this.proxyClickEvents();
this.proxyInputEvents();
this.autoTrackPV();
this.trackPerformance();
}
// 代理所有点击事件
private proxyClickEvents(): void {
document.addEventListener(
'click',
(e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target) return;
// 向上查找带埋点标识的元素
const trackEl = target.closest('[data-track]') as HTMLElement;
if (trackEl) {
// 有 data-track 属性的元素做精确埋点
const trackName = trackEl.dataset.track;
const trackParams = trackEl.dataset.trackParams;
tracker.track(trackName || 'click', {
...this.parseParams(trackParams),
element_text: trackEl.innerText?.slice(0, 50),
});
return;
}
// 无标识元素做自动采集
const xpath = this.getXPath(target);
const selector = this.getCSSSelector(target);
tracker.track('auto_click', {
xpath,
selector,
tag_name: target.tagName.toLowerCase(),
element_text: target.innerText?.slice(0, 50),
element_class: target.className?.toString().slice(0, 100),
element_id: target.id || undefined,
page_x: e.pageX,
page_y: e.pageY,
viewport_x: e.clientX,
viewport_y: e.clientY,
});
},
true // 捕获阶段,最先执行
);
}
// 代理输入事件
private proxyInputEvents(): void {
document.addEventListener(
'focus',
(e: FocusEvent) => {
const target = e.target as HTMLInputElement;
if (!target || !target.tagName) return;
if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
tracker.track('auto_input_focus', {
tag_name: target.tagName.toLowerCase(),
input_type: target.type,
input_name: target.name,
xpath: this.getXPath(target),
});
},
true
);
}
// 自动采集 PV
private autoTrackPV(): void {
tracker.pageView();
}
// 解析 data-track-params
private parseParams(paramsStr?: string): Properties {
if (!paramsStr) return {};
try {
return JSON.parse(paramsStr);
} catch {
return { raw_params: paramsStr };
}
}
// CSS 选择器定位
private getCSSSelector(el: HTMLElement): string {
const parts: string[] = [];
let node: HTMLElement | null = el;
while (node && node !== document.body) {
let selector = node.tagName.toLowerCase();
if (node.id) {
selector += `#${CSS.escape(node.id)}`;
parts.unshift(selector);
break;
}
if (node.className && typeof node.className === 'string') {
const classes = node.className.trim().split(/\s+/).slice(0, 2);
if (classes.length) selector += '.' + classes.map(CSS.escape).join('.');
}
const parent = node.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(c) => c.tagName === node!.tagName
);
if (siblings.length > 1) {
const index = siblings.indexOf(node) + 1;
selector += `:nth-of-type(${index})`;
}
}
parts.unshift(selector);
node = node.parentElement;
}
return parts.join(' > ');
}
// XPath 定位(复用前文方法)
private getXPath(element: HTMLElement): string {
if (element.id) return `//*[@id="${element.id}"]`;
const parts: string[] = [];
let node: HTMLElement | null = element;
while (node && node.nodeType === Node.ELEMENT_NODE) {
let selector = node.tagName.toLowerCase();
if (node.id) {
selector += `#${node.id}`;
parts.unshift(selector);
break;
}
const parent = node.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(c) => c.tagName === node!.tagName
);
if (siblings.length > 1) {
const index = siblings.indexOf(node) + 1;
selector += `[${index}]`;
}
}
parts.unshift(selector);
node = node.parentElement;
}
return '/' + parts.join('/');
}
// 数据量控制:采样 + 限流
private sendQueue: TrackEvent[] = [];
private maxQPS = 5; // 每秒最多上报 5 条
private lastSendTime = 0;
private throttledTrack(event: TrackEvent): void {
const now = Date.now();
if (now - this.lastSendTime < 1000 / this.maxQPS) {
// 限流,丢弃或入队
return;
}
this.lastSendTime = now;
this.sendQueue.push(event);
}
}
HTML data 属性约定
<!-- 精确埋点:data-track 指定事件名 -->
<button data-track="submit_order" data-track-params='{"from":"cart"}'>
提交订单
</button>
<!-- 自动采集:无 data-track 的元素由 SDK 自动采集 -->
<div class="product-card">
<h3>商品名称</h3>
<button>加入购物车</button>
</div>
<!-- 曝光埋点:data-track-expose -->
<div data-track-expose="banner_expose" data-track-params='{"banner_id":"home_top"}'>
<img src="banner.jpg" alt="活动横幅" />
</div>
4. 曝光埋点
IntersectionObserver 实现
// tracker/exposure.ts
interface ExposureOptions {
threshold?: number; // 可见比例阈值,默认 0.5(50% 可见)
trackOnce?: boolean; // 是否只曝光一次,默认 true
minDuration?: number; // 最小曝光时长(ms),默认 0
rootMargin?: string; // 观察区域边距
}
interface ExposureTarget {
element: HTMLElement;
eventName: string;
properties: Properties;
options: Required<ExposureOptions>;
enterTime: number | null; // 进入视口的时间
hasTracked: boolean; // 是否已上报
}
class ExposureTracker {
private observer: IntersectionObserver;
private targets = new Map<HTMLElement, ExposureTarget>();
constructor() {
this.observer = new IntersectionObserver(
(entries) => this.handleIntersection(entries),
{
threshold: [0, 0.25, 0.5, 0.75, 1],
rootMargin: '0px',
}
);
}
// 注册曝光监听
observe(
element: HTMLElement,
eventName: string,
properties: Properties = {},
options: ExposureOptions = {}
): void {
const fullOptions: Required<ExposureOptions> = {
threshold: 0.5,
trackOnce: true,
minDuration: 0,
rootMargin: '0px',
...options,
};
this.targets.set(element, {
element,
eventName,
properties,
options: fullOptions,
enterTime: null,
hasTracked: false,
});
this.observer.observe(element);
}
// 取消监听
unobserve(element: HTMLElement): void {
this.targets.delete(element);
this.observer.unobserve(element);
}
// 处理交叉变化
private handleIntersection(entries: IntersectionObserverEntry[]): void {
const now = Date.now();
for (const entry of entries) {
const target = this.targets.get(entry.target as HTMLElement);
if (!target) continue;
if (target.hasTracked && target.options.trackOnce) continue;
const isVisible = entry.intersectionRatio >= target.options.threshold;
if (isVisible && target.enterTime === null) {
// 进入视口
target.enterTime = now;
} else if (!isVisible && target.enterTime !== null) {
// 离开视口
const duration = now - target.enterTime;
target.enterTime = null;
// 判断是否为有效曝光
if (duration >= target.options.minDuration) {
this.reportExposure(target, duration);
}
}
}
}
// 上报曝光事件
private reportExposure(target: ExposureTarget, duration: number): void {
target.hasTracked = true;
tracker.track(target.eventName, {
...target.properties,
exposure_duration_ms: duration,
exposure_duration_sec: Math.round(duration / 1000),
is_valid_exposure: true,
});
if (target.options.trackOnce) {
this.unobserve(target.element);
}
}
// 销毁
destroy(): void {
this.observer.disconnect();
this.targets.clear();
}
}
列表曝光(虚拟滚动场景)
// tracker/list-exposure.ts
import { useEffect, useRef } from 'react';
interface ListExposureOptions {
threshold?: number;
minDuration?: number;
trackOnce?: boolean;
}
function useTrackListExposure<T>(
listRef: React.RefObject<HTMLElement>,
items: T[],
getItemId: (item: T) => string,
eventName: string,
getProperties: (item: T) => Properties,
options?: ListExposureOptions
) {
const exposureTracker = useRef(new ExposureTracker());
const trackedIds = useRef(new Set<string>());
useEffect(() => {
const container = listRef.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.index);
const item = items[index];
if (!item) continue;
const itemId = getItemId(item);
if (entry.isIntersecting) {
if (options?.trackOnce && trackedIds.current.has(itemId)) continue;
trackedIds.current.add(itemId);
tracker.track(eventName, {
...getProperties(item),
item_index: index,
item_id: itemId,
list_length: items.length,
});
}
}
},
{ threshold: options?.threshold ?? 0.5 }
);
// 观察列表中每个子元素
const children = container.children;
for (let i = 0; i < children.length; i++) {
(children[i] as HTMLElement).dataset.index = String(i);
observer.observe(children[i]);
}
return () => observer.disconnect();
}, [items]);
}
// 使用示例:商品列表曝光
function ProductList({ products }: { products: Product[] }) {
const listRef = useRef<HTMLDivElement>(null);
useTrackListExposure(
listRef,
products,
(p) => p.id,
'product_list_expose',
(p) => ({ product_id: p.id, product_name: p.name, price: p.price }),
{ trackOnce: true, minDuration: 500 }
);
return (
<div ref={listRef} className="product-list">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
5. PV/UV 统计
SPA 路由变化监听
// tracker/spa-pv.ts
class SPAPVTracker {
private currentPath: string = '';
private enterTime: number = 0;
init(): void {
// 首次加载
this.trackPV();
// 监听 history 变化
this.patchHistoryAPI();
// 监听 hash 变化
window.addEventListener('hashchange', () => {
this.trackPV();
});
// 监听 popstate(浏览器前进/后退)
window.addEventListener('popstate', () => {
this.trackPV();
});
}
// 劫持 history.pushState / replaceState
private patchHistoryAPI(): void {
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
originalPushState(...args);
this.trackPV();
};
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
originalReplaceState(...args);
this.trackPV();
};
}
// 记录 PV 并计算页面停留时长
private trackPV(): void {
const now = Date.now();
const currentPath = location.pathname + location.search;
// 上报上一页面的停留时长
if (this.currentPath && this.enterTime) {
const duration = now - this.enterTime;
tracker.track('page_leave', {
page_path: this.currentPath,
duration_ms: duration,
});
}
// 上报新页面 PV
this.currentPath = currentPath;
this.enterTime = now;
tracker.track('page_view', {
page_path: currentPath,
page_url: location.href,
referrer: document.referrer,
page_title: document.title,
});
}
}
Vue Router 集成
// tracker/vue-router.ts
import type { Router } from 'vue-router';
function setupVueRouterTracking(router: Router) {
router.afterEach((to, from) => {
tracker.track('page_view', {
page_path: to.path,
page_name: to.name as string,
page_url: location.href,
referrer: from.fullPath,
page_title: document.title,
route_params: JSON.stringify(to.params),
query_params: JSON.stringify(to.query),
});
});
}
// main.ts
const router = createRouter({ ... });
setupVueRouterTracking(router);
React Router 集成
// tracker/react-router.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteTracker() {
const location = useLocation();
useEffect(() => {
tracker.track('page_view', {
page_path: location.pathname,
page_url: location.href,
page_search: location.search,
page_hash: location.hash,
referrer: document.referrer,
page_title: document.title,
});
}, [location]);
return null;
}
// App.tsx
function App() {
return (
<BrowserRouter>
<RouteTracker />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/product/:id" element={<Product />} />
</Routes>
</BrowserRouter>
);
}
UV 识别方案
// tracker/uv.ts
class UVTracker {
private DEVICE_ID_KEY = '__uv_device_id__';
private SESSION_KEY = '__uv_session__';
// 设备级唯一标识(跨会话持久)
getDeviceId(): string {
let id = this.getCookie(this.DEVICE_ID_KEY);
if (!id) {
id = this.generateFingerprint();
this.setCookie(this.DEVICE_ID_KEY, id, 365); // 365 天
}
return id;
}
// 会话级标识(30 分钟超时)
getSessionId(): string {
const stored = this.getCookie(this.SESSION_KEY);
if (stored) {
const { id, ts } = JSON.parse(stored);
if (Date.now() - ts < 30 * 60 * 1000) {
this.setCookie(this.SESSION_KEY, JSON.stringify({ id, ts: Date.now() }), 1);
return id;
}
}
const id = crypto.randomUUID?.() || this.generateId();
this.setCookie(this.SESSION_KEY, JSON.stringify({ id, ts: Date.now() }), 1);
return id;
}
// 浏览器指纹生成(Canvas + WebGL + 字体 + 插件等)
private generateFingerprint(): string {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
navigator.plugins.length,
this.getCanvasFingerprint(),
];
return this.hash(components.join('||'));
}
// Canvas 指纹
private getCanvasFingerprint(): string {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('fingerprint', 2, 2);
return canvas.toDataURL().slice(-50);
}
// 简单哈希
private hash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
private getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
private setCookie(name: string, value: string, days: number): void {
const expires = new Date(Date.now() + days * 86400000).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/;SameSite=Lax`;
}
}
6. 性能数据采集
// tracker/performance.ts
class PerformanceCollector {
collect(): void {
this.collectNavigationTiming();
this.collectWebVitals();
this.collectResourceTiming();
this.collectCustomMetrics();
}
// Navigation Timing:页面加载全链路
private collectNavigationTiming(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType !== 'navigation') continue;
const nav = entry as PerformanceNavigationTiming;
tracker.track('performance_navigation', {
// 关键时间节点
dns_lookup_ms: nav.domainLookupEnd - nav.domainLookupStart,
tcp_connect_ms: nav.connectEnd - nav.connectStart,
ssl_handshake_ms: nav.secureConnectionStart
? nav.connectEnd - nav.secureConnectionStart
: 0,
ttfb_ms: nav.responseStart - nav.requestStart, // Time to First Byte
content_download_ms: nav.responseEnd - nav.responseStart,
dom_parse_ms: nav.domInteractive - nav.responseEnd,
dom_ready_ms: nav.domContentLoadedEventEnd - nav.fetchStart,
page_load_ms: nav.loadEventEnd - nav.fetchStart, // 完整加载时间
// 传输数据量
transfer_size: nav.transferSize,
encoded_body_size: nav.encodedBodySize,
decoded_body_size: nav.decodedBodySize,
// 连接信息
protocol: nav.nextHopProtocol, // h2 / h3
redirect_count: nav.redirectCount,
});
}
});
observer.observe({ type: 'navigation', buffered: true });
}
// Web Vitals:核心性能指标
private collectWebVitals(): void {
// FCP(First Contentful Paint):首次内容绘制
this.observeMetric('paint', (entry) => {
if (entry.name === 'first-contentful-paint') {
tracker.track('performance_fcp', {
fcp_ms: entry.startTime,
rating: this.rateFCP(entry.startTime),
});
}
});
// LCP(Largest Contentful Paint):最大内容绘制
this.observeMetric('largest-contentful-paint', (entry) => {
tracker.track('performance_lcp', {
lcp_ms: entry.startTime,
rating: this.rateLCP(entry.startTime),
element: entry.element?.tagName,
url: (entry as any).url || undefined,
});
});
// FID(First Input Delay):首次输入延迟
this.observeMetric('first-input', (entry) => {
const fid = (entry as any).processingStart - entry.startTime;
tracker.track('performance_fid', {
fid_ms: fid,
rating: this.rateFID(fid),
event_type: entry.name,
});
});
// CLS(Cumulative Layout Shift):累积布局偏移
let clsValue = 0;
let clsEntries: any[] = [];
let sessionValue = 0;
let sessionEntries: any[] = [];
this.observeMetric('layout-shift', (entry: any) => {
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
if (
sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = [...sessionEntries];
}
}
});
// 页面隐藏时上报 CLS
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && clsValue > 0) {
tracker.track('performance_cls', {
cls_value: clsValue,
rating: this.rateCLS(clsValue),
shift_count: clsEntries.length,
});
}
});
// INP(Interaction to Next Paint):交互到下一次绘制
this.observeMetric('event', (entry: any) => {
const duration = entry.duration;
if (duration > 0) {
tracker.track('performance_inp', {
inp_ms: duration,
event_type: entry.name,
rating: this.rateINP(duration),
});
}
});
// TTFB(Time to First Byte):首字节时间
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (navEntry) {
const ttfb = navEntry.responseStart - navEntry.requestStart;
tracker.track('performance_ttfb', {
ttfb_ms: ttfb,
rating: this.rateTTFB(ttfb),
});
}
}
// 资源加载性能
private collectResourceTiming(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const resource = entry as PerformanceResourceTiming;
// 只采集慢资源(> 1s)和关键资源
if (resource.duration > 1000 || this.isCriticalResource(resource)) {
tracker.track('performance_resource', {
name: resource.name,
type: resource.initiatorType,
duration_ms: resource.duration,
transfer_size: resource.transferSize,
protocol: resource.nextHopProtocol,
start_time: resource.startTime,
});
}
}
});
observer.observe({ type: 'resource', buffered: true });
}
// 自定义业务指标
private collectCustomMetrics(): void {
// 使用 Performance Mark & Measure
performance.mark('app_init_start');
// ... 应用初始化逻辑 ...
performance.mark('app_init_end');
performance.measure('app_init_duration', 'app_init_start', 'app_init_end');
const measures = performance.getEntriesByName('app_init_duration');
if (measures.length) {
tracker.track('performance_custom', {
metric_name: 'app_init_duration',
duration_ms: measures[0].duration,
});
}
}
private observeMetric(type: string, callback: (entry: any) => void): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
callback(entry);
}
});
observer.observe({ type, buffered: true });
} catch {
// 浏览器不支持该指标类型
}
}
private isCriticalResource(resource: PerformanceResourceTiming): boolean {
return ['script', 'link', 'xmlhttprequest', 'fetch'].includes(
resource.initiatorType
);
}
// 评级标准(基于 Google Web Vitals)
private rateFCP(ms: number): 'good' | 'needs-improvement' | 'poor' {
if (ms <= 1800) return 'good';
if (ms <= 3000) return 'needs-improvement';
return 'poor';
}
private rateLCP(ms: number): 'good' | 'needs-improvement' | 'poor' {
if (ms <= 2500) return 'good';
if (ms <= 4000) return 'needs-improvement';
return 'poor';
}
private rateFID(ms: number): 'good' | 'needs-improvement' | 'poor' {
if (ms <= 100) return 'good';
if (ms <= 300) return 'needs-improvement';
return 'poor';
}
private rateCLS(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 0.1) return 'good';
if (value <= 0.25) return 'needs-improvement';
return 'poor';
}
private rateINP(ms: number): 'good' | 'needs-improvement' | 'poor' {
if (ms <= 200) return 'good';
if (ms <= 500) return 'needs-improvement';
return 'poor';
}
private rateTTFB(ms: number): 'good' | 'needs-improvement' | 'poor' {
if (ms <= 800) return 'good';
if (ms <= 1800) return 'needs-improvement';
return 'poor';
}
}
7. 数据上报策略
// tracker/reporter.ts
interface ReporterConfig {
endpoint: string;
batchSize: number;
batchInterval: number;
maxRetryCount: number;
enableOfflineCache: boolean;
maxCacheSize: number;
}
class DataReporter {
private queue: TrackEvent[] = [];
private retryCount = 0;
private db: IDBDatabase | null = null;
private isOnline = navigator.onLine;
constructor(private config: ReporterConfig) {
this.initOfflineCache();
this.bindEvents();
}
// 入队并判断是否立即上报
enqueue(event: TrackEvent): void {
this.queue.push(event);
if (this.queue.length >= this.config.batchSize) {
this.flush();
return;
}
this.startTimer();
}
// 批量上报
async flush(): Promise<void> {
if (this.queue.length === 0) return;
if (!this.isOnline) {
await this.saveToCache(this.queue);
this.queue = [];
return;
}
const events = this.queue.splice(0);
const success = await this.send(events);
if (!success) {
// 发送失败,放回队列或缓存
this.queue.unshift(...events);
this.retryCount++;
if (this.retryCount > this.config.maxRetryCount) {
await this.saveToCache(events);
this.retryCount = 0;
}
} else {
this.retryCount = 0;
}
}
// 发送数据 — 多种策略
private async send(events: TrackEvent[]): Promise<boolean> {
const data = JSON.stringify({ events });
// 策略 1:sendBeacon(页面卸载场景优先)
if (navigator.sendBeacon) {
const blob = new Blob([data], { type: 'application/json' });
const success = navigator.sendBeacon(this.config.endpoint, blob);
if (success) return true;
}
// 策略 2:1x1 GIF 图片(跨域友好)
if (events.length <= 1) {
try {
return await this.sendViaImage(events[0]);
} catch {
// 降级
}
}
// 策略 3:XMLHttpRequest / fetch(批量上报)
return this.sendViaFetch(data);
}
// 1x1 GIF 上报
private sendViaImage(event: TrackEvent): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const params = new URLSearchParams({
d: JSON.stringify(event),
});
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = `${this.config.endpoint}/gif?${params.toString()}`;
});
}
// Fetch 上报
private async sendViaFetch(data: string): Promise<boolean> {
try {
const response = await fetch(this.config.endpoint, {
method: 'POST',
body: data,
headers: { 'Content-Type': 'application/json' },
keepalive: true, // 页面卸载时仍能发送
});
return response.ok;
} catch {
return false;
}
}
// 页面卸载前上报
private flushOnUnload(): void {
if (this.queue.length === 0) return;
const data = JSON.stringify({ events: this.queue });
this.queue = [];
// sendBeacon 是页面卸载时最可靠的上报方式
if (navigator.sendBeacon) {
const blob = new Blob([data], { type: 'application/json' });
navigator.sendBeacon(this.config.endpoint, blob);
}
}
// 离线缓存(IndexedDB)
private async initOfflineCache(): Promise<void> {
if (!this.config.enableOfflineCache) return;
if (!window.indexedDB) return;
const request = indexedDB.open('__tracker_offline__', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('events')) {
db.createObjectStore('events', { autoIncrement: true });
}
};
request.onsuccess = () => {
this.db = request.result;
this.sendCachedEvents();
};
}
private async saveToCache(events: TrackEvent[]): Promise<void> {
if (!this.db) return;
const tx = this.db.transaction('events', 'readwrite');
const store = tx.objectStore('events');
for (const event of events) {
store.add(event);
}
}
private async sendCachedEvents(): Promise<void> {
if (!this.db || !this.isOnline) return;
const tx = this.db.transaction('events', 'readwrite');
const store = tx.objectStore('events');
const request = store.getAll();
request.onsuccess = async () => {
const events = request.result as TrackEvent[];
if (events.length === 0) return;
const success = await this.send(events);
if (success) {
store.clear(); // 发送成功后清除缓存
}
};
}
// 绑定网络状态与页面卸载事件
private bindEvents(): void {
window.addEventListener('online', () => {
this.isOnline = true;
this.sendCachedEvents();
this.flush();
});
window.addEventListener('offline', () => {
this.isOnline = false;
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flushOnUnload();
}
});
window.addEventListener('pagehide', () => {
this.flushOnUnload();
});
}
private timer: ReturnType<typeof setTimeout> | null = null;
private startTimer(): void {
if (this.timer) return;
this.timer = setTimeout(() => {
this.flush();
this.timer = null;
}, this.config.batchInterval);
}
}
上报策略对比
| 方式 | 页面卸载安全 | 跨域 | 体积限制 | 适用场景 |
|---|---|---|---|---|
| sendBeacon | 安全 | 需 CORS | 64KB | 页面卸载上报、批量数据 |
| 1x1 GIF | 不安全 | 无限制 | URL 长度限制(~2KB) | 单条轻量数据、第三方统计 |
| fetch + keepalive | 安全 | 需 CORS | 64KB | 现代浏览器批量上报 |
| XMLHttpRequest | 不安全 | 需 CORS | 无限制 | 大批量数据、需响应处理 |
8. 数据处理与分析
漏斗分析
用户路径:
首页 → 搜索结果 → 商品详情 → 加入购物车 → 提交订单 → 支付成功
各步骤转化:
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ 首页 │──▶│ 搜索 │──▶│ 详情 │──▶│ 购物车│──▶│ 下单 │──▶│ 支付 │
│10000 │ │ 8000 │ │ 6000 │ │ 3000 │ │ 2000 │ │ 1800 │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
100% 80% 60% 30% 20% 18%
步骤转化率:
首页→搜索:80% 搜索→详情:75% 详情→购物车:50%
购物车→下单:66.7% 下单→支付:90%
整体转化率:18%(10000 → 1800)
最大流失点:详情→购物车(50% 流失)
// analyzer/funnel.ts
interface FunnelStep {
eventName: string;
displayName: string;
}
interface FunnelResult {
steps: Array<{
eventName: string;
displayName: string;
count: number;
conversionRate: number; // 该步相对上一步的转化率
overallConversionRate: number; // 该步相对第一步的转化率
dropOffRate: number; // 该步相对上一步的流失率
}>;
totalConversionRate: number;
}
class FunnelAnalyzer {
analyze(
events: TrackEvent[],
steps: FunnelStep[],
timeWindow: number = 7 * 24 * 60 * 60 * 1000 // 7 天窗口
): FunnelResult {
// 按用户分组
const userEvents = this.groupByUser(events);
let firstStepCount = 0;
const stepCounts = new Array(steps.length).fill(0);
for (const [, userEventList] of userEvents) {
// 按时间排序
userEventList.sort((a, b) => a.timestamp - b.timestamp);
let stepIndex = 0;
let lastStepTime = 0;
for (const event of userEventList) {
if (stepIndex >= steps.length) break;
// 检查是否超时
if (lastStepTime && event.timestamp - lastStepTime > timeWindow) {
break;
}
if (event.eventName === steps[stepIndex].eventName) {
if (stepIndex === 0) firstStepCount++;
stepCounts[stepIndex]++;
lastStepTime = event.timestamp;
stepIndex++;
}
}
}
const result = steps.map((step, index) => ({
eventName: step.eventName,
displayName: step.displayName,
count: stepCounts[index],
conversionRate: index === 0
? 1
: stepCounts[index - 1] > 0
? stepCounts[index] / stepCounts[index - 1]
: 0,
overallConversionRate: firstStepCount > 0
? stepCounts[index] / firstStepCount
: 0,
dropOffRate: index === 0
? 0
: stepCounts[index - 1] > 0
? 1 - stepCounts[index] / stepCounts[index - 1]
: 0,
}));
return {
steps: result,
totalConversionRate: firstStepCount > 0
? stepCounts[steps.length - 1] / firstStepCount
: 0,
};
}
private groupByUser(events: TrackEvent[]): Map<string, TrackEvent[]> {
const map = new Map<string, TrackEvent[]>();
for (const event of events) {
const userId = event.userId || event.deviceId;
if (!map.has(userId)) map.set(userId, []);
map.get(userId)!.push(event);
}
return map;
}
}
路径分析
// analyzer/path.ts
interface PathNode {
page: string;
count: number;
}
interface PathEdge {
from: string;
to: string;
count: number;
conversionRate: number;
}
class PathAnalyzer {
analyze(events: TrackEvent[]): { nodes: PathNode[]; edges: PathEdge[] } {
const pageViews = events
.filter((e) => e.eventName === 'page_view')
.sort((a, b) => a.timestamp - b.timestamp);
// 按用户分组
const userPaths = this.groupByUser(pageViews);
// 统计页面访问次数
const nodeMap = new Map<string, number>();
// 统计页面跳转次数
const edgeMap = new Map<string, number>();
for (const [, views] of userPaths) {
for (let i = 0; i < views.length; i++) {
const page = views[i].pagePath;
nodeMap.set(page, (nodeMap.get(page) || 0) + 1);
if (i < views.length - 1) {
const nextPage = views[i + 1].pagePath;
const edgeKey = `${page}→${nextPage}`;
edgeMap.set(edgeKey, (edgeMap.get(edgeKey) || 0) + 1);
}
}
}
const nodes: PathNode[] = Array.from(nodeMap.entries())
.map(([page, count]) => ({ page, count }))
.sort((a, b) => b.count - a.count);
const edges: PathEdge[] = Array.from(edgeMap.entries())
.map(([key, count]) => {
const [from, to] = key.split('→');
const fromCount = nodeMap.get(from) || 0;
return {
from,
to,
count,
conversionRate: fromCount > 0 ? count / fromCount : 0,
};
})
.sort((a, b) => b.count - a.count);
return { nodes, edges };
}
private groupByUser(events: TrackEvent[]): Map<string, TrackEvent[]> {
const map = new Map<string, TrackEvent[]>();
for (const event of events) {
const userId = event.userId || event.deviceId;
if (!map.has(userId)) map.set(userId, []);
map.get(userId)!.push(event);
}
return map;
}
}
留存分析
// analyzer/retention.ts
interface RetentionResult {
cohortDate: string; // 分组日期
cohortSize: number; // 分组人数
retention: Record<number, number>; // { 第N天: 留存率 }
}
class RetentionAnalyzer {
analyze(
events: TrackEvent[],
startDate: Date,
endDate: Date,
retentionDays: number[] = [1, 3, 7, 14, 30]
): RetentionResult[] {
// 找出每日新增用户
const dailyNewUsers = this.getDailyNewUsers(events, startDate, endDate);
// 找出每日活跃用户
const dailyActiveUsers = this.getDailyActiveUsers(events, startDate, endDate);
return Array.from(dailyNewUsers.entries()).map(([date, newUsers]) => {
const retention: Record<number, number> = {};
for (const day of retentionDays) {
const targetDate = this.addDays(date, day);
const activeOnTargetDate = dailyActiveUsers.get(targetDate) || new Set<string>();
const retained = newUsers.intersection(activeOnTargetDate).size;
retention[day] = newUsers.size > 0 ? retained / newUsers.size : 0;
}
return {
cohortDate: date,
cohortSize: newUsers.size,
retention,
};
});
}
private getDailyNewUsers(
events: TrackEvent[],
start: Date,
end: Date
): Map<string, Set<string>> {
const result = new Map<string, Set<string>>();
const userFirstSeen = new Map<string, string>();
for (const event of events) {
const userId = event.userId || event.deviceId;
const date = this.toDateString(event.timestamp);
if (!userFirstSeen.has(userId)) {
userFirstSeen.set(userId, date);
}
}
for (const [userId, date] of userFirstSeen) {
if (!result.has(date)) result.set(date, new Set());
result.get(date)!.add(userId);
}
return result;
}
private getDailyActiveUsers(
events: TrackEvent[],
start: Date,
end: Date
): Map<string, Set<string>> {
const result = new Map<string, Set<string>>();
for (const event of events) {
const userId = event.userId || event.deviceId;
const date = this.toDateString(event.timestamp);
if (!result.has(date)) result.set(date, new Set());
result.get(date)!.add(userId);
}
return result;
}
private toDateString(timestamp: number): string {
return new Date(timestamp).toISOString().slice(0, 10);
}
private addDays(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
}
热力图数据采集
// tracker/heatmap.ts
interface HeatmapPoint {
x: number; // 相对页面宽度的百分比
y: number; // 相对页面高度的百分比
pageX: number; // 绝对页面坐标
pageY: number;
type: 'click' | 'move' | 'scroll';
timestamp: number;
}
class HeatmapCollector {
private points: HeatmapPoint[] = [];
private moveThrottleTimer: number | null = null;
private maxPoints = 5000; // 限制数据量
init(): void {
this.collectClicks();
this.collectMouseMove();
this.collectScrollDepth();
}
private collectClicks(): void {
document.addEventListener('click', (e: MouseEvent) => {
this.addPoint({
x: (e.clientX / window.innerWidth) * 100,
y: (e.clientY / window.innerHeight) * 100,
pageX: e.pageX,
pageY: e.pageY,
type: 'click',
timestamp: Date.now(),
});
}, true);
}
private collectMouseMove(): void {
document.addEventListener('mousemove', (e: MouseEvent) => {
if (this.moveThrottleTimer) return;
this.moveThrottleTimer = window.setTimeout(() => {
this.moveThrottleTimer = null;
}, 500); // 500ms 节流
this.addPoint({
x: (e.clientX / window.innerWidth) * 100,
y: (e.clientY / window.innerHeight) * 100,
pageX: e.pageX,
pageY: e.pageY,
type: 'move',
timestamp: Date.now(),
});
});
}
private collectScrollDepth(): void {
let maxScrollPercent = 0;
window.addEventListener('scroll', () => {
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = scrollHeight > 0
? (window.scrollY / scrollHeight) * 100
: 0;
if (scrollPercent > maxScrollPercent) {
maxScrollPercent = scrollPercent;
// 每达到 25% 的整数倍时上报
const milestones = [25, 50, 75, 100];
for (const milestone of milestones) {
if (scrollPercent >= milestone && maxScrollPercent < milestone) {
tracker.track('scroll_depth', { depth_percent: milestone });
}
}
}
});
}
private addPoint(point: HeatmapPoint): void {
if (this.points.length >= this.maxPoints) return;
this.points.push(point);
}
// 上报热力图数据
flush(): void {
if (this.points.length === 0) return;
tracker.track('heatmap_data', {
page_width: window.innerWidth,
page_height: document.documentElement.scrollHeight,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
points: this.points,
});
this.points = [];
}
}
9. 常用分析平台
| 平台 | 类型 | 特点 | 适用场景 |
|---|---|---|---|
| Google Analytics (GA4) | 免费通用 | 全球最大、功能全面、与 Google 生态打通 | 海外业务、通用流量分析 |
| 神策数据 | 国内商业 | 私有化部署、全埋点、SQL 查询 | 国内企业级、数据安全要求高 |
| GrowingIO | 国内商业 | 全埋点领先、可视化分析强 | 产品增长分析、运营驱动 |
| Mixpanel | 海外商业 | 事件驱动、用户分群强 | SaaS 产品、用户行为分析 |
| Amplitude | 海外商业 | 行为队列分析、实时流式 | 产品分析、增长黑客 |
| 自建方案 | 自研 | 完全可控、定制化强 | 大厂、有专属数据团队 |
自建埋点方案架构
前端 SDK → Nginx 日志 → Kafka → Flink/Spark → 数据仓库 → BI 工具
↓
Node.js 日志服务
↓
ClickHouse
↓
数据查询 API
↓
内部 Dashboard
// 自建日志收集服务(Node.js)
// server/collect.ts
import express from 'express';
import { Kafka } from 'kafkajs';
const app = express();
app.use(express.json({ limit: '1mb' }));
const kafka = new Kafka({ brokers: ['kafka:9092'] });
const producer = kafka.producer();
app.post('/collect', async (req, res) => {
const { appId, events } = req.body;
// 基本校验
if (!appId || !Array.isArray(events)) {
return res.status(400).json({ error: 'Invalid payload' });
}
// 写入 Kafka
try {
await producer.send({
topic: `tracking-${appId}`,
messages: events.map((event: TrackEvent) => ({
key: event.deviceId,
value: JSON.stringify({
...event,
serverTimestamp: Date.now(),
ip: req.ip,
}),
})),
});
} catch (err) {
console.error('Kafka write failed:', err);
}
// 204 无内容响应,减少带宽
res.status(204).end();
});
app.listen(3001);
10. 隐私合规
GDPR/CCPA 合规要点
| 法规 | 地区 | 核心要求 | 违规处罚 |
|---|---|---|---|
| GDPR | 欧盟 | 用户明确同意、数据可删除、数据可携带 | 全球营收 4% 或 2000 万欧元 |
| CCPA | 加州 | 用户有权拒绝数据出售、有权删除数据 | 每次违规 $7,500 |
| 个人信息保护法 | 中国 | 最小必要原则、明示同意、安全存储 | 没收违法所得 + 罚款 |
数据脱敏
// tracker/privacy.ts
class PrivacyManager {
// 数据脱敏规则
private sensitiveFields = [
'password', 'passwd', 'pwd',
'phone', 'mobile', 'telephone',
'email', 'mail',
'id_card', 'idcard', 'identity',
'credit_card', 'card_number',
'address', 'addr',
'token', 'secret', 'api_key',
];
// 对事件数据进行脱敏
sanitize(data: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (this.isSensitiveField(key)) {
result[key] = this.maskValue(value);
} else if (typeof value === 'object' && value !== null) {
result[key] = this.sanitize(value as Record<string, unknown>);
} else if (typeof value === 'string' && this.looksSensitive(value)) {
result[key] = this.maskValue(value);
} else {
result[key] = value;
}
}
return result;
}
private isSensitiveField(key: string): boolean {
const lowerKey = key.toLowerCase();
return this.sensitiveFields.some((field) => lowerKey.includes(field));
}
// 检测值是否像敏感数据
private looksSensitive(value: string): boolean {
// 手机号
if (/^1[3-9]\d{9}$/.test(value)) return true;
// 邮箱
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return true;
// 身份证号
if (/^\d{17}[\dXx]$/.test(value)) return true;
// 银行卡号
if (/^\d{16,19}$/.test(value)) return true;
return false;
}
private maskValue(value: unknown): string {
const str = String(value);
if (str.length <= 2) return '**';
if (str.length <= 6) return str[0] + '****' + str[str.length - 1];
return str.slice(0, 2) + '****' + str.slice(-2);
}
}
用户同意管理
// tracker/consent.ts
interface ConsentStatus {
necessary: boolean; // 必要 Cookie(始终开启)
analytics: boolean; // 分析 Cookie
marketing: boolean; // 营销 Cookie
preferences: boolean; // 偏好 Cookie
}
class ConsentManager {
private CONSENT_KEY = '__tracker_consent__';
// 获取用户同意状态
getConsent(): ConsentStatus {
const stored = localStorage.getItem(this.CONSENT_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
// 无效数据,需要重新获取同意
}
}
return { necessary: true, analytics: false, marketing: false, preferences: false };
}
// 用户授予同意
grantConsent(categories: Partial<ConsentStatus>): void {
const consent = { ...this.getConsent(), ...categories };
localStorage.setItem(this.CONSENT_KEY, JSON.stringify(consent));
// 根据同意状态启用/禁用埋点
if (consent.analytics) {
tracker.setCommonProperties({ consent_analytics: true });
} else {
// 禁用分析类埋点
tracker.use({
name: 'consent-filter',
beforeTrack: (event) => {
if (event.eventName.startsWith('analytics_')) return null;
return event;
},
});
}
}
// 用户撤回同意
revokeConsent(): void {
localStorage.removeItem(this.CONSENT_KEY);
// 清除已设置的 Cookie
this.clearTrackingCookies();
}
// 检查是否需要弹出同意对话框
needsConsent(): boolean {
// GDPR:欧盟用户必须获取同意
// CCPA:加州用户必须提供"拒绝"选项
return !localStorage.getItem(this.CONSENT_KEY);
}
private clearTrackingCookies(): void {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const name = cookie.split('=')[0].trim();
if (name.startsWith('__tracker_') || name.startsWith('_ga') || name.startsWith('_gid')) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}
}
}
}
11. AB 测试与埋点
// tracker/ab-test.ts
interface Experiment {
id: string; // 实验ID
name: string; // 实验名称
variants: Variant[]; // 实验变体
trafficPercent: number; // 流量分配比例 0-1
metrics: string[]; // 观测指标
}
interface Variant {
id: string; // 变体ID
name: string; // 变体名称(control / treatment)
weight: number; // 权重(流量分配比例)
}
class ABTestManager {
private experiments = new Map<string, Experiment>();
private userVariants = new Map<string, Map<string, string>>(); // userId → experimentId → variantId
private STORAGE_KEY = '__ab_variants__';
// 注册实验
registerExperiment(experiment: Experiment): void {
this.experiments.set(experiment.id, experiment);
}
// 为用户分配变体
getVariant(experimentId: string, userId?: string): string {
const experiment = this.experiments.get(experimentId);
if (!experiment) return 'control';
const uid = userId || tracker.getDeviceId?.() || 'anonymous';
// 检查是否已分配
const existing = this.getUserVariant(uid, experimentId);
if (existing) return existing;
// 流量控制:不在实验流量范围内的用户返回默认
if (!this.isInExperiment(uid, experiment)) {
return 'control';
}
// 按权重分配变体
const variant = this.assignVariant(uid, experiment);
this.saveVariant(uid, experimentId, variant);
// 上报实验分组
tracker.track('ab_experiment_assign', {
experiment_id: experiment.id,
experiment_name: experiment.name,
variant_id: variant,
is_new_assignment: true,
});
return variant;
}
// 判断用户是否在实验流量内
private isInExperiment(userId: string, experiment: Experiment): boolean {
const hash = this.hashUserId(userId, experiment.id);
return hash < experiment.trafficPercent;
}
// 分配变体(加权随机)
private assignVariant(userId: string, experiment: Experiment): string {
const hash = this.hashUserId(userId, experiment.id + '_variant');
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (hash < cumulative) return variant.id;
}
return experiment.variants[0].id;
}
// 一致性哈希:同一用户总是分配到同一变体
private hashUserId(userId: string, salt: string): number {
const str = `${userId}:${salt}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return (Math.abs(hash) % 10000) / 10000; // 0-1 之间
}
// 记录实验指标
trackMetric(experimentId: string, metricName: string, value: number): void {
const variant = this.getUserVariant(
tracker.getUserId?.() || 'anonymous',
experimentId
);
if (!variant) return;
tracker.track('ab_metric', {
experiment_id: experimentId,
variant_id: variant,
metric_name: metricName,
metric_value: value,
});
}
// 显著性检验(简化版 Z-test)
calculateSignificance(
control: { count: number; sum: number; sumSquares: number },
treatment: { count: number; sum: number; sumSquares: number }
): { pValue: number; isSignificant: boolean; confidence: number } {
const mean1 = control.sum / control.count;
const mean2 = treatment.sum / treatment.count;
const var1 = control.sumSquares / control.count - mean1 * mean1;
const var2 = treatment.sumSquares / treatment.count - mean2 * mean2;
const se = Math.sqrt(var1 / control.count + var2 / treatment.count);
const zScore = (mean2 - mean1) / se;
const pValue = 2 * (1 - this.normalCDF(Math.abs(zScore)));
return {
pValue,
isSignificant: pValue < 0.05,
confidence: 1 - pValue,
};
}
// 正态分布 CDF 近似
private normalCDF(x: number): number {
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const p = 0.3275911;
const sign = x < 0 ? -1 : 1;
x = Math.abs(x) / Math.SQRT2;
const t = 1.0 / (1.0 + p * x);
const y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return 0.5 * (1.0 + sign * y);
}
private getUserVariant(userId: string, experimentId: string): string | null {
if (!this.userVariants.has(userId)) {
this.loadFromStorage(userId);
}
return this.userVariants.get(userId)?.get(experimentId) || null;
}
private saveVariant(userId: string, experimentId: string, variantId: string): void {
if (!this.userVariants.has(userId)) {
this.userVariants.set(userId, new Map());
}
this.userVariants.get(userId)!.set(experimentId, variantId);
this.persistToStorage();
}
private loadFromStorage(userId: string): void {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const data = JSON.parse(stored);
this.userVariants.set(userId, new Map(Object.entries(data)));
}
} catch { /* ignore */ }
}
private persistToStorage(): void {
const data: Record<string, Record<string, string>> = {};
for (const [userId, experiments] of this.userVariants) {
data[userId] = Object.fromEntries(experiments);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
}
}
// 使用示例
const abTest = new ABTestManager();
abTest.registerExperiment({
id: 'exp_checkout_btn_color',
name: '结账按钮颜色测试',
variants: [
{ id: 'control', name: '蓝色按钮', weight: 0.5 },
{ id: 'treatment', name: '红色按钮', weight: 0.5 },
],
trafficPercent: 0.2, // 20% 流量
metrics: ['checkout_click_rate', 'order_conversion_rate'],
});
// React 组件中使用
function CheckoutButton() {
const variant = abTest.getVariant('exp_checkout_btn_color');
const bgColor = variant === 'treatment' ? 'red' : 'blue';
return (
<button
style={{ backgroundColor: bgColor }}
onClick={() => {
abTest.trackMetric('exp_checkout_btn_color', 'checkout_click_rate', 1);
tracker.track('checkout_click', { variant });
}}
>
立即结算
</button>
);
}
常见坑点
| # | 坑点 | 表现 | 解决方案 |
|---|---|---|---|
| 1 | 埋点数据丢失 | 页面卸载时部分事件未上报 | 使用 sendBeacon + visibilitychange、批量上报 + 离线缓存 |
| 2 | SPA 路由 PV 不准 | SPA 切换路由不触发页面刷新,PV 未统计 | 劫持 history API + 监听 hashchange/popstate |
| 3 | 曝光埋点性能差 | 列表 1000+ 元素全部注册 IntersectionObserver,主线程卡顿 | 虚拟滚动 + 批量观察 + threshold 优化 + 防抖 |
| 4 | 数据量过大 | 无痕埋点全量采集,日增 TB 级数据 | 采样率控制 + 限流 + 数据分级 + 只采集关键页面 |
| 5 | 埋点与业务耦合 | 业务代码充斥埋点逻辑,难以维护 | 装饰器 / Hook / 指令封装、埋点配置化管理 |
| 6 | 公共属性污染 | setCommonProperties 设置的属性覆盖业务属性 | 属性合并采用”后覆盖前”策略,业务属性优先级更高 |
| 7 | UV 统计不准 | 隐私模式下 Cookie/localStorage 不可用,UV 虚高 | 多级标识(Cookie → localStorage → fingerprint)、服务端去重 |
| 8 | 埋点版本不一致 | 前端发版后旧版埋点仍在上报,数据口径混乱 | 埋点事件携带 SDK 版本号 + App 版本号,后端按版本过滤 |
| 9 | 跨域上报被拦截 | CORS 限制导致上报请求失败 | 服务端配置 CORS、1x1 GIF 方式兜底、代理转发 |
| 10 | 隐私合规违规 | 未获取用户同意就采集数据,被监管处罚 | 实现 ConsentManager、数据脱敏、按地区差异化策略 |
最佳实践
- 混合埋点策略 — 核心业务事件用代码埋点保证精度,辅助行为用无痕埋点降低成本
- 事件命名规范 — 采用
对象_动作格式(button_click/page_view/order_submit),全团队统一 - 属性设计前瞻 — 事件属性预留扩展位,避免频繁变更事件结构
- 埋点文档化 — 维护埋点字典(事件名、属性、类型、说明),与代码同步更新
- 上报前校验 — SDK 内置数据校验(必填字段、类型检查、枚举值限制),脏数据不入库
- 批量上报优先 — 合并多条事件为一次请求,减少网络开销,提升成功率
- 离线缓存兜底 — IndexedDB 缓存未上报数据,网络恢复后自动补发
- 采样率分级 — 核心事件 100% 采集,辅助事件 10% 采样,调试事件 1% 采样
- 性能数据独立上报 — 性能指标不与业务事件混合,避免批量延迟导致数据时效性差
- 埋点治理流程 — 上线前 Code Review 检查埋点、埋点自动化测试、上线后数据校验告警
面试题
1. 三种埋点方式(代码埋点、可视化埋点、无痕埋点)各自的优缺点是什么?
答:代码埋点的优点是灵活性最高、数据精度最好,可以采集任意自定义业务数据(如下单金额、商品 ID);缺点是侵入性强、开发成本高、每个埋点需要编码且需发版生效。可视化埋点的优点是无需编码、运营可自行配置、配置即生效;缺点是灵活性受限于圈选能力,只能基于 DOM 元素定位,无法采集复杂业务语义数据。无痕埋点的优点是接入成本极低(接入 SDK 即可)、全量采集不怕遗漏;缺点是数据精度低(需后期人工映射事件含义)、数据量极大(带来存储和计算压力)、无法采集业务特有数据(如订单金额)。
2. 无痕埋点的原理是什么?如何实现全局事件代理?
答:无痕埋点的核心原理是在 document 上通过事件捕获阶段代理所有用户交互事件。具体实现:在 document.addEventListener('click', handler, true) 注册捕获阶段监听器,这样在事件冒泡到具体元素之前就能拦截。当事件触发时,通过 event.target 获取目标元素,然后用 XPath 或 CSS Selector 定位元素在 DOM 树中的位置,记录标签名、文本内容、类名、位置坐标等信息。元素定位的两种方案:XPath 从根节点逐层描述路径(如 /html/body/div[2]/button),CSS Selector 用 id/class/tag 组合定位。两种方案各有优劣——XPath 精确但脆弱(DOM 变动易失效),CSS Selector 容错但可能不唯一。实际方案通常两者结合,优先用 id 定位,其次 XPath,最后 CSS Selector。
3. 曝光埋点如何实现?如何判定”有效曝光”?
答:曝光埋点使用 IntersectionObserver API 实现。核心流程:为需要监听曝光的元素注册 Observer,当元素进入视口且可见比例超过阈值(threshold,通常 0.5 即 50%)时触发回调。“有效曝光”的判定条件通常包括三个维度:可见比例(intersectionRatio >= threshold,默认 50%)、停留时长(minDuration,如 500ms,防止快速划过算作曝光)、曝光次数(trackOnce 控制是否只上报一次)。实现要点:进入视口时记录 enterTime,离开视口时计算 duration = Date.now() - enterTime,只有 duration >= minDuration 才上报。列表场景下需注意性能,避免对大量元素逐一注册 Observer,应结合虚拟滚动只观察可视区域内的元素。
4. SPA 应用如何准确统计 PV?为什么传统方式不准确?
答:传统 PV 统计依赖页面刷新(window.onload / performance.navigation),但 SPA 应用路由切换不会触发页面刷新,导致路由变化时 PV 不会自动统计。解决方案是监听路由变化事件,具体实现需要覆盖三种路由模式:history 模式需要劫持 history.pushState 和 history.replaceState(因为它们不触发任何事件),同时监听 popstate 事件(浏览器前进/后退);hash 模式监听 hashchange 事件。React 项目推荐在 useLocation 的 useEffect 中上报;Vue 项目推荐在 router.afterEach 钩子中上报。此外还需注意:首次加载需要单独上报一次 PV;路由切换时记录上一页面的停留时长;区分页面跳转和参数变化(同页面不同参数是否算新 PV)。
5. sendBeacon 相比其他上报方式有什么优势?有什么限制?
答:sendBeacon 的核心优势是页面卸载安全——它将数据交给浏览器在空闲时发送,不阻塞页面卸载,即使页面已经关闭数据也能成功发出。这是 XMLHttpRequest 和 fetch(不带 keepalive)做不到的,它们在页面卸载时会被浏览器取消。此外 sendBeacon 是异步的,不阻塞主线程,对用户体验无影响。限制包括:数据大小限制约 64KB(Chrome),超出会被拒绝;需要服务端配置 CORS(只能发 POST 请求);无法获取响应内容(只能知道是否成功入队);浏览器兼容性(IE 不支持)。实际策略是页面卸载场景优先使用 sendBeacon,普通场景使用 fetch + keepalive 或 XMLHttpRequest,1x1 GIF 作为兼容兜底。
6. 漏斗分析的流程是什么?如何定位流失环节?
答:漏斗分析流程分五步:第一步定义漏斗步骤——确定关键路径上的事件序列(如 首页→搜索→详情→购物车→下单→支付);第二步确定时间窗口——用户必须在窗口内完成所有步骤才算有效转化(通常 7-30 天);第三步按用户分组——将同一用户的事件按时间排序,找到最早满足步骤序列的路径;第四步统计各步骤人数——计算每步到达人数、步骤转化率和整体转化率;第五步分析流失原因——对流失率最高的步骤下钻分析(用户属性、设备类型、来源渠道等维度交叉分析)。定位流失环节的关键指标是步骤流失率(1 - 步骤转化率),流失率最高的步骤就是最需要优化的环节。例如详情页到购物车流失 50%,需要分析是价格问题、体验问题还是信息不足。
7. 数据上报策略有哪些?如何保证数据不丢失?
答:上报策略包括四种方式:sendBeacon(页面卸载安全、64KB 限制、适合离场数据)、1x1 GIF(跨域简单、URL 长度限制约 2KB、适合单条轻量数据)、fetch + keepalive(现代浏览器推荐、64KB 限制、适合批量数据)、XMLHttpRequest(体积无限制、但页面卸载不安全)。保证数据不丢失的策略:批量上报——合并多条事件为一次请求,减少网络请求次数提升成功率;离线缓存——网络断开时将数据写入 IndexedDB,网络恢复后自动补发;重试机制——上报失败后指数退避重试,超过最大重试次数后持久化缓存;页面卸载上报——监听 visibilitychange(hidden 状态)和 pagehide 事件,在页面关闭前 flush 队列;数据确认——服务端返回成功确认后才能从队列移除,否则重发。
8. 前端埋点需要遵守哪些隐私合规要求?如何实现?
答:主要合规法规包括 GDPR(欧盟)、CCPA(加州)、个人信息保护法(中国)。核心要求:明示同意——采集数据前必须获取用户明确同意,不能默认开启;最小必要——只采集业务必需的数据,不超范围采集;数据可删除——用户有权要求删除其个人数据(“被遗忘权”);数据可携带——用户有权获取其数据的副本;数据安全——传输加密、存储加密、访问控制。前端实现方式:ConsentManager 管理用户同意状态,按类别(必要/分析/营销)控制埋点开关;数据脱敏——对手机号、邮箱、身份证号等敏感字段自动遮蔽(138****1234);数据分级——区分个人身份信息(PII)和匿名行为数据,PII 需更严格的保护;Cookie 政策——非必要 Cookie 需用户同意后才能设置,提供”拒绝全部”选项;地区差异化——根据用户 IP 或地区设置自动应用对应法规要求。
相关链接
- 前端监控与错误追踪 — 前端异常监控与埋点的互补关系
- BFF与Serverless — 埋点数据收集服务的 BFF 架构设计
- AI与前端结合 — AI 驱动的智能数据分析与用户行为预测
- Web Vitals 官方文档 — Google 核心性能指标标准
- IntersectionObserver MDN — 曝光监听 API 文档
- Navigator.sendBeacon MDN — 数据上报 API 文档
- 神策数据技术博客 — 国内埋点领域最佳实践
- GDPR 官方文本 — 通用数据保护条例全文