背景
埋点,是收集产品的数据的一种方式,其目的是上报相关行为数据(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()
}
})