在單頁應用中,如何優雅的上報前端性能數據


  最近在作一個較爲通用的前端性能監控平臺,區別於前端異常監控,前端的性能監控主要須要上報和展現的是前端的性能數據,包括首頁渲染時間、每一個頁面的白屏時間、每一個頁面全部資源的加載時間以及每個頁面中因此請求的響應時間等等。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);
      }
    }
相關文章
相關標籤/搜索