在單頁應用中,如何優雅的上報前端效能資料

yuxiaoliang發表於2019-01-14

  最近在做一個較為通用的前端效能監控平臺,區別於前端異常監控,前端的效能監控主要需要上報和展示的是前端的效能資料,包括首頁渲染時間、每個頁面的白屏時間、每個頁面所有資源的載入時間以及每一個頁面中所以請求的響應時間等等。

  本文的介紹的是如何設計一個通用的jssdk,可以以較小的侵入性,自動上報前端的效能資料。主要採用的是Performance API以及sendBeacon方法等等。主要參考的是google analytics以及阿里雲前端效能監控平臺的實踐。

  在我的專案中使用nestjs作為後端框架,nestjs是基於express的一款完美支援typescript,類java spring的node後端框架。本文主要側重與如何上報效能資料,後端處理邏輯比較簡單,不會具體介紹,因此不需要了解如何使用nestjs。本文的主要內容包含了:

  • 根據Performance API獲取前端效能資料
  • 何時應該上報效能資料
  • 如何上報效能資料

原文在我的部落格中,歡迎star

github.com/fortheallli…


一、根據Performance API 獲取前端效能資料

本文上報的前端效能資料包含兩部分,一是通過Performance API獲得的效能資料,二是自定義的在每個頁面應該上報的資料。

首先來看通過Performance API所獲取的資料,該資料也包含了兩個部分,當前頁面的效能相關資料以及當前頁面資源載入和非同步請求的相關資料。

(1)、Performance API 所提供的效能資料

window.performance.timing會返回一個物件,該物件包含了各種與頁面渲染所相關的資料。本文不會具體去介紹該物件,只給出根據該物件計算相關效能資料的方法:

  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就是首屏載入的時間,此外也可以自定義的方法上報首屏的時間:

比如有些場景可以認為是dom增量最大的點為首屏渲染完成的時間,也有一些場景可以定義可見的dom在增量最大處為首屏渲染完成的時間。

(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()所獲取的資源載入和非同步請求所相關的資料,在頁面切換路由的時候會重新的計算,可以實現自動的上報。

二、何時上報效能資料

  接著來確定應該何時上報效能資料,因為要處理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事件流模型的事件。

三、如何上報效能資料

  那麼如何上報效能資料呢,我們第一反應就是通過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);
      }
    }
複製程式碼

相關文章