大家好,我是杨成功。在上一篇文章中,我们详细介绍了前端是如何采集异常数据的。收集异常数据的目的是随时监控线上项目的运行情况,及时修复问题。在很多场景下,除了异常监控的用处外,收集用户行为数据也很有意义。如何定义行为数据?顾名思义,就是用户在使用产品过程中产生的行为轨迹。比如访问了哪些页面,点击了哪些按钮,甚至在某个页面停留了多长时间,某个按钮被点击了多少次,必要时都可以记录下来。然而,记录行为数据是与业务密切相关的事情。不可能非常详细地记录每个用户的每一步。这样会产生非常大的数据,这显然是不现实的。合理的做法是根据产品的实际情况评估需要重点关注哪些模块和按钮,然后详细收集;哪些模块不需要关注,简单记录一下基本信息。按照这个逻辑,我们可以将行为数据分为两类:一般数据和特定数据。下面分别介绍如何收集这两类数据。通用数据在一个产品中,用户最基本的行为就是切换页面。用户使用了哪些功能,也可以从切换页面反映出来。所以一般在页面切换的时候会产生通用数据,表示某个用户访问了某个页面。页面切换对应前端是路由切换,通过监听路由变化可以获取到新页面的数据。Vue在全局路由守卫中监听路由变化,任何路由切换都可以在这里执行回调函数。//Vue3routingconstrouter=createRouter({...})router.beforeEach(to=>{//to表示新页面的路由对象recordBehaviors(to)})React在useEffect中实现了同样的功能成分。但是需要注意的是,要监听所有的路由变化,所有的路由都需要经过这个组件,监听才会生效。具体方法是在配置路由的时候加入*配置:importHomePagefrom'@/pages/Home',然后在这个组件的useEffect中监听路由更改://HomePage.jsxconst{pathname}=useLocation();useEffect(()=>{//这个函数触发recordBehaviors(pathname);},[pathname]);调用recordBehaviors()方法并传入参数。Vue传递一个路由对象,React传递一个路由地址,然后就可以在这个函数中采集数据了。现在我们知道在哪里收集数据,我们需要知道要收集什么数据。收集行为数据最基本的字段如下:app:应用程序的名称/标识。env:应用环境,一般是开发、测试、生产。版本:应用程序的版本号。user_id:当前用户ID。user_name:当前用户名。page_route:页面路由。page_title:页面标题。start_at:进入时间。end_at:出发时间。上述字段中,应用标识、环境、版本号统称为应用字段,用于标识数据的来源。其他字段主要分为用户、页面、时间三类。通过这三类数据,可以很容易判断出一件事:谁去了哪个页面,停留了多长时间。如何配置和获取应用字段我们在上一节中设置了前端监控。如何采集异常数据?中有提到,我就不做多余的介绍了。获取字段的方法很常见。下面介绍如何获取其他类型的数据。获取用户信息现代前端应用存储用户信息的方式基本相同,一种在localStorage,一种在statemanagement。因此,可以从这两个地方的任何一个地方获取用户信息。这里简单介绍一下如何从状态管理中获取。最简单的方法是直接在函数recordBehaviors()所在的js文件中导入用户状态://Exportuserdatafromstatemanagementimport{UserStore}from'@/stores';let{user_id,user_name}=UserStore;这里的@/stores指向我项目中的文件src/stores/index.ts,表示状态管理的入口文件,使用时替换为自己项目的实际位置。实际情况中会出现用户数据为空的问题,需要单独处理,以便我们在后续查看数据时看到区别:import{UserStore}from'@/stores';//采集行为functionconstrecordBehaviors=()=>{letreport_date={...}if(UserStore){let{user_id,user_name}=UserStorereport_date.user_id=user_id||0report_date.user_name=user_name||'未命名'}else{report_date.user_id=user_id||-1report_date.user_name=user_name||'notobtained'}}上面代码中,首先判断状态管理中是否有用户数据,如果有则获取,如果没有则指定默认值。需要注意这里指定默认值的细节。这不是随机的。例如user_id的默认值有以下含义:user_id为0:表示有用户数据,但是没有user_id字段或者该字段为空。user_id为-1:表示没有用户数据,所以获取不到user_id字段。由于登录状态和权限等复杂问题,用户数据经常出错。指定以上默认值后,可以通过收集到的行为数据判断某个页面的用户状态是否正常。在获取页面信息之前,我们在监听路由变化的地方调用了recordBehaviors函数并传入了参数。可以从参数中获取页面信息。我们先看看在Vue中如何获取://路由配置{path:'/test',meta:{title:'TestPage'},component:()=>import('@/views/test/Index.vue')}//获取配置constrecordBehaviors=(to)=>{letpage_route=to.pathletpage_title=to.meta.title}Vue比较简单,直接通过参数获取页面数据即可。相比之下,React的参数只是一个路由地址,想要获取页面名称需要单独处理。一般在设计权限的时候,我们会在服务器端维护一组路由数据,包括路由地址和名称。路由数据是登录后获取的,保存在状态管理中,所以通过pathname,可以从路由数据中找到对应的路由名称。//在React中从'@/stores'导入{RouteStore};constrecordBehaviors=(pathname)=>{let{routers}=RouteStore;//取出路由数据letroute=routers.find((row)=>(row.path=pathname));如果(路线){让page_route=route.path;让page_title=route.title;}};这样也得到了页面信息的page_route和page_title字段。在设置时间行为数据中,start_at和end_at两个字段分别表示用户进入页面和离开页面的时间。这两个领域非常重要。我们在后面使用数据的时候可以判断出很多信息,比如:某个用户在某个页面停留了多长时间?用户在一定时间内停留在哪些页面?在一定时间内,用户在哪个页面停留的时间最长?哪些用户在某个页面上的使用率最高?根据这两个时间域可以判断出很多信息。开始时间很容易处理。函数触发时,直接获取当前时间:varstart_at=newDate();末世要考虑很多情况。首先,应该什么时候上报数据?用户进入页面或离开页面时报告?如果在进入页面的时候上报,可以保证行为数据会被记录下来,不会丢失,但是此时end_at字段必须为空。这种情况下,需要调整离开页面时的接口,更新这条记录的end_time。这个方法实现起来比较麻烦://进入页面时调用constrecordBehaviors=()=>{letreport_date={...}//此时end_at为空http.post('/behaviors/insert',report_date).then(res=>{letid=res.id//dataidlocalStorage.setItem('CURRENT_BEHAVIOR_ID',id)})}//离开页面时调用:constupdateBehaviors=()=>{letid=localStorage.getItem('CURRENT_BEHAVIOR_ID')letend_at=newDate()http.post('/behaviors/update/'+id,end_at)//根据id更新结束时间localStorage.removeItem('CURRENT_BEHAVIOR_ID')}上面代码中,先进入到上报数据的页面,保存id,离开页面,然后根据id更新这条数据的结束时间。如果在离开页面时上报,必须保证在离开页面前已经触发了上报接口,否则数据会丢失。如果满足这个前置条件,上报逻辑就会变成这样://进入页面时调用constrecordBehaviors=()=>{letreport_date={...}//此时end_at为空localStorage.setItem('CURRENT_BEHAVIOR',JSON.stringify(report_date));}//离开页面时调用constreportBehaviors=()=>{letend_at=newDate()letreport_str=localStorage.getItem('CURRENT_BEHAVIOR')if(report_str){letreport_date=JSON.parse(report_str)report_date.end_at=end_athttp.post('/behaviors/insert',report_date)}else{console.log('nobehaviordata')}}比较这两种方案,第一种缺点是接口需要调整两次,接口请求量会成倍增加。第二种方案只调用一次,但需要特别注意可靠性处理。一般来说,第二种解决方案更好。特定数据除了一般数据,大多数情况下我们还需要收集特定页面上的某些特定行为。比如某个按键按钮是否被点击,点击了多少次;或者用户是否看过某个关键区域,看过(曝光)了多少次,等等。采集数据还有一个更专业的名字——埋点。直观的理解就是在需要上报数据的地方,嵌入一个上报功能。所有页面自动采集通用数据,具体数据需要根据每个页面的实际需要手动添加。以一个按钮为例:Click;constonClick=(e)=>{//console.log(e);回购事件(e);};在上面的代码中,我们想要记录这个按钮的点击,所以我做了一个简单的埋点——在按钮的点击事件中调用repoerEvents()方法,这个方法会收集数据并在内部上报。这是最原始的埋点方式,直接把reporting方法放到eventfunction中。repoerEvents()方法接收一个事件对象参数,在该参数中获取需要上报的事件数据。特定数据和一般数据一样有很多字段,收集特定数据需要的基本字段如下:app:应用的名称/标识。env:应用环境,一般是开发、测试、生产。版本:应用程序的版本号。user_id:当前用户ID。user_name:当前用户名。page_route:页面路由。page_title:页面标题。created_at:触发时间。event_type:事件类型。action_tag:动作标签。action_label:动作描述。在这些基本字段中,前7个字段与前面的通用数据采集完全相??同,这里不再赘述。实际上,具体数据需要获取的专有字段只有3个:event_type:事件类型。action_tag:动作标签。action_label:动作描述。这三个字段也很容易访问。event_type表示事件触发的类型,如点击、滚动、拖动等,可以在事件对象中获取。action_tag和action_label是必须指定的属性,表示该埋点的标识和文字描述,方便后续数据处理时参考和统计。知道了如何采集具体的数据,下面我们就用代码来实现吧。手动上报埋点假设要埋点登录按钮,根据上面的数据采集方式,我们编写代码如下:登录constonClick=(e)=>{//console.log(e);回购事件(e);};在代码中,我们通过元素的自定义属性传递tag和label,在reporting函数中的Obtained中使用。上报函数repoerEvents()的代码逻辑如下://埋点上报函数constrepoerEvents=(e)=>{letreport_date={...}let{tag,label}=e.target.datasetif(!tag||!label){returnnewError('reportelementattributemissing')}report_date.event_type=e.typereport_date.action_tag=tagreport_date.action_label=label//报告数据http.post('/events/insert',report_date)}这样就实现了一个基本的特定数据埋点上报功能。全局自动上报现在我们回过头来梳理一下上报流程。虽然基本的功能已经实现了,但是还是有一些不合理的地方,比如:必须为元素指定一个事件处理器。必须向元素添加自定义属性。在原有事件处理函数中手动添加埋点,侵入性强。首先,我们的埋点方法是基于事件的,也就是说不管元素本身是否需要事件处理,我们都要加上,在函数内部调用repoerEvents()方法。如果一个项目需要埋很多地方,这种方式的接入成本会非常高。参考之前异常监控的逻辑,我们换个思路:全局监控事件能自动上报吗?想一想,如果要全局监听事件,只能监听需要埋点的元素的事件。那么如何判断需要嵌入哪些元素呢?上面我们为buried元素指定了两个自定义属性,data-tag和data-label。是不是可以根据这两个自定义属性来判断呢?我们来实验一下:window.addEventListener('click',(event)=>{let{tag,label,trigger}=event.target.dataset;if(tag&&label&&trigger=='click'){//意思是该元素需要嵌入repoerEvents(event);}});上面的代码还判断了一个自定义属性dataset.trigger,表示哪个事件触发了该元素需要上报。全局监听事件需要这个标志,可以避免事件冲突。添加全局监听器后,很容易收集某个元素的具体数据,如下:Save实验证明上述全局处理方式是可行的,这样就不需要在每个元素上添加或修改事件处理函数,只需要添加三个自定义属性data-tag、data-label、data-trigger可自动实现数据埋点上报。在组件报告上报告全局监控事件的方法比手动埋点要高效得多。现在让我们换个场景。一般情况下,当埋点功能成熟后,会打包成SDK供其他项目使用。如果我们按照SDK的思路收集数据,让开发者全局监听事件,是不是一个好办法?显然不是很友好。如果是SDK,那么最好的方式就是将所有内容聚合成一个组件,在组件中实现所有上报的功能,而不是要求用户在项目中添加监听事件。如果组件是封装的,那么组件的功能最好把要添加的元素包裹起来,这样自定义的元素不需要指定,而是转化为组件的一个属性,然后实现事件监听在组件中。以React为例,我们看看如何将上面的集合函数封装成一个组件:import{useEffect,useRef}from'react';constCusReport=(props)=>{constdom=useRef(null);consthandelEvent=()=>{console.log(props);//{tag:xx,label:xx,trigger:xx}repoerEvents(props);};useEffect(()=>{if(dom.currentinstanceofHTMLElement){dom.current.addEventListener(props.trigger,handelEvent);}},[]);return({props.children});};导出默认CusReport;组件使用如下:这样比较优雅,不需要修改targetelement,只需将组件包装在目标元素之外。小结本文介绍了在搭建前端监控时如何采集行为数据,并将数据分为通用数据和特定数据两类,分别进行处理。同时,还引入了多种上报数据的方式,不同的场景可以选择不同的方式。数据部分只介绍实现功能的基本字段,实际情况可以根据自己的业务需要添加。很多小伙伴评论说这个前端监控能不能开源,一定要开源,但是很多内容我还在做,等基本完善了再发一个版本。