前端埋点sdk(前端组件化埋点方案与实现)
背景
埋点 ,是收集产品的数据的一种方式 ,其目的是上报相关行为数据(PV/UV/时长/曝光/点击等) ,由相关人员以此分析用户的使用习惯 ,助力产品不断迭代和优化 。对于开发来说 ,通常不仅仅需要完成基础的业务需求 ,还需要完成埋点需求 ,所以 ,追求的是简单快捷的埋点工作 。而一个完整的埋点体系由以下三个部分构成:
产品应用(产生行为数据) 数据分析平台(展示 、分析行为数据) 数据平台 SDK(上报行为数据):封装数据分析平台的各种接口 ,暴露简单的方法供调用 ,实现简易的埋点上传 。目前 ,前端埋点存在的痛点一般是:
埋点字段的手动拼接 ,存在出错风险;复杂场景的曝光埋点实现繁琐:分页列表 、虚拟列表等;
埋点代码的侵入性:尤其是曝光代码导致逻辑复用困难 。
埋点类型一般有:
页面埋点:统计用户进入或离开页面的各种维度信息,如页面浏览次数(PV) 、浏览页面人数(UV) 、页面停留时间 、浏览器信息等 。 点击埋点:统计用户在应用内的每一次点击事件 ,如新闻的浏览次数 、文件下载的次数 、推荐商品的命中次数等 。 曝光埋点:统计具体区域是否被用户浏览到 ,如活动的引流入口的显示 、投放广告的显示等 。市场上常见的埋点方案:
全埋点(无埋点):由前端自动采集全部事件并上报,前端也就没有埋点成本 ,由数据分析平台或后端过滤有用数据 ,优点是数据全面 ,缺点是数据量大 ,噪声数据多 。 可视化埋点:由可视化工具进行配置采集指定元素——查找 dom 并绑定事件 ,优点是简单 ,缺点是准确性较低 ,针对性和自定义埋点能力较弱 。 代码埋点:用户触发某个动作后手动上报数据 ,优点时准确性高 ,能满足自定义的场景 ,缺点是埋点逻辑容易与业务逻辑耦合(命令式埋点) ,不利于维护与复用 。综上 ,需要的是一种简单快速且准确,同时埋点逻辑与业务逻辑解耦的埋点方案 ,也就是本文分析的声明式的组件化埋点方案 。
声明式的组件化埋点方案
名词解释
页面 (Page):在浏览器中打开的网页 ,不同页面以路径 location.pathname 来作区分; 页面可见时长:一个页面对用户可见的累计时长; 页面活跃时长:用户在页面上进行有效的鼠标 、键盘及触控活动的累计时长; 组件 (Component):DOM 元素的集合,是页面的组成部分 。一个页面内可包含多个组件; 组件可见时长:一个组件对用户可见的累计时长。 可见性(visiability) visible:页面 viewport 中且位于前台; invisible - 页面不 viewport 中 ,或处于后台 。 活跃性 (activity) active - 用户在网页中有活动(例如鼠标 、键盘活动及页面滚动等); inactive - 用户在网页中没有任何活动 。根据概念可知 ,一个页面不可见时 ,则一定不活跃 ,且其中的所有组件一定也都不可见;页面活跃时长 ≤ 页面可见时长;组件可见时长 ≤ 页面可见时长 ,
原理与思路
该方案总体思路如下:
对于通用字段进行统一处理 ,既不容易出错 ,也方便后期拓展。对于运行时字段(异步) ,支持 extra 进行传入 。 对于页面级事件 ,埋点库初始化后自动注册关于页面级曝光的相关事件 ,不需要在代码中进行维护 。 考虑到存在高频场景 ,设置上报缓冲队列 pendingQueue ,通过定时任务分批次上报数据,支持设置上报频率 。根据实践 ,点击类上报频率 1000ms ,曝光类 3000ms 。 考虑到埋点 sdk 没初始完,上报行为就已经产生了 ,设置 unInitQueue 来存储 。 以页面为维度来管理埋点配置 ,便于维护和迁移 。考虑到埋点 sdk 没初始完 ,上报行为就已经产生了 ,比如曝光 ,新增如果这时候生成对应的点进入缓冲队列 ,就是属于无效的点因为没有加载到坑位信息 、配置参数等 ,所以针对这种场景下产生的点位信息 ,我们新开一个队列存储 ,等到初始化完成再去处理;
因此 ,埋点上报总体流程为:埋点 sdk 接受返回埋点的函数 ,将其返回值上报 ,支持上报多个埋点;埋点事件由应用发送给埋点 sdk 后,埋点 sdk 首先会对数据进行处理 ,再调用数据平台暴露的方法 , 将埋点事件上报给数据平台 。
具体实现
判断页面可见性
虽然 Page Visibility API 的浏览器兼容情况不错,但对于Android 、iOS 和最新的 Windows 系统可以随时自主地停止后台进程 ,及时释放系统资源 。因此 ,基于 Google 描述网页生命周期的 Page Lifecycle API 兼容库 PageLifecycle.js 来监听页面可见性变化——一个网页从载入到销毁的过程中 ,会通过浏览器的各种事件在以下六种生命周期状态 (Lifecycle State) 之间相互转化 ,通过监听页面生命周期的变化并记录其时间 ,就可以相应获取页面可见性的统计数据:
active:网页可见且具有焦点; passive:网页可见但处于失焦状态; hidden:网页不可见但未被浏览器冻结 ,一般由用户切换到别的 tab 或最小化浏览器触发; frozen:网页被浏览器冻结(后台任务比如定时器、fetch等被挂起以节约 CPU 资源); terminated:网页被浏览器卸载并从内存中清理 。一般用户主动将网页关闭时触发此状态; discarded:网页被浏览器强制清理 。一般由系统资源严重不足引起 。由此可得 ,页面生命周期状态和页面可见状态之间的映射关系为
active + passive = visible; hidden + terminated + frozen + discarded = invisible 。因此 ,通过监听 statechange 来识别页面可见状态的改变 ,在生命周期状态为 active 和 passive 时标记页面为 visible 状态 ,在生命周期状态为其他几个时标记页面为 invisible 状态 ,更新最后一次可见的时间戳 ,并累加页面可见时间。PageLifecycle.js 是无法推送 discarded 事件的,因为网页已经被销毁并从内存中清理 ,无法向外传递任何事件——解决方案是需要在页面进入 invisible 状态时 ,对数据使用 JSON.stringify 序列化并储存在 localStorage 中,若页面后续转为 visible 状态即将其清空 ,否则在页面被强制清除后 ,在下一次初始进入页面时先将 localStorage 中的数据通过事件推送出去 。另外 , PageLifecycle.js 无法感知单页面应用的history 或 hash 路由切换 ,需要在埋点 sdk 中额外添加对路由变化事件(popstate/replacestate)的监听 ,等同于进入 terminated 生命周期:
lifecycleInstance.addEventListener("statechange", (event: StateChangeEvent) => { const { newState } = event; if (["active", "passive"].includes(newState)) { // page visible, do something return; } if (["hidden", "terminated", "frozen"].includes(newState)) { // page invisible, do something else } })判断页面活跃性
通过监听以下的六种浏览器事件 ,就可以判断用户是否在当前页面上有活动 ,此时页面标记为 active 状态 ,并记录当前时间戳 ,用于累加活跃时长 。:
keydown:用户敲击键盘时触发; mousedown:用户点击鼠标按键时触发; mouseover:用户移动鼠标指针时触发; touchstart:用户手指接触触摸屏时触发(仅限触屏设备); touchend:用户手指离开触摸屏时触发(仅限触屏设备); scroll:用户滚动页面时触发。而页面被标记为 inactive 状态 ,有以下两种情况:
在初始化埋点 sdk 时自定义 ,visible 状态下超过一定的时间阈值(比如 15 秒)没有监测到表示页面活跃的六种事件; 页面状态为 invisible ,因为如果页面对用户不可见,那么它一定是不活跃的 。判断组件可见性
首先需要获取需要统计的所有 DOM 元素 ,并指定埋点的标准 。MutationObserver API 提供了 DOM 节点增减以及属性变化检测的能力 。该 API 是异步触发 ,即要等到当前所有 DOM 操作都结束才触发,避免DOM频繁变动造成性能损耗 ,因此 ,可以用来监听 DOM 结构变化 。
const mutationObserver = new MutationObserver(function (mutations, observer) { mutations.forEach(function (mutation) { console.log(mutation.target); // target: 发生变动的 DOM 节点 }); }); // 观察整个文档 mutationObserver.observe(document.documentElement, { childList: true, // 子节点的变动(指新增 ,删除或者更改) attributes: true, // 属性的变动 characterData: true, // 节点内容或节点文本的变动 subtree: true, // 表示是否将该观察器应用于该节点的所有后代节点 attributeOldValue: false, // 表示观察 attributes 变动时 ,是否需要记录变动前的属性值 characterDataOldValue: false, // 表示观察 characterData 变动时 ,是否需要记录变动前的值 。 attributeFilter: false, // 表示需要观察的特定属性 ,比如[class,src] }); mutationObserver.disconnect(); // 用来停止观察 。调用该方法后 ,DOM 再发生变动则不会触发观察器其中 , mutations 是所有被触发改动的 MutationRecord 对象数组 。
考虑到可能存在被监控组件是第三方库的 ,自定义属性 data-tracking-pv 会被过滤 ,为了统一 ,利用babel 插件在编译过程中寻找添加了 data-tracking-pv 属性的组件 ,并在其外层包裹一个自定义的 <tracking></tracking> 标签,自定义标签的优点是没有任何样式 ,所以包裹该标签也不会影响到原有组件的样式 。埋点 sdk 收集这些元素供 MutationObserver 监听 DOM 变化 。
const Component = () => { <div data-tracking-pv={ "event": "component_custom_pv", "params": { ... } }> component_custom </div> } const Component = () => { return <Button data-tracking-pv={{ event: "component_antd_pv", params: { ... } }}>点击按钮</Button> } // 插件最终生成的组件为 </tracking data-tracking-pv={ "event": "component_custom_pv", "params": { ... } } > // ...真实组件 </tracking>组件可见性 ,即曝光的三个判断标准:
是否处于 viewport 中:使用 IntersectionObserver API 判断, 该 API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法;我们知道 ,用户的感兴趣程度 = 点击率(点击次数/曝光次数) ,考虑曝光的有效性 ,即需要判断组件出现在 viewpoint 内的达到一定的比例(0.5 或 0.75 或 1.0)和时长(3s)以及次数(是否重复曝光)标准; const intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach(function (entry) { /** entry.boundingClientRect // 返回包含目标元素的边界信息 。 边界的计算方式与 Element.getBoundingClientRect() 相同 。 entry.intersectionRatio // 目标元素的可见比例 ,即 intersectionRect 占 boundingClientRect 的比例 ,完全可见时为 1 ,完全不可见时小于等于 0 entry.intersectionRect // 目标元素与视口(或根元素)的交叉区域的信息 entry.isIntersecting // 返回一个布尔值, 如果根与目标元素相交(即从不可视状态变为可视状态) ,则返回 true 。如果返回 false, 变换是从可视状态到不可视状态。 entry.rootBounds // 根元素的矩形区域的信息, getBoundingClientRect 方法的返回值 ,如果没有根元素(即直接相对于视口滚动) ,则返回 null entry.target // 被观察的目标元素 ,是一个 DOM 节点对象 entry.time // 可见性发生变化的高精度时间戳 ,单位为毫秒 **/ }); }, { root: document.querySelector(#root), // 根 dom 元素, 默认 null 为 viewport rootMargin: 0px, // root元素的外边距 threshold: [0, 0.25, 0.5, 0.75, 1], // Number 或 Number 数组 ,该属性决定了什么时候触发回调函数 。默认为 0.0; } ); intersectionObserver.observe(document.getElementById("item")); // 开始监听一个目标元素 intersectionObserver.disconnect(); // 停止全部监听工作兼容性方面有对应的 intersection-observer-polyfill,
样式是否可见:使用 CSS 的 display/ visibility / opacity 样式属性判断;以下是会被标记为 invisible 的情况: visibility: hidden display: none opacity: 0 页面是否可见:使用页面可见性判断 。页面 invisible 状态下 ,所有组件的状态也标记为 invisible。使用 requestIdleCallback 方法 ,浏览器会在空闲时执行传入的埋点函数,避免埋点影响主业务 。其浏览器兼容情况如下:
可以使用 requestIdleCallback shim/polyfill ,以下是简化版:
const requestIdleCallback = window.requestIdleCallback || function(callback, options) { var options = options || {}; var relaxation = 1; var timeout = options.timeout || relaxation; var start = performance.now(); return setTimeout(function() { callback({ get didTimeout() { return options.timeout ? false : performance.now() - start - relaxation > timeout; }, timeRemaining: function() { return Math.max(0, relaxation + (performance.now() - start)); } }); }, relaxation); };MutationObserver + IntersectionObserver + requestIdleCallback 曝光埋点实现
const observerOptions = { childList: true, // 观察目标子节点的变化 ,是否有添加或者删除 attributes: true, // 观察属性变动 subtree: true, // 观察后代节点 ,默认为 false } function callback(mutationList, observer) { mutationList.forEach((mutation) => { switch (mutation.type) { case childList: collectTargets() break case attributes: break } }) } function getParams(el) { const { exposureTrackerAction, exposureTrackerParams } = el.dataset; const Key = exposureTrackerAction; const pageEl = document.querySelector([data-exposure-tracker-page-params]); let pageParams; if (pageEl?.dataset?.exposureTrackerPageParams) { try { pageParams = JSON.parse(pageEl?.dataset?.exposureTrackerPageParams); } catch (error) { console.error(parse pageParams fail); } } let params; if (exposureTrackerParams) { try { params = JSON.parse(exposureTrackerParams); } catch (error) { console.error(parse params fail); } } return { Key, ...pageParams, ...params }; } const intersectionObserver = new IntersectionObserver(function callback(entries, observer) { const list = []; entries.forEach(entry => { if ( entry.target.dataset.exposureTrackerExposed !== 1 && entry.intersectionRatio >= 0.5 ) { list.push(entry); observer.unobserve(entry.target); } else if (entry.intersectionRatio === 0) { delete entry.target.dataset.exposureTrackerExposed; } requestIdleCallback(() => { // ...上报 }); }); }, options); collectTargets() { const els = Array.from( document.querySelectorAll([data-exposure-tracker-action]) ).filter(el => !el.dataset.exposureTrackerTracked); if (els.length > 0) { // console.log(collectTargets, els); els.forEach(el => { intersectionObserver.observe(el); el.dataset.exposureTrackerTracked = true; }); } export function getExposureTrackerPageParamsProps(params) { return { data-exposure-tracker-page-params: params ? JSON.stringify(params) : undefined }; } export function getExposureTrackerParamsProps(action, params) { return { data-exposure-tracker-action: action, data-exposure-tracker-params: params ? JSON.stringify(params) : undefined }; } export default function App() { return ( <div {...getExposureTrackerPageParamsProps({ pageData: some page data })}> <div {...getExposureTrackerParamsProps(item_content_expose, { itemData: xxx })} > item content </div> </div> ); }自定义类指令式埋点实现
该方式适合简单的埋点上报 ,埋点逻辑与业务逻辑清晰分离 ,埋点 sdk 给 document 对象加上监听 click / hover 事件触发时 ,从当前触发事件的 target 逐级向上遍历 ,查看是否有对应此事件的指令 。如果有 ,则上报此埋点事件 ,直至遇到一个没有事件指令的元素节点 。这样也可以在指令中控制是否要继续向上遍历 。
// 类指令式埋点实现逐级上报 <section data-tracking-hover={JSON.stringify({ type: func_operation, params: { value: 3 }})}> <div data-tracking-click={JSON.stringify({ type: func_operation, params: { value: 2 }})}> <Button data-tracking-click={JSON.stringify({ type: func_operation, params: { value: 1 }})}>点击</Button> </div> </section>但是如果我们需要在上报事件前 ,对所上报的数据进行处理 ,那么这种方式就无法满足了 。并且 ,并不是所有的场景都可以被 DOM 事件所覆盖 。如果我想在用户在搜索框输入某个值时,上报埋点 ,那么我就需要对用户输入的值进行分析 ,而不能在 input 事件每次触发时都上报埋点 。
装饰器式埋点实现
装饰器只能用于类组件,@tracking 修饰器接受一个函数形式的参数 ,其返回值即是要上报的事件 。在 handleClick 函数被调用的时候 ,埋点 sdk 会首先上报埋点事件 ,然后再执行 handleClick 函数的业务逻辑 。
target - 装饰器所在的类 propertyKey - 被装饰的函数的属性名 descriptor - 被装饰的函数的属性描述符 // tracking 函数简化源代码 tracking = (event: TrackingEvent) => { return (target: object, propertyKey: string, descriptor: object) => { if (isFunction(event) || isObject(event) || isArray(event)) { const oldMethod = descriptor.value; const _event = this.evalEvent(event)(...arguments); const composedFn = () => { this.sendEvent(_event); oldMethod.apply(this, arguments); } set(descriptor, "value", composedFn); return descriptor; } } }; /** * @tracking 使用示例 */ class Test extends React.component { ... @tracking((value: string) => ({ type: func_operation, params: { keyword: value }, })) handleClick() { console.log(执行点击的业务逻辑,); } render() { return ( <Button onClick={handleClick} /> ) } }因此 ,会先上报埋点 ,然后执行 descriptor.value 的逻辑 ,即被装饰的函数 。
React Hook 埋点实现
与装饰器实现类似 ,useTracking 接受两个函数:埋点函数和业务函数 ,返回组合函数 。
// useTracking 源代码 useTracking = (fn: () => any /** 业务函数 */, event: TrackingEvent /** 埋点函数 */) => { if (!event) return fn; return (...args) => { const _event = this.evalEvent(event)(...args); this.sendEvent(_event); return fn.apply(this, args); }; }; // useTracking 使用示例 const Example = (props: object) => { const handleClick = useTracking( // 业务逻辑 () => { console.log(业务逻辑); }, // 埋点逻辑 () => { return { type: "func_operation", params: { data, props.data }, }; } ); return <Button onClick={handleClick} />; };组件化点击埋点实现
使用者只需要把触发的回调(handleClick)绑定到对应的事件上即可 ,埋点逻辑由 埋点sdk 负责组合上去 。
function setClickEvent(ele) { // 对于列表 ,也可以遍历 return React.cloneElement(ele, { onClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => { const originClick = ele.props?.onClick || noop; doClickTracking(); originClick.call(ele, event); } }); } <TrackerClick name=button.click> { ({ handleClickTrack }) => <button onClick={() => { handleClick() /** 业务逻辑 */; handleClickTrack() /** 埋点逻辑 */; }}>点击</button> } </TrackerClick> /** class TrackerClick { constructor(props) { super(props); } handleClick() { } render() { return this.props.children({ handleClick }); } } */组件化曝光埋点实现
仅向外暴露一个 setRef 用以获取 dom 执行监听工作 ,其他工作都交给埋点 sdk 来处理 ,同时支持配置以下曝光规则:
threshold: 曝光阈值; visibleTime:组件曝光时长; once:是否进行重复曝光埋点监听。 // case1: 直接绑定dom return ( <TrackerExposure name=button.exposure extra={{ data }}> {({ setRef }) => <div ref={setRef}>{i + 1}</div>} </TrackerExposure>)) ); // case2: 自定义组件 const Test = React.forwardRef((props, ref) => (<div ref={ref} style={{ width: 150px, height: 150px, border: 1px solid gray }}>TEST</div>) ) return ( <TrackerExposure name="button.exposure" extra={{ data }}> {({ setRef }) => <Test ref={setRef} />} </TrackerExposure> ) /** class TrackerExposure { constructor(props) { super(props); } setRef() { } render() { return this.props.children({ setRef }); } } */补充--微信小程序曝光埋点
微信小程序的 节点布局相交状态 API (IntersectionObserver),可用于监听两个或多个组件节点在布局位置上的相交状态 。这一组 API 常常可以用于推断某些节点是否可以被用户看见 、有多大比例可以被用户看见 。
/** 创建并返回一个 IntersectionObserver 对象实例。 * 在自定义组件或包含自定义组件的页面中 , * 应使用 this.createIntersectionObserver([Object options]) 来代替 。 */ wx.createIntersectionObserver( /** 自定义组件实例 */ [Object component], /** * thresholds: 一个数值数组 ,包含所有阈值 。 * initialRatio: 初始的相交比例,如果调用时检测到的相交比例与这个值不相等且达到阈值 ,则会触发一次监听器的回调函数 。 * observeAll: 是否同时观测多个目标节点(而非一个) ,如果设为 true ,observe 的 targetSelector 将选中多个节点(注意:同时选中过多节点将影响渲染性能) */ [Object options] )相关概念:
参照节点:监听的参照节点 ,取它的布局区域作为参照区域 。如果有多个参照节点 ,则会取它们布局区域的 交集 作为参照区域 。页面显示区域也可作为参照区域之一 。 目标节点:监听的目标 ,默认只能是一个节点(使用 selectAll 选项时 ,可以同时监听多个节点) 。 相交区域:目标节点的布局区域与参照区域的相交区域 。 相交比例:相交区域占参照区域的比例 。 阈值:相交比例如果达到阈值 ,则会触发监听器的回调函数 。阈值可以有多个 。IntersectionObserver 一共有四个方法
IntersectionObserver.relativeTo(string selector, Object margins) 使用选择器指定一个节点 ,作为参照区域之一; IntersectionObserver.relativeToViewport(Object margins) 指定页面显示区域作为参照区域之一; IntersectionObserver.observe(string targetSelector, function callback) 指定目标节点并开始监听相交状态变化情况; callback: (res) => {}; res: { id string 节点 ID dataset Record.<string, any> 节点自定义数据属性 intersectionRatio number 相交比例 intersectionRect Object 相交区域的边界 boundingClientRect Object 目标边界 relativeRect Object 参照区域的边界 time number 相交检测时的时间戳 } IntersectionObserver.disconnect() 停止监听 。回调函数将不再触发。显然 ,曝光上报的默认参照是 viewport ,所以使用 IntersectionObserver.relativeToViewport 作为参照物 ,进行曝光埋点:
Page({ data: { list: [ { value: 1, hadReport: false }, { value: 2, hadReport: false }, { value: 3, hadReport: false }, ] }, onLoad() { this._observer = this.createIntersectionObserver({ thresholds: [0.5], observeAll: true }); this._observer.relativeToViewport({ bottom: 0 }) .observe(.item, (res) => { const { index } = res.dataset; // item 下标; if (!this.data.list[index].hadReport) { console.log(`report ${index}`) this.data.list[index].hadReport = true; this.setData({ list: [].concat(this.data.list)}) } }) }, onUnload() { if (this._observer) this._observer.disconnect() } })创心域SEO版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!