最近在做一个比较通用的前端性能监控平台。与前端异常监控不同,前端性能监控主要需要对前端性能数据进行上报和展示。包括首页渲染时间、各页面白屏时间、各页面所有资源的加载时间、各页面所有请求的响应时间等。本文介绍如何设计一个通用的jssdk,可以自动以较少的入侵报告前端性能数据。主要使用的是PerformanceAPI和sendBeacon方法等。主要参考了GoogleAnalytics和阿里云前端性能监控平台的实践。我在项目中使用nestjs作为后端框架。nestjs是一个基于express的node后端框架,完美支持typescript和javaspring。本文主要关注如何上报性能数据。后端处理逻辑比较简单,就不详细介绍了,所以不用懂nestjs。本文主要内容包括:根据PerformanceAPI获取前端性能数据性能数据应该在什么时候上报如何上报性能数据前端性能数据本文上报的前端性能数据包括两部分,一个是通过PerformanceAPI获取的性能数据,一个是自定义的应该在每个页面上报的数据。首先来看通过PerformanceAPI获取的数据。数据也包括两部分,当前页面的性能相关数据和当前页面的资源加载和异步请求相关的数据。(1)PerformanceAPI提供的性能数据window.performance.timing会返回一个对象,该对象包含与页面渲染相关的各种数据。本文不会详细介绍该对象,仅给出基于该对象计算相关性能数据的方法:lettimes={};让t=window.performance.timing;//重定向时间times.redirectTime=t.redirectEnd-t.redirectStart;//dns查询耗时times.dnsTime=t.domainLookupEnd-t.domainLookupStart;//TTFB读取页面第一个字节的时间times.ttfbTime=t.responseStart-t.navigationStart;//DNS缓存时间times.appcacheTime=t.domainLookupStart-t.fetchStart;//卸载页面的时间times.unloadTime=t.unloadEventEnd-t.unloadEventStart;//tcp连接耗时times.tcpTime=t.connectEnd-t.connectStart;//请求耗时次数.reqTime=t.responseEnd-t.responseStart;//解析dom树耗时times.analysisTime=t.domComplete-t.domInteractive;//白屏时间times.blankTime=t.domLoading-t.fetchStart;//domReadyTimetimes.domReadyTime=t.domContentLoadedEventEnd-t.fetchStart;上面的times对象包含了performance相关的属性,可以通过performance.timing中的相关属性计算得到结果。这里我们认为domReadyTime就是第一屏加载的时间。另外,还可以自定义上报首屏时间:比如在某些场景下,dom增量最大的点就是首屏渲染完成的时间。还有一些场景可以定义为visible增量最大的dom,就是首屏渲染完成的时间。(2)PerformanceAPI提供的资源加载和请求数据可以使用window.performance.getEntries()获取资源加载和请求相关数据。在每个页面中,需要加载很多js、css等资源,页面中也会有一些异步请求。这些资源加载和异步请求相关的数据可以通过window.performance.getEntries()获取。我们可以通过以下方式获取加载和异步请求数据:letentryTimesList=[];让entryList=window.performance.getEntries();entryList.forEach((item,index)=>{lettempleObj={};letusefulType=['navigation','script','css','fetch','xmlhttprequest','link','img'];if(usefulType.indexOf(item.initiatorType)>-1){templeObj.name=item.name;templeObj.nextHopProtocol=item.nextHopProtocol;//dns查询耗时templeObj.dnsTime=item.domainLookupEnd-item.domainLookupStart;//tcp连接耗时templeObj.tcpTime=item.connectEnd-item.connectStart;//请求时间templeObj.reqTime=item.responseEnd-item.responseStart;//重定向时间templeObj.redirectTime=item.redirectEnd-item.redirectStart;entryTimesList.push(templeObj);}});我们通过window.performance。getEntries()获取一个数组,里面有资源加载和异步请求相关的数据,然后根据数组中每个元素的initiatorType属性过滤出属性为['navigation','script','css','fetch','xmlhttprequest','链接','img'的元素数据之一](3),注意在单页应用中,通过window.performance.timing获取到的页面渲染相关数据,在url改变但页面不刷新时不会改变更新。所以仅仅通过这个API是无法获取到各个子路由对应的页面渲染时间的。如果需要在切换路由的情况下报告每个子页面的重新渲染时间,则需要自定义报告。通过window.performance.getEntries()获取到的资源加载和异步请求相关的数据会在页面切换路由时重新计算,可以实现自动上报。2.什么时候上报性能数据接下来,确定什么时候上报性能数据,因为要处理pv(访问量)和uv(唯一用户访问量)。一般认为一次报告就是一次访问,那么什么时候报告性能数据。在我的系统中,我选择在以下场景上报前端性能数据:页面加载并重新刷新页面,路由页面所在的tab标签再次可见。对于以上三种场景,尤其是切换路由时,如果路由切换是通过改变hash值来实现的,那么只需要监听hashchange事件即可。如果通过html5historyapi更改了url,则需要重新定义pushstate和replacestate事件。具体可以参考我之前的文章:如何在单页应用中优雅的监控URL变化。直接给history实现路由场景下监听url变化的解决方案:var_wr=function(type){varorig=history[type];returnfunction(){varrv=orig.apply(this,arguments);vare=新事件(类型);e.arguments=参数;window.dispatchEvent(e);返回房车;};};history.pushState=_wr('pushState');history.replaceState=_wr('replaceState');那么我们可以根据以上场景,分别监听对应的事件,从而实现前端性能数据的上报:addEvent(window,'load',function(e){...dealwithsomething});//根据历史记录监听单页路由中url的变化addEvent(window,'replaceState',function(e){...dealwithsomething});addEvent(window,'pushState',function(e){...dealwithsomething});//通过哈希切换路由场景addEvent(window,'hashchange',function(e){...dealwithsomething});addEvent('document','visibilitychang',function(e){...dealwithsomething})addEvent是一个兼容IE和标准DOM事件流模型的事件。3、性能数据如何上报那么性能数据如何上报,我们第一反应就是以ajax请求的形式上报前端性能数据。这种方法有一些缺陷,比如需要对跨域做特殊处理,如果页面被破坏,对应的ajax方法可能发送不成功。其中,跨域问题比较好处理,而最难解决的问题是第二点:即如果页面被破坏,对应的ajax方法可能无法成功发送。我们可以根据浏览器的兼容性和url的长度,按照googleanalytics(GA)中的方法,使用不同的方法来报告性能数据。主要原理是:通过动态创建img标签,拼接在img.src中,请求以url的形式发送,没有跨域限制。如果url太长,请使用sendBeacon发送请求。如果sendBeacon方法不兼容,则发送ajaxpost同步请求(1)。sendBeacon方法解决了文档卸载或页面关闭后异步ajax请求无法完成的问题。很多情况下我们会把异步变成同步。在页面卸载的unload或beforeunload事件中执行同步方法调用。但是同步方法调用有一个问题,就是会延迟页面A切换到页面B的时间,sendBeacon方法解决了这个问题。简单来说:sendBeacon方法可以在页面销毁期间异步发送数据,所以不会像同步ajax请求那样造成阻塞问题,也不会影响下一页的渲染。SendBeacon的调用方法是:navigator.sendBeacon(url[,data]);data可以是:ArrayBufferView,Blob,DOMString,orFormData为了传递参数,我们通常会把数据做成Blob的形式。另外需要注意的是,sendBeacon的请求头中,不支持Content-Type为“application/json;charset=utf-8”。在sendBeacon的header中,只支持三种形式的Content-Type:application/x-www-form-urlencodedmultipart/form-datatext/plain一般制定为application/x-www-form-urlencoded,完全通过sendBeacon发送请求示例如下:functionsendBeacon(url,data){//判断是否支持navigator.sendBeaconletheaders={type:'application/x-www-form-urlencoded'};让blob=newBlob([JSON.stringify(data)],headers);navigator.sendBeacon(url,blob);}后端如何处理sendBeacon请求?SendBeacon在请求头中发送一个类似POST的请求,所以可以类似post一样处理sendBeacon请求。一般我们约定ajax请求的content-type为:"application/json;charset=utf-8",sendBeacon请求的content-type为:"application/x-www-form-urlencoded",所以在后端处理,可以区分是普通的ajaxpost请求还是sendBeacon请求。另外,如果在处理请求的时候出现跨域问题,可以通过cors跨域来处理。后端需要配置:allow-control-allow-origin等,通过express的cors包可以简化配置:asyncfunctionbootstrap(){constapp=awaitNestFactory.create(ApplicationModule,instance);app.use(cors());等待app.listen(3000)}bootstrap();(2)动态创建img标签的形式以动态创建img标签的形式,指定src属性指定的url发送请求。首先,它不受跨域限制。其次,img标签的动态插入会延迟页面的卸载,保证图片的插入,所以可以保证在页面销毁期间可以请求。发生。下面是一个动态创建img标签的例子:functionimgReport(url,data){if(!url||!data){return;}letimage=document.createElement('img');让项目=[];items=JSON.Parse(数据);letname='img_'+(+newDate());image.onload=image.onerror=function(){};让newUrl=url+(url.indexOf('?')<0?'?':'&')+items.join('&');image.src=newUrl;}另外,我们在动态创建img标签发送请求的时候,我们请求的是一张图片,在后端处理的时候,最后要返回图片,这样image.onload方法前端的将被触发。我们以请求地址为:localhost:8080/1.jpg为例,后端处理逻辑为:@Controller('1.jpg')exportclassAppUploadController{constructor(privatereadonlyappService:AppService){}@Get()getUpload(@Req()req,@Res()res):void{...处理一些事情res.sendFile(join(__dirname,'..','public/1.jpg'))}}在get请求的处理中,我们通过res.sendFile(join(__dirname,'..','public/1.jpg'))返回图片后,会调用前端图片的onload方法.(3)同步ajaxpost请求??动态创建img标签的方式在拼接url时存在一定的问题,因为浏览器对url的长度有限制。sendBeacon方法的兼容性不是很好。最后的解决方案是发送一个同步ajax请求。前面说过,同步ajax请求会在页面销毁期之前执行,虽然会在一定程度上阻塞下一页的渲染。functionxmlLoadData(url,data){varclient=newXMLHttpRequest();client.open("POST",url,false);client.setRequestHeader("Content-Type","application/json;charset=utf-8");client.send(JSON.stringify(data));}(4)综合解决方案??一般先拼接完整的带参数的url,判断url的长度。如果url长度小于浏览器允许的最大长度,则通过动态创建img标签发送前端性能数据。如果url太长,判断浏览器是否支持sendBeacon方法。如果是,则通过sendBeacon方法发送请求,否则发送同步ajax请求。functiondealWithUrl(url,appId){lettimes=performanceInfo(appId);让项目=解耦(次);让urlLength=(url+(url.indexOf('?')<0?'?':'&')+items.join('&')).length;如果(urlLength<2083){imgReport(url,时间);}elseif(navigator.sendBeacon){sendBeacon(url,times);}else{xmlLoadData(url,times);}}
