在单页应用中,如何优雅的上报前端性能数据


  最近在作一个较为通用的前端性能监控平台,区别于前端异常监控,前端的性能监控主要须要上报和展现的是前端的性能数据,包括首页渲染时间、每一个页面的白屏时间、每一个页面全部资源的加载时间以及每个页面中因此请求的响应时间等等。css

  本文的介绍的是如何设计一个通用的jssdk,能够以较小的侵入性,自动上报前端的性能数据。主要采用的是Performance API以及sendBeacon方法等等。主要参考的是google analytics以及阿里云前端性能监控平台的实践。html

  在个人项目中使用nestjs做为后端框架,nestjs是基于express的一款完美支持typescript,类java spring的node后端框架。本文主要侧重与如何上报性能数据,后端处理逻辑比较简单,不会具体介绍,所以不须要了解如何使用nestjs。本文的主要内容包含了:前端

  • 根据Performance API获取前端性能数据
  • 什么时候应该上报性能数据
  • 如何上报性能数据

原文在个人博客中,欢迎starhtml5

https://github.com/forthealll...java


1、根据Performance API 获取前端性能数据

本文上报的前端性能数据包含两部分,一是经过Performance API得到的性能数据,二是自定义的在每一个页面应该上报的数据。node

首先来看经过Performance API所获取的数据,该数据也包含了两个部分,当前页面的性能相关数据以及当前页面资源加载和异步请求的相关数据。git

(1)、Performance API 所提供的性能数据

window.performance.timing会返回一个对象,该对象包含了各类与页面渲染所相关的数据。本文不会具体去介绍该对象,只给出根据该对象计算相关性能数据的方法:github

let times = {};
  let 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;
  
  //request请求耗时
  times.reqTime = t.responseEnd - t.responseStart;
  
  //解析dom树耗时
  times.analysisTime = t.domComplete - t.domInteractive;
  
  //白屏时间
  times.blankTime = t.domLoading - t.fetchStart;
  
  //domReadyTime
  times.domReadyTime = t.domContentLoadedEventEnd - t.fetchStart;

在上面的times对象中就包含了性能相关的属性,根据performance.timing中的相关属性计算就能够获得结果。在这里咱们认为domReadyTime就是首屏加载的时间,此外也能够自定义的方法上报首屏的时间:ajax

好比有些场景能够认为是dom增量最大的点为首屏渲染完成的时间,也有一些场景能够定义可见的dom在增量最大处为首屏渲染完成的时间。spring

(2)、Performance API 所提供的资源加载和请求数据

  能够经过window.performance.getEntries()来获取资源的加载和请求相关的数据。每个页面中,须要去加载不少资源好比js、css等等,同时在页面中还会存在一些异步请求。经过window.performance.getEntries()能够得到这些资源加载和异步请求所相关的数据。咱们能够经过以下的方式来获取加载和异步请求的数据:

