最近在做一個較爲通用的前端性能監控平臺,區別於前端異常監控,前端的性能監控主要需要上報和展示的是前端的性能數據,包括首頁渲染時間、每個頁面的白屏時間、每個頁面所有資源的加載時間以及每一個頁面中所以請求的響應時間等等。
本文的介紹的是如何設計一個通用的jssdk,可以以較小的侵入性,自動上報前端的性能數據。主要採用的是Performance API以及sendBeacon方法等等。主要參考的是google analytics以及阿里雲前端性能監控平臺的實踐。
在我的項目中使用nestjs作爲後端框架,nestjs是基於express的一款完美支持typescript,類java spring的node後端框架。本文主要側重與如何上報性能數據,後端處理邏輯比較簡單,不會具體介紹,因此不需要了解如何使用nestjs。本文的主要內容包含了:
- 根據Performance API獲取前端性能數據
- 何時應該上報性能數據
- 如何上報性能數據
原文在我的博客中,歡迎star
https://github.com/forthealllight/blog/issues/38
一、根據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);
}
}