前端埋点有哪些方式?怎样设计一套埋点方案?
埋点分三种:代码埋点由开发在业务代码里手动调用上报,最准确也最费人力;可视化埋点在页面上圈选元素生成配置,不用改代码;无埋点也叫全埋点,自动采集所有点击和页面浏览,事后再筛。方案设计要先定事件规范,再处理 PV、曝光、点击和停留时长,用队列批量上报,在页面隐藏时补发,并遵守隐私合规要求。
简答
产品经理想知道商品详情页的“立即购买”按钮每天被点了多少次、首页轮播图有多少人看到,这些数据要靠埋点采集:在用户做某件事时,前端记下一条事件并发给统计服务。
埋点有三种做法。代码埋点是开发在业务代码里手动调用
track('click_buy', { goodsId }),能带上准确的业务参数,但每加一个点都要改代码、发版。可视化埋点是运营在一个圈选工具里点选页面元素,生成配置下发,不用改代码,可是页面结构一改,圈选就可能失效。无埋点也叫全埋点,SDK 自动采集所有点击和页面浏览,事后再挑要看的数据,覆盖全但数据量大,也拿不到业务含义。实际项目里通常组合使用,关键转化用代码埋点,其余用全埋点兜底。设计方案时先定事件规范:事件名、公共字段(用户 ID、会话 ID、页面、时间、版本)和业务字段。然后分别处理几类事件:PV 在页面加载和单页应用路由变化时记一次,UV 由服务端按用户去重算出来;曝光用 IntersectionObserver 判断元素进入视口;点击可以用事件委托统一采集;停留时长要结合页面可见性计算。
上报时先放进队列批量发送,在页面变成隐藏时把剩下的发出去,避免丢数据。还要注意隐私合规,不采集密码、身份证这类敏感输入,需要用户同意的地区要先取得同意再采集。
三种埋点方式的取舍
代码埋点最直接。开发在按钮的点击回调里写一行上报,参数由代码决定,所以数据最准,比如“哪个商品、什么价格、来自哪个推荐位”都能带上。缺点是成本高,每次新增或修改埋点都要开发、测试、发版,漏埋了只能等下一个版本补。
可视化埋点想解决发版的问题。SDK 在页面里提供一个圈选模式,运营点选某个元素,工具记下这个元素的定位信息,比如 CSS 选择器或元素路径,生成规则下发给 SDK,SDK 按规则监听。可是定位依赖页面结构,改版后选择器对不上,埋点就悄悄失效了;而且它只能拿到页面上看得见的信息,拿不到商品 ID 这类业务数据。
无埋点干脆什么都采。SDK 在 document 上监听所有点击,在每次页面切换时自动记 PV,把元素路径、文本一起报上去,事后在分析平台里定义“哪个路径的点击算购买”。好处是可以回溯,事后想看的数据不需要提前埋;代价是数据量和存储成本高,也同样缺少业务参数。
所以常见做法是三者结合。核心业务流程和转化事件用代码埋点保证准确,其他交互用全埋点覆盖,临时的运营分析用可视化埋点。
一个埋点 SDK 的骨架
不管哪种方式,最后都要把事件交给一个统一的上报模块。下面的例子只看队列怎样攒批和补发:事件先放进队列,攒够 batchSize 条就发一批,剩下的等页面隐藏时由 flush 发出。
function createTracker({ send, batchSize = 3 }) {
const queue = [];
function flush() {
if (queue.length === 0) return;
// 一次取走当前队列,发送期间新产生的事件进入下一批
send(queue.splice(0, queue.length));
}
function track(event, props = {}) {
queue.push({ event, ...props, ts: Date.now() });
if (queue.length >= batchSize) flush();
}
return { track, flush };
}
const sent = [];
const tracker = createTracker({ send: (batch) => sent.push(batch.map((e) => e.event)) });
tracker.track('page_view', { page: '/goods/1' });
tracker.track('expose', { id: 'banner-1' });
tracker.track('click', { id: 'buy-btn' });
tracker.track('expose', { id: 'banner-2' });
console.log(sent); // [ [ 'page_view', 'expose', 'click' ] ]
tracker.flush(); // 页面隐藏时把剩下的发出去
console.log(sent.length); // 2
console.log(sent[1]); // [ 'expose' ]
前三条事件攒够了一批,立即发出;第四条留在队列里。为什么不每条都马上发?因为一次页面访问可能产生几十条事件,每条一个请求会占用网络连接、增加服务端压力,攒批能把请求数降下来。可是攒着的事件如果在用户关页面时还没发,就丢了,所以必须有 flush,在页面隐藏时调用。
真实项目里 send 一般用 navigator.sendBeacon 或带 keepalive 的 fetch,并在 visibilitychange 事件里、document.visibilityState 变成 hidden 时调用 flush。这两种方式的区别和配额限制见《页面关闭时怎样可靠上报?sendBeacon 和 keepalive 怎么选?》,sendBeacon 的返回值能说明什么见《sendBeacon 返回 true 就代表上报成功了吗?页面离开时怎样发送统计?》。
PV、曝光、点击和停留时长怎么采
PV 是页面浏览次数。多页应用在每次页面加载时记一次就行。单页应用切换路由不会重新加载页面,要在路由变化时记:浏览器的后退、前进会触发 popstate,可是 MDN 说明,直接调用 history.pushState() 或 replaceState() 不会触发 popstate,所以要在路由库的导航钩子里上报,或者包装 pushState。UV 是独立访客数,前端只负责每条事件都带上稳定的用户或设备标识,去重统计由服务端完成。
曝光是元素被用户看到的次数,比如商品卡片、广告位。用 IntersectionObserver 观察这些元素,回调里 isIntersecting 为 true、intersectionRatio 达到阈值时上报。threshold 可以设成 0.5,表示元素有一半进入可视区域时触发。业务上通常还会要求连续可见一段时间才算一次曝光,并且同一个元素只报一次,这些规则要在回调里自己记录状态实现。还要注意,IntersectionObserver 只计算位置有没有重叠,不能证明元素没被别的东西挡住,详见《IntersectionObserver 适合做什么,能否证明用户看到了内容?》。
点击用事件委托最省事:在 document 上监听一次 click,通过 event.target.closest('[data-track]') 找到带埋点标记的元素,读取它的 data-* 属性作为事件参数。这样新增埋点只要在元素上加属性,不用到处写监听。
停留时长不能简单用“离开时间减进入时间”。用户切到别的标签页,页面还开着,却没在看。所以要监听 visibilitychange:页面变成可见时开始计时,变成隐藏时累加这一段时长。MDN 指出 blur 和 focus 只能说明用户切换了窗口,不代表页面被隐藏,因此判断依据应该用 Page Visibility API。页面隐藏也是最后一个可靠的上报时机,累计时长在这时随队列一起发出。
数据质量和隐私
埋点数据会被拿去做决策,所以要防止脏数据。每个事件名和字段要有文档登记,SDK 上报前按规范校验,错误的事件在测试环境就报出来。数据量大时可以采样,采样和去重的做法见《前端监控数据怎样做采样、去重、聚合和告警?》。
隐私是另一条红线。全埋点会自动采集元素文本,SDK 要默认屏蔽输入框的值,不采集密码、手机号、身份证这类敏感信息。在要求先取得用户同意的地区,同意之前不能采集或上报个人数据,SDK 应该提供开关,由应用在用户同意后再启用。具体要求以所在地区的法律和公司合规部门的意见为准。
常见追问
埋点上报和错误监控能共用一套 SDK 吗?
可以共用上报通道,也就是队列、批量发送和页面隐藏时补发这部分。错误采集有自己的入口,比如全局的
error和unhandledrejection监听,见《前端怎样全局捕获 JS 错误、Promise 拒绝和资源加载失败?》。两类数据最好打上不同的类型标记,分开存储和告警。
上报接口为什么常用 GIF 图片请求?
早期常用
new Image().src = url发统计。同源策略允许页面嵌入跨源图片,所以请求能发到统计域名,也不需要读响应。缺点是数据只能放在 URL 里。另外 MDN 在 Beacon API 页面说明,页面即将卸载时,浏览器不保证普通的异步请求能发出去,而sendBeacon保证在卸载前发起并完成。所以现在页面离开时的上报更推荐sendBeacon或fetch的keepalive。
怎样验证埋点有没有埋对?
开发阶段在 SDK 里加调试模式,把每条事件打印到控制台或展示在页面浮层上;测试时对照埋点文档逐条触发。上线后在分析平台看各事件的数量趋势,某个事件突然掉到零,多半是改版把埋点弄丢了。