let  entryTimesList = [];
  let entryList = window.performance.getEntries();
  entryList.forEach((item,index)=>{
  
     let templeObj = {};
     
     let usefulType = ['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','link','img']之一的元素数据。

(3)、注意点

  • 经过window.performance.timing所获的的页面渲染所相关的数据,在单页应用中改变了url但不刷新页面的状况下是不会更新的。所以若是仅仅经过该api是没法得到每个子路由所对应的页面渲染的时间。若是须要上报切换路由状况下每个子页面从新render的时间,须要自定义上报。
  • 经过window.performance.getEntries()所获取的资源加载和异步请求所相关的数据,在页面切换路由的时候会从新的计算,能够实现自动的上报。

2、什么时候上报性能数据

  接着来肯定应该什么时候上报性能数据,由于要处理pv(访问量)和uv(独立用户访问量),通常认为一次上报就是一次访问,那么什么时候上报性能数据呢。在个人系统中选择在一下场景下进行一次前端性能数据的上报:

  • 页面加载和从新刷新
  • 页面切换路由
  • 页面所在的tab标签从新变得可见

针对上述的3种场景,特别是切换路由的状况,若是切换路由是经过改变hash值来实现的,那么只须要监听hashchange事件,若是是经过html5的history api来改变url的,那么须要从新定义pushstate和replacestate事件。具体的作法能够看个人上一篇文章:在单页应用中,如何优雅的监听url的变化

直接给出history实现路由场景下监听url改变的方案:

var _wr = function(type) {
   var orig = history[type];
   return function() {
       var rv = orig.apply(this, arguments);
      var e = new Event(type);
       e.arguments = arguments;
       window.dispatchEvent(e);
       return rv;
   };
};
 history.pushState = _wr('pushState');
 history.replaceState = _wr('replaceState');

而后咱们就能够根据上述场景,分别监听相应的事件,从而实现前端性能数据的上报:

addEvent(window,'load',function(e){
    ...deal with something
});
//监控history基础上实现的单页路由中url的变化
addEvent(window,'replaceState', function(e) {
    ...deal with something
});
addEvent(window,'pushState', function(e) {
    ...deal with something
});
//经过hash切换来实现路由的场景
addEvent(window,'hashchange',function(e){
   ...deal with something
});
addEvent('document','visibilitychang',function(e){
   ...deal with something
})

addEvent是一个兼容IE和标准DOM事件流模型的事件。

3、如何上报性能数据

  那么如何上报性能数据呢,咱们第一反应就是经过ajax请求的形式来上报前端性能数据。这种方法有一些缺陷,好比必须对跨域作特殊处理以及若是页面销毁后,相应的ajax方法并不必定发送成功等问题。

其中跨域的问题比较好处理,最难解决的问题是第二点:

就是若是页面销毁,那么对应的ajax方法并不必定能成功发送。

  咱们能够根据google analytics(GA)中的方法,根据浏览器的兼容性以及url的长度,来采用不一样的方法上报性能数据,主要原理是:

经过动态建立img标签的方式,在img.src中拼接url的方式发送请求,不存在跨域限制。若是url太长,则才用sendBeacon的方式发送请求,若是sendBeacon方法不兼容,则发送ajax post同步请求

(1)、sendBeacon方法

  解决在文档卸载或者页面关闭后没法完成异步ajax请求的问题,不少状况下咱们会把异步变成同步。在页面卸载的unload或者beforeunload事件中执行同步方法调用。

可是同步方法调用存在一个问题,就是会推迟A页面切换进入B页面的时间。而sendBeacon方法解决了该问题,简单来讲:

sendBeacon方法在页面销毁期,能够异步的发送数据,所以不会形成相似同步ajax请求那样的阻塞问题,也不会影响下一个页面的渲染

sendBeacon的调用方式为:

navigator.sendBeacon(url [, data]);

data能够为: ArrayBufferView, Blob, DOMString, 或者 FormData

为了发送参数,咱们通常data制定为Blob的形式。此外还要注意的是,在sendBeacon的请求头header中,不支持Content-Type为“application/json; charset=utf-8”。

在sendBeacon的header中,只支持一下3种形式的Content—Type:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

通常制定为application/x-www-form-urlencoded,完整的经过sendBeacon来发送请求的例子以下:

function sendBeacon(url,data){
  //判断支不支持navigator.sendBeacon
  let headers = {
    type: 'application/x-www-form-urlencoded'
  };
  let blob = new Blob([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”,这样在后端处理中,就能够区别是正常的ajax post请求仍是sendBeacon请求。

此外,在处理请求的时候若是存在跨域问题,经过cors跨域的方式来处理,后端须要配置:allow-control-allow-origin等,能够经过express的cors包,来简化配置:

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule,instance);
  app.use(cors());

  await app.listen(3000)
}
bootstrap();

(2)动态建立img标签的形式

  经过动态建立img标签的形式,指定src属性所指定的url来发送请求,首先不受跨域的限制,其次img标签动态插入,会延迟页面的卸载保证图片的插入,所以能够保证在页面的销毁期,请求能够发生。

下面是一个动态建立img标签的例子:

function imgReport(url, data) {
   if (!url || !data) {
       return;
   }
   let image = document.createElement('img');
   let items = [];
   items = JSON.Parse(data);
   let name = 'img_' + (+new Date());
   image.onload = image.onerror = function () {
      
   };
   let newUrl = url + (url.indexOf('?') < 0 ? '?' : '&') + items.join('&');

   image.src = newUrl;
}

此外,咱们在动态建立img标签发送请求的时候,请求的是一张图片,在后端处理的时候,要在末尾将这个图片返回,这样前端的image.onload方法才会被触发。咱们以请求的地址为:localhost:8080/1.jpg为例,后端的处理逻辑为:

@Controller('1.jpg')
export class AppUploadController {
  constructor(private readonly appService: AppService) {}
  @Get()
  getUpload(@Req() req,@Res() res): void {
  
    ...deal with some thing
    res.sendFile(join(__dirname, '..', 'public/1.jpg'))
  }
}

在get请求的处理中,咱们经过res.sendFile(join(__dirname, '..', 'public/1.jpg'))将图片返回后,这样前端的image的onload方法才会被调用。

(3)同步ajax post请求

  动态建立img标签的方法,拼接url的时候存在必定的问题,由于浏览器对url的长度是有限制的。而sendBeacon方法兼容性不是很好,最后兜底的处理方式就是发送同步的ajax请求,同步的ajax请求前面说过,会在页面销毁期以前执行,虽然会有必定程度的阻塞下一个页面的渲染。

function xmlLoadData(url,data) {
  var client = new XMLHttpRequest();
  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请求。

function dealWithUrl(url,appId){
      let times = performanceInfo(appId);
      let items = decoupling(times);
      let urlLength = (url + (url.indexOf('?') < 0 ? '?' : '&') + items.join('&')).length;
      if(urlLength<2083){
        imgReport(url,times);
      }else if(navigator.sendBeacon){
        sendBeacon(url,times);
      }else{
        xmlLoadData(url,times);
      }
    }
相关文章
相关标签/搜索