前端埋点踩坑总结 最近在做一个项目的埋点需求,踩了不少坑,这里总结一下前端埋点的一些方法。
常见前端埋点方案 我们对目前市场上几种埋点方案进行了一些调研,常规有 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 >() 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 ) { 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 || {}), } }) } } }); } 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 ) const reportMessage = useCallback (debounce ((data: any ) => { 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 while (elem) { if (hits) break if (typeof elem?.onclick === 'function' ) { hits = true if (isContainInList (ignoreElements, e.target )) break reportMessage (elem); } 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