前端埋点踩坑总结

前端埋点踩坑总结

最近在做一个项目的埋点需求,踩了不少坑,这里总结一下前端埋点的一些方法。

常见前端埋点方案

我们对目前市场上几种埋点方案进行了一些调研,常规有 3 种方案:

手动代码埋点:用户触发某个动作后手动上报数据

  • 优点:是最准确的,可以满足很多定制化的需求。
  • 缺点:埋点逻辑与业务代码耦合到一起,不利于代码维护和复用。

可视化埋点:通过可视化工具配置采集节点,指定自己想要监测的元素和属性。核心是查找 dom 然后绑定事件,业界比较有名的是 Mixpanel

  • 优点:可以做到按需配置,又不会像全埋点那样产生大量的无用数据。
  • 缺点:比较难加载一些运行时参数;页面结构发生变化的时候,可能就需要进行部分重新配置。

无埋点:也叫“全埋点”,前端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据

  • 优点:收集用户的所有端上行为,很全面。
  • 缺点:无效的数据很多、上报数据量大。

选择的埋点方案

由于项目的工期比较赶,可以分配进行埋点的工时有限。而且策划主要是希望分析一些页面pv以及按钮的点击事件,需求整体比较简单,可以接受有部分脏数据后期导入到bi系统时候可以进行过滤。

所以我们采用了全埋点的手法,希望通过全局注册部分方法,来进行全局埋点同时尽量减少对业务代码的入侵。

同时这我们也使用了我们之前开发的日志上报工具,能过进行日志的缓存合并,定时发送等功能,减少网络流量。

页面访问埋点

对于页面访问的埋点,我们如果我们使用了React Router或者umi等路由框架的可以在history中直接绑定事件进行页面访问日志收集以及统计。代码如下可以简单参考。

1
2
3
4
5
6
7
8
9
10
11
12
const unlisten = history.listen((location: any) => {
if (prePathName !== location.pathname) {
easyLogReport.log({
eventType: 'page_view',
elemId: 'App',
extraParams: {
path: location.pathname
}
})
prePathName = location.pathname;
}
});

上面的方案没有办法对页面进行更加精细化的区分,例如页面的层级结构,页面的自定义中文名称,页面是否注销等进行记录,所以我们还有另外一个更加详细的版本。利用useEffect来进行页面生成时候以及离开页面时候的记录。同时由于react hook需要绑定到指定页面中,我们也可以针对某些页面进行特化处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const usePageViewLog = (other?: any) => {
const history = useHistory();
const {data, setData} = useContext(LogDataContext)
const query = (history.location as any).query
const params = useParams();
const [backupData, setBackupData] = useState<any>()

/* 有时候会进行url params query的调整,进行debounce发送最后确定的url */
const reportMessage = useCallback(debounce((logData: any) => {
easyLogReport.log({
eventType: 'page_view',
extraParams: logData
})
}, 500), []);

useEffect(() => {
let logData: any = {
path: history.location.pathname,
...query,
...params,
...(other || {}),
}
if(!noGame) {
logData.game = game?.gameName
}
setData && setData(logData)
setBackupData(logData)
reportMessage(logData)
}, [history.location.pathname, query, params, other])

return [data]
}

export default usePageViewLog

点击埋点

点击埋点我们一开始参考了网易云音乐的埋点方案,打算使用一层组件去包裹需要监听的元素。但是我们不希望在项目中增加上百个TrackerClick,所以我们希望能够在最外层包裹一层TrackerClick然后使用props.children一直遍历所有的node节点,并且找出onClick的方法进行进行点击埋点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
interface TrackerClickProps {
extra?: any
}

const ClickTracker: React.FC<TrackerClickProps> = ({ extra, children}) => {
const { data } = useContext(LogDataContext)


function AddClickEvent(ele: any) {
// console
return React.cloneElement(ele, {
onClick: (e) => {
const originClick = ele.props.onClick;
if(originClick) {
originClick?.call(ele, e);
easyLogReport.log({
eventModule: data?.eventModule,
eventType: 'btn_click',
extraParams: {
...data,
...(extra || {}),
}
})
}
}
});
}

/***
* 简单递归,会导致antd form部分绑定失效
* @param ele
*/
function findHtmlElement(ele: any): any {
if(!ele) return
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 (ele?.props?.onClick) {
return AddClickEvent(ele);
}

if(ele?.props?.children) {
const newEle = React.cloneElement(ele, {}, React.Children.map(ele.props.children, findHtmlElement));
return newEle
}else {
return ele;
}
}
return findHtmlElement(React.Children.only(children));
}

上面的代码如同网易云音乐的方案里面所描述的一样,存在着一些问题,比如使用者并不清楚里面的实现细节,有可能里面没有一个 container 包裹,也可能使用了 React.Fragment 造成一些不可预估的行为、同时也无形的增加了dom结构层级(虽然我们没有引入,但是我们在告诉用户,你最好有个 container )。同时也会导致我们的form组件里面的事件绑定部分失效。 所以我们只能再找其他方案。

