原文作者 杨运心 实习期间,公司内部KM的文章,侵删
埋点:收集产品数据、上报相关行为数据。通过分析数据来辅助产品优化迭代
BI: 商业智能,公司内部做数据分析相关的部门
痛点:
- 在构造埋点字段的时候需要根据 BI 的规则,把若干个字段拼接成一个,这样费时费力还有错误的风险;
- 一些曝光场景下的点不好打比如:分页列表、虚拟列表;他们的的曝光埋点实现较为繁琐;
- 逻辑复用问题:特别是曝光相关的点需要在业务代码里面做额外的处理,所以逻辑复用很困难,对现有代码的侵入也很严重;
目前主流的埋点方案
手动代码埋点:用户触发某动作后,手动上报数据
- 优:准确、可定制。
- 缺: 埋点逻辑与业务逻辑耦合,不利于代码维护。
可视化埋点:通过可视化工具来配置采集数据。核心是查找dom然后绑定事件。主流如:Mixpanel https://mixpanel.com/
- 优:可做到按需配置,同时不会像全埋点一样产生大量无用数据
- 缺: 运行时参数比较难获取,页面结构变化时,可能需要进行重新配置
无埋点(全埋点):自动采集全部事件并上报数据,数据在后端过滤计算
- 优:可以收集所有用户行为,全面
- 缺:无效的数据很多、上报数据量大
需要一个准确、高效、埋点与业务解耦、项目迁移工作量小的埋点方案
声明式的组件化埋点 + 缓冲队列
- 为了解决埋点代码与业务逻辑耦合的问题,我们认为可以在视图层处理,埋点可以归纳为两大类,点击与曝光埋点。我们可以抽象出两个组件分别处理这两种场景。
- 在一些场景下快速滑动、频繁点击会在短时间打出大量的点,造成频繁的接口调用,这在移动端是要避免的,针对这种场景我们引入了缓冲队列,产生的点位信息先进入队列,通过定时任务分批次上报数据,针对不同类型的点也可以应用不同的上报频率。
- 目前对于一些字段采用的是人工拼接,比如 BI 定义的 _mspm2 等相关通用字段,类似这种我们完全可以在库统一处理,既不容易出错,也方便后期拓展。
对于页面级曝光,我们可以在埋点库初始化后自动注册关于页面曝光的相关事件,不需要使用者关心
点击埋点
点击埋点我们开始的思考是提供一个组件,包裹需要进行点击埋点的 dom
元素,也有可能是组件
,然后给子元素绑定点击事件,当用户触发事件时进行埋点相关处理。
按照上述思路我们就必须绑定点击事件到 dom 上,但是我们又不想引入额外的 dom 元素,因为这会增加 dom 结构层级,给使用者带来麻烦,这样留给我们的操作空间就剩下 props.children
,所以我们去递归 TrackerClick
组件的 children
,找到最外层的dom元素,同时要求 TrackerClick
下面必须有一个 container
元素,按照这个思路我们进行了处理。
export default function TrackerClick({
name,
extra,
immediate,
children,
}) {
handleClick = () => {
// todo append queue
};
function AddClickEvent(ele) {
return React.cloneElement(ele, {
onClick: (e) => {
const originClick = ele.props.onClick || noop;
originClick.call(ele, e);
handleClick();
}
});
}
function findHtmlElement(ele) {
if (typeof ele.type === 'function') {
if (ele.type.prototype instanceof React.Component) {
ele = new ele.type(ele.props).render();
} else {
ele = ele.type(ele.props);
}
}
if (typeof ele.type === 'string') {
return AddClickEvent(ele);
}
return React.cloneElement(ele, {
children: findHtmlElement(ele.props.children)
});
}
return findHtmlElement(React.Children.only(children));
}
// case1
// case2
从使用上来说很简便,达到了我们的目的。但是经过我们的实践也发现了一些问题,比如使用者并不清楚里面的实现细节,有可能里面没有一个 container
包裹,也可能使用了 React.Fragment
造成一些不可预估的行为、同时也无形的增加了dom结构层级(虽然我们没有引入,但是我们在告诉用户,你最好有个 container
)。
我们又在反思这种方案的合理性,虽然使用上带来了便捷,但是带来了不确定性。经过讨论我们决定把绑定的工作交给组件使用者,我们只需要明确告诉他可以使用哪些方法,这是确定性的工作。使用方只需要把触发的回调绑定到对应的事件上即可。
改造后如下:
{
({ handleClick }) =>
}
曝光埋点
曝光对于我们来说一直是比较麻烦的,我们先来看看曝光埋点的一些要求:
- 元素出现在视窗内一定的比例才算一次合法的曝光
- 元素在视窗内停留的时长达到一定的标准才算曝光
- 统计元素曝光时长
站在前端的角度看实现这三点就比较复杂了,再加上一些分页、虚列表的场景就更加繁琐,带着这些问题调研了 IntersectionObserver。
IntersectionObservers calculate how much of a target element overlaps (or "intersects with") the visible portion of a page, also known as the browser's "viewport"
IntersectionObservers
计算目标元素与页面可见部分的重叠程度(或 "相交"),也被称为浏览器的 "视口"。
[图片上传失败...(image-acf860-1605579661870)]
const intersectionObserver = new IntersectionObserver(function(entries) {
// If intersectionRatio is 0, the target is out of view
// and we do not need to do anything.
if (entries[0].intersectionRatio <= 0) return;
console.log('Loaded new items');
}, {
// 曝光阈值
threshold: 0
});
// start observing
intersectionObserver.observe(document.querySelector('.scrollerFooter'));
上面是 MDN 的一个例子,所以我们是可以知道元素什么时候进入以及什么时候离开 viewport,间接的上面三点需求我们都可以实现。
经过调研,在能力方面可以满足我们的需求、兼容性方面有对应的intersection-observer polyfill; 对于分页、虚列表,我们只需要关注我们需要观测的列表item
,所以我们需要实现一个高性能的 ReactObserver
组件来提供 intersection-observer
的能力并对外提供相应的回调。如何实现一个高性能的Observer此处不做赘述。
下面是曝光组件绑定 dom 的两种方式
// case1: 直接绑定dom
render() {
return (
{
arr.map((item, i) => (
{({ addRef }) => {i + 1}}
))
}
);
}
// case2: 自定义组件
const Test = React.forwardRef((props, ref) => (TEST)
)
render() {
return (
{
arr.map((item, i) =>
{
({ addRef }) =>
}
)
}
)
}
使用上我们仅提供一个 addRef 用以获取 dom 执行监听工作,其他工作都交给库来处理,曝光变得如此简单。针对上述3点要求,我们提供配置如下:
- threshold: 曝光阈值,当 element 出现在视窗多少比例触发
- viewingTime:元素曝光时长,用来判断是否是一次时长合规的曝光
- once:是否重复打曝光埋点
运行时参数
一般固定的参数我们会放在config配置文件中管理,当然也有一些运行时的参数,比如 userId,modulePosition
等运行时字段,针对这种场景我们提供 extra props
通过组件的 props
传递,在组件内部拼装,使用时只需要传入对应业务字段即可。
5.5 appendQueue
一些场景下我们没法绑定事件到dom上,比如原生的元素:audio、video
,以及封装层级很深的业务组件,类似这种只对外提供了回调,针对这种场景我们提供了 appendQueue
方法,把点加入到缓冲队列中。
appendQueue({
name: 'module.click',
action: 'click',
extra: {
userId: 'xxx',
}
})
定时任务
我们的设计是所有产生的点都会进入缓冲队列中,通过定时任务上报。目前策略是点击类上报频率 1000ms,曝光类 3000ms,当然这个间隔也不是凭空想象的,经过跟算法、BI 讨论商定出来的,兼顾了前端的需求与算法那边实时性的要求,目前这两个值也是支持配置的。
关于定时任务的时间间隔,我们取点击和曝光上报频率的最大公约数,以减少执行次数。
页面曝光
我们在初始化的时候会根据配置文件中约定的字段判断是否需要处理页面曝光;
页面曝光的关键是采集页面曝光的时机,浏览器的页面生命周期标准和规范才开始制定没多久,各个厂商支持的都不是很好,参考 Chrome 的页面生命周期中的 visibilitychange 事件作为采集页面曝光的时机。
visiblitychange 的浏览器兼容情况
[图片上传失败...(image-35f277-1605579661870)]
使用
import Tracker, {
TrackerExposure,
appendQueue
} from 'music/tracker';
const generateConfig = () => ({
opus: {
mspm: 'xxxx091781c235b0c828xxxx'
},
'playstart': {
mspm: 'xxxx91981c235b0c8286xxxx',
_resource_1_id: '',
_resource_1_type: 'school'
},
viewstart: {
mspm: 'xxxxd091781c235b0c828xxx',
type: 'page'
},
viewend: {
mspm: 'xxxx17b1b200b0c2e3xxxxxx',
type: 'page',
_time: ''
}
});
export default Tracker;
export {
generateConfig,
TrackerExposure,
appendQueue
};
import React, { useEffect, useState } from 'react';
import Tracker, { generateConfig, TrackerExposure, appendQueue } from './tracker.js';
const Demo = () => {
const [opusList, setOpusList] = useState([]);
useEffect(() => {
Tracker.init({
common: {
osVer: 'xxx',
activityId: 'xxx',
},
config: generateConfig()
});
// fetch opuslist
setOpusList(opus);
}, []);
const handleStart = () => {
appendQueue({
name: 'playstart',
action: 'playstart'
});
}
return <>
{
opusList.map(opus =>
{
({ addRef }) => {opus.name}
}
)
}
<>;
}
总结
我们在音街移动站中进行了迁移、在多个运营活动中进行了使用,达到了我们预期的目标;在提效方面,埋点库把费时的部分处理了,我们需要做的就是从埋点平台把坑位信息放入配置文件,业务开发的时候使用对应的组件就可以了,几乎没有太大的成本,且对于代码复用和维护来说也达到了目的。
在使用过程中发现对于点击类埋点 appendQueue 使用频率远高于 TrackerClick 组件,因为大部分元素的点击事件都有他自己的回调函数,但是我们使用 TrackerClick 的初衷是埋点代码和业务代码解耦,这个也要根据实际场景去选择。
参考资料
- MDN/IntersectionObserver
- react-intersection-observer
- page-lifecycle
本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!