营销页面的目标不是”页面好看”,而是让用户产生行为:点击、领取、提交、预约、购买、咨询、留资。
但如果没有埋点,这些行为就是黑盒。你不知道用户从哪来、看了什么、点了什么、在哪里流失。页面发出去,只能证明”页面存在”,不能证明”页面有效”。
所以埋点不是锦上添花,而是营销平台的基础能力。
埋点的本质是什么
埋点的本质是把用户的隐性行为显性化。
用户打开页面、滚动屏幕、点击按钮、填写表单——这些动作在前端发生时是瞬时的,不留痕迹。埋点的作用就是把这些行为记录下来,变成结构化数据,供后续分析使用。
一条埋点数据至少包含:
事件名(what happened)事件时间(when)用户标识(who)页面标识(where)业务参数(context)
缺少任何一项,数据的价值都会大打折扣。事件名不规范,分析时无法聚合;时间不准,漏斗分析会错乱;用户标识缺失,无法做用户级归因。
埋点协议设计
埋点协议是埋点系统的地基。协议不清晰,后面的采集、存储、分析都会出问题。
一个合理的埋点事件结构:
interface TrackEvent { // 事件名,如 page_view、button_click、form_submit event: string; // 事件发生时间戳(毫秒) timestamp: number; // 用户标识 userId?: string; anonymousId: string; // 页面上下文 pageId: string; componentId?: string; // 渠道归因 channel?: string; campaignId?: string; utm_source?: string; utm_medium?: string; utm_campaign?: string; // 业务参数 properties: Record<string, any>; // 设备信息 device: { platform: 'ios' | 'android' | 'web'; screenWidth: number; screenHeight: number; userAgent: string; };}
事件命名要统一规范。常见做法是用 对象_动作 的格式:
page_view // 页面浏览page_leave // 页面离开button_click // 按钮点击component_exposure // 组件曝光form_submit // 表单提交form_submit_success // 表单提交成功form_submit_fail // 表单提交失败coupon_claim // 优惠券领取product_click // 商品点击
命名规范的好处是可预测。分析时不需要每次都去查事件名,看到 form_submit 就知道是表单提交,看到 _success 就知道是成功。
组件级埋点声明
如果每个业务方自己写埋点,长期会很混乱。事件名不统一、参数不一致、漏埋错埋很常见。
更合理的方式是让组件自己声明支持哪些埋点事件。
在组件协议中增加 trackingSchema:
{ "type": "CouponCard", "props": { "couponId": "string", "title": "string", "discount": "string" }, "trackingSchema": { "exposure": { "event": "coupon_exposure", "description": "优惠券卡片曝光", "params": { "couponId": "string", "position": "number" } }, "click": { "event": "coupon_click", "description": "优惠券卡片点击", "required": ["couponId"], "params": { "couponId": "string" } }, "claim": { "event": "coupon_claim", "description": "优惠券领取成功", "required": ["couponId"], "params": { "couponId": "string" } } }}
这样做的好处:
- 统一事件名:所有优惠券组件用同样的事件名,分析时不需要猜。
- 参数有文档:
description和params就是埋点文档,不需要单独维护。 - 必填校验:
required字段可以在运行时校验,防止漏传关键参数。 - 可复用:同一个组件在不同页面使用,埋点协议保持一致。
页面级埋点配置
组件声明了”能埋哪些点”,页面 DSL 配置”具体怎么埋”。
{ "id": "page_001", "type": "Page", "children": [ { "id": "coupon_1", "type": "CouponCard", "props": { "couponId": "summer_50", "title": "满300减50" }, "tracking": { "exposure": { "params": { "position": 1, "module": "top_banner" } }, "click": { "params": { "source": "homepage" } } } } ]}
注意 tracking 配置里的参数会和组件声明的 trackingSchema 合并。组件提供参数结构,页面提供具体值,运行时负责组装。
这样配置人员不需要知道埋点 SDK 怎么调用,只需要在 DSL 里填写业务参数。埋点细节由平台运行时统一处理。
曝光监听的实现
曝光埋点和点击埋点不同。点击是用户主动触发,曝光是元素进入可视区域时被动触发。
现代浏览器提供了 IntersectionObserver,这是实现曝光监听的标准方案:
class ExposureTracker { private observer: IntersectionObserver; private tracked: Set<string> = new Set(); constructor() { this.observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const elementId = entry.target.getAttribute('data-track-id'); if (elementId && !this.tracked.has(elementId)) { this.tracked.add(elementId); this.handleExposure(entry.target); } } }); }, { // 元素可见 50% 才算曝光 threshold: 0.5, // 可见区域扩展 10px,提前一点触发 rootMargin: '10px' } ); } observe(element: HTMLElement) { this.observer.observe(element); } private handleExposure(element: HTMLElement) { const trackId = element.getAttribute('data-track-id'); const trackEvent = element.getAttribute('data-track-event'); const trackParams = JSON.parse(element.getAttribute('data-track-params') || '{}'); TrackingSDK.track(trackEvent, trackParams); }}
使用时,运行时给需要曝光埋点的组件自动挂载 data-track-id:
<div data-track-id="coupon_1_exposure" data-track-event="coupon_exposure" data-track-params='{"couponId":"summer_50","position":1}'> <!-- CouponCard 组件内容 --></div>
曝光有几个常见坑:
- 重复曝光:用户滚动到优惠券,继续往下,再滚回来。需要控制只曝光一次还是每次都曝光。优惠券这种场景通常只要一次,但商品列表可能需要每次进入都曝光。
- 页面初始就可见:首屏内容在页面加载时就已经可见,
IntersectionObserver可能不触发。需要页面加载后手动检查一次首屏元素。 - SPA 路由切换:单页应用切换路由时,需要重新初始化曝光监听,否则新页面的元素不会被观察。
- 容器滚动:如果组件在一个可滚动的容器里,需要指定
root为该容器,否则计算不准。
批量上报机制
每条埋点数据单独发送请求,会浪费大量网络资源。营销页面用户停留时间短,可能短时间内产生大量事件(曝光 + 点击 + 滚动),必须批量上报。
class TrackingSDK { private queue: TrackEvent[] = []; private flushInterval = 3000; // 3秒刷一次 private maxQueueSize = 20; // 积攒20条刷一次 private timer: number | null = null; track(event: string, properties: Record<string, any>) { const fullEvent: TrackEvent = { event, timestamp: Date.now(), anonymousId: this.getAnonymousId(), pageId: this.pageId, properties, device: this.getDeviceInfo() }; this.queue.push(fullEvent); // 达到批量阈值,立即发送 if (this.queue.length >= this.maxQueueSize) { this.flush(); } // 启动定时器,保证数据不会一直积压 if (!this.timer) { this.timer = window.setTimeout(() => { this.flush(); }, this.flushInterval); } } private flush() { if (this.queue.length === 0) return; const events = [...this.queue]; this.queue = []; // 用 sendBeacon 优先,兼容用 fetch const data = JSON.stringify(events); if (navigator.sendBeacon) { navigator.sendBeacon('/api/tracking/batch', data); } else { fetch('/api/tracking/batch', { method: 'POST', body: data, headers: { 'Content-Type': 'application/json' }, // keepalive 保证页面关闭后请求还能发出 keepalive: true }); } if (this.timer) { clearTimeout(this.timer); this.timer = null; } }}
sendBeacon 是页面关闭场景的救星。用户关闭页面或跳转时,fetch 可能被浏览器取消,但 sendBeacon 可以保证数据发出。
参数自动补全
营销埋点需要大量上下文参数:页面 ID、组件 ID、渠道、活动 ID、UTM 参数、设备信息。如果让配置人员每次都手动填写,很容易漏。
平台运行时应该自动补全这些参数:
function enrichParams(componentParams: Record<string, any>, context: TrackingContext) { return { // 业务参数(来自页面配置) ...componentParams, // 自动补全的上下文 pageId: context.pageId, componentId: context.componentId, // 渠道归因(来自 URL 或 localStorage) channel: context.channel, campaignId: context.campaignId, utm_source: context.utm_source, utm_medium: context.utm_medium, utm_campaign: context.utm_campaign, // 页面信息 pageUrl: window.location.href, referrer: document.referrer, // 时间 timestamp: Date.now(), // 会话信息 sessionId: context.sessionId, userId: context.userId, anonymousId: context.anonymousId };}
UTM 参数需要在页面加载时从 URL 中解析并存储:
function parseUTMParams(): Record<string, string> { const params = new URLSearchParams(window.location.search); return { utm_source: params.get('utm_source') || '', utm_medium: params.get('utm_medium') || '', utm_campaign: params.get('utm_campaign') || '', utm_content: params.get('utm_content') || '', utm_term: params.get('utm_term') || '' };}// 存储到 sessionStorage,保证同页面会话内所有埋点都有 UTM 信息sessionStorage.setItem('utm_params', JSON.stringify(parseUTMParams()));
客户端内嵌场景:用 JSBridge 上报
营销页面最常见的打开方式是在 App 内嵌的 WebView 里。这种场景下,纯 H5 上报有一些天然劣势,而客户端原生上报可以解决这些问题。
H5 上报的局限:
| 问题 | 原因 |
|---|---|
| 页面关闭丢数据 | sendBeacon 不是 100% 可靠,部分 Android WebView 行为不一致 |
| 用户标识不稳定 | localStorage 可能被清理,换浏览器、清缓存都会丢失 |
| 设备信息受限 | 浏览器拿不到 IMEI、OAID、设备型号等原生标识 |
| 跨页面归因断裂 | 多个 WebView 之间无法共享会话,用户路径断开 |
| 离线场景丢失 | 断网时 H5 请求直接失败,无法缓存后重发 |
用 JSBridge 上报,可以让客户端接管埋点数据的存储和发送,前端只负责“采集”和“调用桥”。
JSBridge 上报接口设计
客户端暴露一个统一的上报接口:
// JSBridge 接口定义interface NativeBridge { // 单条上报 track(event: string, properties: string): void; // 批量上报 trackBatch(events: string): void; // 获取设备信息(同步) getDeviceInfo(): string; // 获取用户标识(异步) getUserId(callback: (userId: string) => void): void; // 获取客户端生成的匿名 ID getAnonymousId(): string;}
前端 SDK 判断运行环境,选择上报通道:
class TrackingSDK { private isNative: boolean; private bridge: NativeBridge | null = null; constructor() { this.isNative = this.detectNativeEnvironment(); if (this.isNative) { this.bridge = window.JSBridge; } } private detectNativeEnvironment(): boolean { // 判断是否在客户端 WebView 内 return !!(window.JSBridge && window.JSBridge.track); } track(event: string, properties: Record<string, any>) { const fullEvent = this.buildEvent(event, properties); if (this.isNative && this.bridge) { // 客户端上报:直接调桥,由客户端负责存储和发送 this.bridge.track(event, JSON.stringify(fullEvent)); } else { // H5 上报:走原有的队列 + sendBeacon 逻辑 this.enqueue(fullEvent); } }}
客户端上报的优势
1. 数据可靠性
客户端可以实现本地存储 + 重试机制:
上报请求 -> 写入本地数据库(SQLite) -> 标记为待发送 -> 网络可用时批量发送 -> 发送成功后删除 -> 发送失败保留重试
即使用户断网、杀 App,数据也不会丢。下次打开时,客户端自动补发之前缓存的数据。
2. 跨页面会话连续
客户端维护全局会话,不受 WebView 生命周期影响:
用户打开活动页 A(WebView 1) -> 客户端分配 sessionId -> 用户点击跳转活动页 B(WebView 2) -> 同一个 sessionId 继续 -> 用户在 B 页提交表单 -> 归因数据可以追溯到 A 页的来源
H5 方案下,WebView 1 和 WebView 2 是两个独立页面,sessionStorage 不共享,会话就断了。
3. 设备信息更完整
客户端可以提供 H5 无法获取的设备信息:
// 客户端返回的设备信息{ deviceId: "oaid_xxxx", // 设备唯一标识 imei: "xxxxx", // Android IMEI idfa: "xxxxx", // iOS 广告标识 model: "Xiaomi 14", // 设备型号 osVersion: "Android 14", // 系统版本 appVersion: "3.2.1", // App 版本 networkType: "wifi", // 网络类型 batteryLevel: 0.85, // 电量 isRooted: false // 是否越狱/root}
这些信息对风控和用户画像很重要。比如 isRooted 可以辅助判断刷量风险,networkType 可以分析不同网络环境下的转化差异。
4. 性能友好
H5 上报需要在 JS 线程里做序列化、发请求、处理回调。页面动画和用户交互可能受到影响。
客户端上报时,JS 只需要调一个桥方法,数据就交给客户端处理了:
// H5 上报:JS 线程阻塞const data = JSON.stringify(events); // 序列化耗时fetch(url, { body: data }); // 网络请求// 客户端上报:JS 线程立即释放bridge.trackBatch(JSON.stringify(events)); // 一行调用,客户端异步处理
5. 隐私合规
敏感信息(设备 ID、手机号 hash)在客户端处理,不暴露给前端 JS:
客户端获取设备 ID -> 客户端内部存储和使用 -> 前端 JS 只拿到匿名化的 sessionId -> 即使页面被 XSS 攻击,敏感信息也不会泄露
上报链路对比
纯 H5 方案:前端采集 -> JS 队列 -> sendBeacon/fetch -> 服务端 API客户端接管方案:前端采集 -> JSBridge -> 客户端队列 -> 本地持久化 -> 原生网络模块 -> 服务端 API
两种方案可以共存。平台检测到 JSBridge 存在时走客户端通道,否则走 H5 通道。代码层面是统一的 SDK 调用,配置人员不需要关心页面在哪里打开。
埋点数据的采集链路
前端埋点只是起点,完整的链路是:
前端 SDK -> 批量上报 API -> 消息队列(Kafka) -> 数据清洗 -> 数据仓库(ClickHouse) -> 分析看板(Grafana / Metabase)
API 层的职责很轻,只做两件事:
- 接收批量数据。
- 校验格式,丢入消息队列。
// 服务端上报接口app.post('/api/tracking/batch', async (req, res) => { const events = req.body; // 基本校验 if (!Array.isArray(events)) { return res.status(400).json({ error: 'Invalid format' }); } // 校验每条事件的必要字段 const validEvents = events.filter(event => { return event.event && event.timestamp && event.anonymousId; }); // 丢入消息队列 await kafka.send('tracking-events', validEvents); res.json({ success: true, count: validEvents.length });});
为什么用消息队列?
- 削峰:营销页面投放后,流量可能瞬间涌入,消息队列可以平滑处理。
- 解耦:数据采集和数据处理分离,后端可以独立扩展。
- 可靠性:消息队列有持久化机制,即使处理服务临时挂掉,数据也不会丢。
常见埋点事件清单
营销页面有一些通用埋点事件,建议平台内置支持:
| 事件名 | 触发时机 | 典型参数 |
|---|---|---|
page_view | 页面加载完成 | pageId, referrer, utm_* |
page_leave | 页面关闭或跳转 | pageId, stayDuration |
component_exposure | 组件进入可视区域 | componentId, position, module |
button_click | 按钮点击 | buttonId, buttonText, action |
form_start | 用户开始填写表单 | formId, firstField |
form_submit | 用户点击提交 | formId, fieldCount |
form_submit_success | 提交成功 | formId, leadId |
form_submit_fail | 提交失败 | formId, errorMessage |
coupon_exposure | 优惠券曝光 | couponId, position |
coupon_click | 优惠券点击 | couponId |
coupon_claim | 优惠券领取 | couponId, claimResult |
product_exposure | 商品曝光 | productId, position, listName |
product_click | 商品点击 | productId, position |
scroll_depth | 页面滚动深度 | depth(25/50/75/100) |
这些事件可以作为平台默认事件,配置人员只需要关注业务特有事件。
漏斗分析依赖埋点规范
埋点数据最终要服务于业务分析。营销页面最常用的分析方式是漏斗分析。
一个典型的营销漏斗:
页面访问(page_view) -> 优惠券曝光(coupon_exposure) -> 优惠券点击(coupon_click) -> 表单开始填写(form_start) -> 表单提交(form_submit) -> 提交成功(form_submit_success)
漏斗分析的前提是事件名规范、参数一致、用户标识可关联。如果事件名不统一,比如有的叫 form_submit 有的叫 submit_form,分析时就无法正确聚合。
所以埋点规范不是”建议”,而是”要求”。平台层必须强制执行。
埋点质量保障
埋点上线后最常见的问题是:数据不对。
常见问题:
- 事件没上报:代码写漏了,或者组件没正确挂载埋点。
- 参数缺失:必填参数没传,分析时无法关联。
- 重复上报:同一个事件上报了多次,数据失真。
- 上报时机不对:应该在曝光时上报的事件,变成了点击时才上报。
解决方案:
- 本地开发时开启埋点日志:在开发环境打印每条埋点数据,方便验证。
class TrackingSDK { track(event: string, properties: Record<string, any>) { const fullEvent = this.buildEvent(event, properties); // 开发环境打印日志 if (process.env.NODE_ENV === 'development') { console.log('[Tracking]', event, fullEvent); } this.queue.push(fullEvent); }}
-
测试环境验证埋点完整性:自动化测试脚本检查页面上报的所有事件,对比埋点文档,找出缺失和多余。
-
上线后监控数据量:如果某个事件的数据量突然下降或飙升,可能是埋点出了问题。
小结
埋点看起来简单,但做好不容易。它涉及协议设计、组件声明、运行时采集、参数补全、批量上报、数据落盘、质量保障等多个环节。
营销平台如果把埋点做成内置能力,配置人员只需要关心业务参数,不需要理解 SDK 的实现细节。平台负责统一事件名、统一参数格式、统一上报链路,保证数据质量。
有了可靠的埋点数据,后面的转化分析、渠道归因、A/B 测试才有基础。否则页面再好看,也只是自嗨。