同时上面的方法还有一个问题,就是如果ClickTracker放在最顶层的node,那么只要他的children更新了,ClickTracker也必须跟随更新,但是ClickTracker的更新是非常耗费资源的,代码里面可以看到,他将不断递归进行node的创建。这会让部分场景的页面卡顿或者渲染出现闪烁。

我们又想到了直接在body处绑定点击监听事件,让用户的每一次点击我们都进行监听,找到我们需要的点击事件,其余的就都过滤掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
export const useClickLog: (logData?: any) => [React.MutableRefObject<any>, (data: any, type: any) => void] = (logData) => {
/* 定义一些公共参数 */
const { data: contextData } = useContext(LogDataContext)

/* debounce防抖处理 */
const reportMessage = useCallback(debounce((data: any) => {
// console.log('data', data.textContent.trim(), data.className)
easyLogReport.log({
eventModule: contextData?.eventModule,
eventType: 'btn_click',
extraParams: {
btn_name: data?.dataset?.btnName || data?.textContent?.trim(),
btn_class: data?.className,
...contextData,
...logData
}
})
}, 300), [contextData, logData]);

useEffect(() => {
const handleClick = (e: any) => {
const ignoreElements = document.getElementsByClassName('click-log-ignore')
const ignoreElementsLength = ignoreElements.length
if(typeof e.target.onclick == "function") {
if(isContainInList(ignoreElements, e.target)) return
reportMessage(e.target);
}else {
let elem = e.target
// 提前结束循环,节约资源
let hits = false
// 反响查找onClick
while (elem) {
if(hits) break
// @ts-ignore
if (typeof elem?.onclick === 'function') {
hits = true
if (isContainInList(ignoreElements, e.target)) break
reportMessage(elem);
}
// @ts-ignore
elem = elem.parentNode;
}
}
};

document.body.addEventListener("click", handleClick, {capture: true});


return function () {
document.body.removeEventListener("click", handleClick, {capture: true});
};
}, [contextData, logData, reportMessage]);

const isContainInList = (elementList: HTMLCollectionOf<Element>, targetElement: Element) => {
let isInList = false
// 判断是否需要忽略
for(let j = 0; j < elementList.length; j++) {
if(elementList[j] === targetElement || elementList[j].contains(targetElement)) {
isInList = true
}
}
return isInList
}

return ;
};

这里我们可以对event.target进行反向回溯,找到他的父母节点是否有onclick事件,如果有就证明用户点击了有绑定事件的元素,需要上报日志。同时我们也可以自行添加一些css类,来过滤部分点击,如点击下拉选择器的点击,表单里面一些填写数据的点击等。

页面滚动展示元素埋点

关于页面滚动的可以有两种方法监听,一种是比较旧的方法,监听scroll事件,这种方法兼容大部分的旧版浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
window.addEventListener('scroll', function() {
var element = document.querySelector('#main-container');
var position = element.getBoundingClientRect();

// 元素全部都能在屏幕中展示
if(position.top >= 0 && position.bottom <= window.innerHeight) {
console.log('Element is fully visible in screen');
}

// 元素部分在屏幕中展示
if(position.top < window.innerHeight && position.bottom >= 0) {
console.log('Element is partially visible in screen');
}
});

还有一种是使用Intersection Observer的api,这种方法代码更加简介,而且性能会好一点,毕竟不用每次scroll都跑一次判断。

1
2
3
4
5
6
7
var observer = new IntersectionObserver(function(entries) {
// 元素部分在屏幕中展示
if(entries[0].isIntersecting === true)
console.log('Element has just become visible in screen');
}, { threshold: [0] });

observer.observe(document.querySelector("#main-container"));
1
2
3
4
5
6
7
var observer = new IntersectionObserver(function(entries) {
// 元素全部都能在屏幕中展示
if(entries[0].isIntersecting === true)
console.log('Element is fully visible in screen');
}, { threshold: [1] });

observer.observe(document.querySelector("#main-container"));

把上面的代码包装为一个component,对需要监测的元素进行包裹即可。

其他情况

对于其他一些特殊情况的埋点统计,一开始是希望使用装饰器进行埋点处理,因为之前有同事也介绍过在python中用装饰器进行埋点。但是在React中,如果使用了函数式组件,你将无法使用装饰器。原因是装饰器只能用于类和类的属性、方法,不能用于函数,因为存在函数提升。类是不会提升的。

参考资料

ES6 Decorator js中的装饰器函数

前端组件化埋点的实践

How to Know when an Element Gets Visible in the Screen During Scrolling

Github trackpoint-tools


前端埋点踩坑总结
https://zjw93615.github.io/2023/07/16/杂谈/前端埋点踩坑总结/
作者
前菜
发布于
2023年7月16日
许可协议