前端性能監控是個老話題了,各個團隊都會對其有所關注,因為關注性能是工程師的本分。
  頁面性能對用戶體驗而言十分關鍵,每次重構或優化,僅靠手中的幾個設備或模擬的測試,缺少說服力,需要有大量的真實數據來做驗證。
  在2016年,我就寫過一篇《[前端頁面性能參數搜集](https://www.cnblogs.com/strick/p/5750022.html)》的文章,當時采用的還是W3C性能參數的[第一版](https://www.w3.org/blog/2012/09/performance-timing-information/),現在已有[第二版](https://www.w3.org/TR/navigation-timing-2/)了。
  在2020年,根據自己所學整理了一套監控系統,代號[菠蘿](https://github.com/pwstrick/pineapple),不過并沒有正式上線,所以只能算是個玩具。
  這次不同,公司急切的需要一套性能監控系統,用于分析線上的活動,要扎扎實實的提升用戶體驗。
  整個系統大致的運行流程如下:
:-: 
  2023-01-16 經過 TypeScript 整理重寫后,正式將監控系統的腳本開源,命名為?[shin-monitor](https://github.com/pwstrick/shin-monitor)。
## 一、SDK
  性能參數搜集的代碼仍然寫在前面的監控 [shin.js](https://github.com/pwstrick/shin-admin/blob/main/public/shin.js)(SDK) 中,為了兼容兩個版本的性能標準,專門編寫了一個函數。
~~~
function _getTiming() {
var timing =
performance.getEntriesByType("navigation")[0] || performance.timing;
var now = 0;
if (!timing) {
return { now: now };
}
var navigationStart;
if (timing.startTime === undefined) {
navigationStart = timing.navigationStart;
/**
* 之所以老版本的用 Date,是為了防止出現負數
* 當 performance.now 是最新版本時,數值的位數要比 timing 中的少很多
*/
now = new Date().getTime() - navigationStart;
} else {
navigationStart = timing.startTime;
now = shin.now() - navigationStart;
}
return {
timing: timing,
navigationStart: navigationStart,
now: _rounded(now)
};
}
~~~
  其實兩種方式得當的參數類似,第二版中的參數比第一版來的多,下面兩張圖是官方給的參數示意圖,粗看的話下面兩種差不多。
:-: 
W3C第一版的性能參數
:-: 
W3C第二版的性能參數
  但其實在將 performance.getEntriesByType("navigation")\[0\] 打印出來后,就會發現它還會包含頁面地址、傳輸的數據量、協議等字段。
**1)統計的參數**
  網上有很多種統計性能參數的計算方式,大部分都差不多,我選取了其中較為常規的參數。
~~~
shin.getTimes = function () {
// 出于對瀏覽器兼容性的考慮,仍然引入即將淘汰的 performance.timing
var currentTiming = _getTiming();
var timing = currentTiming.timing;
// var timing = performance.timing;
var api = {}; // 時間單位 ms
if (!timing) {
return api;
}
var navigationStart = currentTiming.navigationStart;
/**
* http://javascript.ruanyifeng.com/bom/performance.html
* 頁面加載總時間,有可能為0,未觸發load事件
* 這幾乎代表了用戶等待頁面可用的時間
* loadEventEnd(加載結束)-navigationStart(導航開始)
*/
api.loadTime = timing.loadEventEnd - navigationStart;
/**
* Unload事件耗時
*/
api.unloadEventTime = timing.unloadEventEnd - timing.unloadEventStart;
/**
* 執行 onload 回調函數的時間
* 是否太多不必要的操作都放到 onload 回調函數里執行了,考慮過延遲加載、按需加載的策略么?
*/
api.loadEventTime = timing.loadEventEnd - timing.loadEventStart;
/**
* 首次可交互時間
*/
api.interactiveTime = timing.domInteractive - timing.fetchStart;
/**
* 用戶可操作時間(DOM Ready時間)
* 在初始HTML文檔已完全加載和解析時觸發(無需等待圖像和iframe完成加載)
* 緊跟在DOMInteractive之后。
* https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-content-loaded-dcl
*/
api.domReadyTime = timing.domContentLoadedEventEnd - timing.fetchStart;
/**
* 白屏時間
* FP(First Paint)首次渲染的時間
*/
var paint = performance.getEntriesByType("paint");
if (paint && timing.entryType && paint[0]) {
api.firstPaint = paint[0].startTime - timing.fetchStart;
api.firstPaintStart = paint[0].startTime; // 記錄白屏時間點
} else {
api.firstPaint = timing.responseEnd - timing.fetchStart;
}
/**
* FCP(First Contentful Paint)首次有實際內容渲染的時間
*/
if (paint && timing.entryType && paint[1]) {
api.firstContentfulPaint = paint[1].startTime - timing.fetchStart;
api.firstContentfulPaintStart = paint[1].startTime; // 記錄白屏時間點
} else {
api.firstContentfulPaint = 0;
}
/**
* 解析DOM樹結構的時間
* DOM中的所有腳本,包括具有async屬性的腳本,都已執行。加載DOM中定義的所有頁面靜態資源(圖像、iframe等)
* loadEventStart緊跟在domComplete之后。在大多數情況下,這2個指標是相等的。
* 在加載事件開始之前可能引入的唯一額外延遲將由onReadyStateChange的處理引起。
* https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-complete
*/
api.parseDomTime = timing.domComplete - timing.domInteractive;
/**
* 請求完畢至DOM加載耗時
* 在加載DOM并執行網頁的阻塞腳本時觸發
* 在這個階段,具有defer屬性的腳本還沒有執行,某些樣式表加載可能仍在處理并阻止頁面呈現
* https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-interactive
*/
api.initDomTreeTime = timing.domInteractive - timing.responseEnd;
/**
* 準備新頁面耗時
*/
api.readyStart = timing.fetchStart - navigationStart;
/**
* 重定向次數(新)
*/
api.redirectCount = timing.redirectCount || 0;
/**
* 傳輸內容壓縮百分比(新)
*/
api.compression = (1 - timing.encodedBodySize / timing.decodedBodySize) * 100 || 0;
/**
* 重定向的時間
* 拒絕重定向!比如,http://example.com/ 就不該寫成 http://example.com
*/
api.redirectTime = timing.redirectEnd - timing.redirectStart;
/**
* DNS緩存耗時
*/
api.appcacheTime = timing.domainLookupStart - timing.fetchStart;
/**
* DNS查詢耗時
* DNS 預加載做了么?頁面內是不是使用了太多不同的域名導致域名查詢的時間太長?
* 可使用 HTML5 Prefetch 預查詢 DNS,參考:http://segmentfault.com/a/1190000000633364
*/
api.lookupDomainTime = timing.domainLookupEnd - timing.domainLookupStart;
/**
* SSL連接耗時
*/
var sslTime = timing.secureConnectionStart;
api.connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0;
/**
* TCP連接耗時
*/
api.connectTime = timing.connectEnd - timing.connectStart;
/**
* 內容加載完成的時間
* 頁面內容經過 gzip 壓縮了么,靜態資源 css/js 等壓縮了么?
*/
api.requestTime = timing.responseEnd - timing.requestStart;
/**
* 請求文檔
* 開始請求文檔到開始接收文檔
*/
api.requestDocumentTime = timing.responseStart - timing.requestStart;
/**
* 接收文檔(內容傳輸耗時)
* 開始接收文檔到文檔接收完成
*/
api.responseDocumentTime = timing.responseEnd - timing.responseStart;
/**
* 讀取頁面第一個字節的時間,包含重定向時間
* TTFB 即 Time To First Byte 的意思
* 維基百科:https://en.wikipedia.org/wiki/Time_To_First_Byte
*/
api.TTFB = timing.responseStart - timing.redirectStart;
/**
* 僅用來記錄當前 performance.now() 獲取到的時間格式
* 用于追溯計算
*/
api.now = shin.now();
// 全部取整
for (var key in api) {
api[key] = _rounded(api[key]);
}
/**
* 瀏覽器讀取到的性能參數,用于排查,并保留兩位小數
*/
api.timing = {};
for (var key in timing) {
const timingValue = timing[key];
const type = typeof timingValue;
if (type === "function") {
continue;
}
api.timing[key] = timingValue;
if (type === "number") {
api.timing[key] = _rounded(timingValue, 2);
}
}
return api;
};
~~~
  所有的性能參數最終都要被取整,以毫秒作單位。兼容的 timing 對象也會被整個傳遞到后臺,便于分析性能參數是怎么計算出來的。
  compression(傳輸內容壓縮百分比)是一個[新的參數](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#compression)。
  白屏時間的計算有兩種:
1. 第一種是調用 [performance.getEntriesByType("paint")](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformancePaintTiming)方法,再減去 fetchStart;
2. 第二種是用 responseEnd 來與 fetchStart 相減。
  在實踐中發現,每天有大概 2 千條記錄中的白屏時間為 0,而且清一色的都是蘋果手機,一番搜索后,了解到。
  當 iOS 設備通過瀏覽器的前進或后退按鈕進入頁面時,fetchStart、responseEnd 等性能參數很可能為 0。
  2023-01-19 發現當初始頁面的結構中,若包含漸變的效果時,1 秒內的白屏占比會從最高 94% 降低到 85%。
  loadTime(頁面加載總時間)有可能為0,就是當頁面資源還沒加載完,觸發 load 事件前將頁面關閉。
  如果這種很多,那就很有可能頁面被阻塞在某個位置,可能是接收時間過長、可能是DOM解析過長等。
  當這個頁面加載時間超過了用戶的心理承受范圍時,就需要抽出時間來做各個方面的頁面優化了。
  注意,在調用 performance.getEntriesByType("paint") 方法后,可以得到一個數組,第一個元素是白屏對象,第二個元素是[FCP](https://developer.mozilla.org/en-US/docs/Glossary/First_contentful_paint)對象。
  FCP(First Contentful Paint)是首次有實際內容渲染的時間,于 2022-08-16 新增該指標。
  在上線一段時間后,發現有大概 50% 的記錄,load 和 ready 是小于等于 0。查看原始的性能參數,如下所示。
~~~
{
"unloadEventStart": 0,
"unloadEventEnd": 0,
"domInteractive": 0,
"domContentLoadedEventStart": 0,
"domContentLoadedEventEnd": 0,
"domComplete": 0,
"loadEventStart": 0,
"loadEventEnd": 0,
"type": "navigate",
"redirectCount": 0,
"initiatorType": "navigation",
"nextHopProtocol": "h2",
"workerStart": 0,
"redirectStart": 0,
"redirectEnd": 0,
"fetchStart": 0.5,
"domainLookupStart": 3.7,
"domainLookupEnd": 12.6,
"connectStart": 12.6,
"connectEnd": 33.3,
"secureConnectionStart": 20.4,
"requestStart": 33.8,
"responseStart": 44.1,
"responseEnd": 46,
"transferSize": 1207,
"encodedBodySize": 879,
"decodedBodySize": 2183,
"serverTiming": [],
"name": "https://www.xxx.me/xx.html",
"entryType": "navigation",
"startTime": 0,
"duration": 0
}
~~~
  發現 domContentLoadedEventEnd 和 loadEventEnd 都是 0,一開始懷疑參數的問題。
  舊版的[performance.timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming)已經被廢棄,新版通過 performance.getEntriesByType('navigation')\[0\] 得到一個[PerformanceNavigationTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming)對象,它繼承自[PerformanceResourceTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming)類,但沒查到有什么問題。
  還有一種情況,就是 DOMContentLoaded 與 load 兩個事件都沒有觸發。關于兩者的觸發時機,網上的[一篇文章](https://juejin.cn/post/6844903623583891469)總結道:
* load 事件會在頁面的 HTML、CSS、JavaScript、圖片等靜態資源都已經加載完之后才觸發。
* DOMContentLoaded 事件會在 HTML 加載完畢,并且 HTML 所引用的內聯 JavaScript、以及外鏈 JavaScript 的同步代碼都執行完畢后觸發。
  在網上搜索一圈后,沒有發現阻塞的原因,那很有可能是自己代碼的問題。
  經查,是調用 JSBridge 的一種同步方式阻塞了兩個事件的觸發。代碼中的 t 就是一條鏈接。
~~~
window.location.href = t
~~~
  在加載腳本時,就會觸發某些 JSBridge,而有些手機就會被阻塞,有些并不會。解決方案就是將同步的跳轉改成異步的,如下所示。[鏈接]()
~~~
var iframe = document.createElement("iframe");
iframe.src = t;
document.body.append(iframe);
~~~
  值得一提的是,在將此問題修復后,首屏 1 秒內的占比從 66.7% 降到了 48.4%,2 秒內的占比從 20.7% 升到了 25.5%,3、4、4+ 秒的占比也都提升了。
**2)首屏時間**
  首屏時間很難計算,一般有幾種計算方式。
  第一種是算出首屏頁面中所有圖片都加載完后的時間,這種方法難以覆蓋所有場景(例如 CSS 中的背景圖、Image 元素等),并且計算結果并不準。
~~~
/**
* 計算首屏時間
* 記錄首屏圖片的載入時間
* 用戶在沒有滾動時候看到的內容渲染完成并且可以交互的時間
*/
doc.addEventListener(
"DOMContentLoaded",
function () {
var isFindLastImg = false,
allFirsrImgsLoaded = false,
firstScreenImgs = [];
//用一個定時器差值頁面中的圖像元素
var interval = setInterval(function () {
//如果自定義了 firstScreen 的值,就銷毀定時器
if (shin.firstScreen) {
clearInterval(interval);
return;
}
if (isFindLastImg) {
allFirsrImgsLoaded = firstScreenImgs.every(function (img) {
return img.complete;
});
//當所有的首屏圖像都載入后,關閉定時器并記錄首屏時間
if (allFirsrImgsLoaded) {
shin.firstScreen = _calcCurrentTime();
clearInterval(interval);
}
return;
}
var imgs = doc.querySelectorAll("img");
imgs = [].slice.call(imgs); //轉換成數組
//遍歷頁面中的圖像
imgs.forEach(function (img) {
if (isFindLastImg) return;
//當圖像離頂部的距離超過屏幕寬度時,被認為找到了首屏的最后一張圖
var rect = img.getBoundingClientRect();
if (rect.top + rect.height > firstScreenHeight) {
isFindLastImg = true;
return;
}
//若未超過,則認為圖像在首屏中
firstScreenImgs.push(img);
});
}, 0);
},
false
);
~~~
  第二種是自定義首屏時間,也就是自己來控制何時算首屏全部加載好了,這種方法相對來說要精確很多。
~~~
shin.setFirstScreen = function() {
this.firstScreen = _calcCurrentTime();
}
/**
* 計算當前時間與 fetchStart 之間的差值
*/
function _calcCurrentTime() {
return _getTiming().now;
}
/**
* 標記時間,單位毫秒
*/
shin.now = function () {
return performance.now();
}
~~~
  之所以未用 Date.now() 是因為它會受系統程序執行阻塞的影響, 而performance.now() 的時間是以恒定速率遞增的,不受系統時間的影響(系統時間可被人為或軟件調整)。
  在頁面關閉時還未獲取到首屏時間,那么它就默認是 domReadyTime(用戶可操作時間)。
  首屏時間(screen)有可能是負數,例如返回上一頁、刷新當前頁后,馬上將頁面關閉,此時 screen 的值取自 domReadyTime。
  domReadyTime 是由 domContentLoadedEventEnd 和 fetchStart 相減而得到,domContentLoadedEventEnd 可能是 0,fetchStart 是個非 0 值。
  這樣就會得到一個負值,不過總體占比并不高,每天在 300 條上下,0.3% 左右。
  2023-01-06 去掉了這兩種首屏算法,因為默認會采用 LCP 或 FMP 的計算結果。
**3)上報**
  本次上報與之前不同,需要在頁面關閉時上報。而在此時普通的請求可能都無法發送成功,那么就需要[navigator.sendBeacon()](https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon)的幫忙了。
  它能將少量數據異步 POST 到后臺,并且支持跨域,而少量是指多少并沒有特別指明,由瀏覽器控制,網上查到的資料說一般在 64KB 左右。
  在接收數據時遇到個問題,由于后臺使用的是 KOA 框架,解析請求數據使用了 koa-bodyparser 庫,而它默認不會接收?Content-Type: text 的數據,因此要額外配置一下,具體可[參考此處](https://stackoverflow.com/questions/53591683/how-to-access-request-payload-in-koa-web-framework)。
~~~
/**
* 在頁面卸載之前,推送性能信息
*/
window.addEventListener("beforeunload", function () {
var data = shin.getTimes();
if (shin.param.rate > Math.random(0, 1) && shin.param.pkey) {
navigator.sendBeacon(shin.param.psrc, _paramifyPerformance(data));
}
},
false
);
~~~
  在上報時,還限定了一個采樣率,默認只會把 50% 的性能數據上報到后臺,并且必須定義 pkey 參數,這其實就是一個用于區分項目的 token。
  本來一切都是這么的順利,但是在實際使用中發現,在 iOS 設備上調試發現不會觸發 beforeunload 事件,安卓會將其觸發,一番查找后,根據[iOS支持的事件](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW5)和[社區的解答](https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad),發現得用[pagehide](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/pagehide_event)事件替代。
  以為萬事大吉,但還是太年輕,在微信瀏覽器中的確能觸發 pagehide 事件,但是在自己公司APP中,表現不盡如意,無法觸發,若要監控關閉按鈕,得發一次版本。
  無奈,只能自己想了個比較迂回的方法,那就是在后臺跑個定時器,每 200ms 緩存一次要搜集的性能數據,在第二次進入時,再上報到后臺。
~~~
/**
* 組裝性能變量
*/
function _paramifyPerformance(obj) {
obj.token = shin.param.token;
obj.pkey = shin.param.pkey;
obj.identity = getIdentity();
obj.referer = location.href; // 來源地址
// 取 FCM、LCP 和用戶可操作時間中的最大值
obj.firstScreen = Math.max.call(
undefined,
shin.fmp.time,
shin.lcp.time,
obj.domReadyTime
);
obj.timing.lcp = shin.lcp; //記錄LCP對象
obj.timing.fmp = shin.fmp; //記錄FMP對象
obj.timing.fid = shin.fid; //記錄FID對象
// 靜態資源列表
var resources = performance.getEntriesByType("resource");
var newResources = [];
resources && resources.forEach(function (value) {
// 過濾 fetch 請求
if (value.initiatorType === "fetch") return;
// 只存儲 1 分鐘內的資源
if (value.startTime > 60000) return;
newResources.push({
name: value.name,
duration: _rounded(value.duration),
startTime: _rounded(value.startTime)
});
});
obj.resource = newResources;
return JSON.stringify(obj);
}
/**
* 均勻獲得兩個數字之間的隨機數
*/
function _randomNum(max, min) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
* iOS 設備不支持 beforeunload 事件,需要使用 pagehide 事件
* 在頁面卸載之前,推送性能信息
*/
var isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
var eventName = isIOS ? "pagehide" : "beforeunload";
var isNeedHideEvent = true; // 是否需求觸發隱藏事件
window.addEventListener(eventName, function () {
isNeedHideEvent && sendBeacon();
},
false
);
/**
* 在 load 事件中,上報性能參數
* 該事件不可取消,也不會冒泡
*/
window.addEventListener("load", function () {
/**
* 監控頁面奔潰情況
* 原先是在 DOMContentLoaded 事件內觸發,經測試發現,當因為腳本錯誤出現白屏時,兩個事件的觸發時機會很接近
* 在線上監控時發現會有一些誤報,HTML是有內容的,那很可能是 DOMContentLoaded 觸發時,頁面內容還沒渲染好
*/
setTimeout(function () {
monitorCrash(shin.param);
}, 1000);
// 加定時器是避免在上報性能參數時,loadEventEnd 為 0,因為事件還沒執行完畢
setTimeout(function () {
sendBeacon();
}, 0);
});
// var SHIN_PERFORMANCE_DATA = 'shin_performance_data';
// var heartbeat; // 心跳定時器
/**
* 發送 64KB 以內的數據
*/
function sendBeacon(existData) {
// 如果傳了數據就使用該數據,否則讀取性能參數,并格式化為字符串
var data = existData || _paramifyPerformance(shin.getTimes());
var rate = _randomNum(10, 1); // 選取1~10之間的整數
if (shin.param.rate >= rate && shin.param.pkey) {
navigator.sendBeacon(shin.param.psrc, data);
}
// clearTimeout(heartbeat);
// localStorage.removeItem(SHIN_PERFORMANCE_DATA); // 移除性能緩存
isNeedHideEvent = false;
}
/**
* 發送已存在的性能數據
*/
// function sendExistData() {
// var exist = localStorage.getItem(SHIN_PERFORMANCE_DATA);
// if (!exist) { return; }
// setTimeout(function() {
// sendBeacon(exist);
// }, 0);
// }
// sendExistData();
/**
* 一個心跳回調函數,緩存性能參數
* 適用于不能觸發 pagehide 和 beforeunload 事件的瀏覽器
*/
// function intervalHeartbeat() {
// localStorage.setItem(SHIN_PERFORMANCE_DATA, _paramifyPerformance(shin.getTimes()));
// }
// heartbeat = setInterval(intervalHeartbeat, 200);
~~~
  2023-01-06 去掉了對性能參數的緩存,因為在 load 事件中也會上報性能參數。
  并且也是為了減少對 FMP 的計算次數,消除不必要的性能損耗,避免錯誤的計算結果,故而做出了此決定。
  注意,首屏最終會取 FMP、LCP 和用戶可操作時間中的最大值。
**4)LCP**
  2022-07-12 新增該指標,LCP(Largest Contentful Paint)是指最大的內容在可視區域內變得可見的時間點。
  在 MDN 網站中,有一段[LCP](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)的計算示例,在此基礎之上,做了些兼容性判斷。
  通過[PerformanceObserver.observe()](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe)監控 LCP。entries 是一組[LargestContentfulPaint](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)類型的對象,它有一個 url 屬性,如果記錄的元素是個圖像,那么會存儲其地址。
~~~
var lcp;
function getLCP() {
var types = PerformanceObserver.supportedEntryTypes;
var lcpType = 'largest-contentful-paint';
// 瀏覽器兼容判斷
if(types.indexOf(lcpType) === -1) {
return;
}
new PerformanceObserver((entryList) => {
var entries = entryList.getEntries();
var lastEntry = entries[entries.length - 1];
lcp = lastEntry.renderTime || lastEntry.loadTime;
// 斷開此觀察者的連接,因為回調僅觸發一次
obs.disconnect();
// buffered 為 true 表示調用 observe() 之前的也算進來
}).observe({type: lcpType, buffered: true});
}
getLCP();
~~~
  在 iOS 的 WebView 中,只支持三種類型的 entryType,不包括 largest-contentful-paint,所以加了段瀏覽器兼容判斷。并且 entries 是一組[PerformanceResourceTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming)類型的對象。
  在《[Largest Contentful Paint 最大內容繪制](https://web.dev/i18n/zh/lcp/)》中提到,選項卡和頁面轉移到后臺后,得停止 LCP 的計算,因此需要找到隱藏到后臺的時間。
~~~
let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
// 記錄頁面隱藏時間 iOS 不會觸發 visibilitychange 事件
const onVisibilityChange = (event) => {
// 頁面不可見狀態
if (lcp && document.visibilityState === 'hidden') {
firstHiddenTime = event.timeStamp;
// 移除事件
document.removeEventListener('visibilitychange', onVisibilityChange, true);
}
}
document.addEventListener('visibilitychange', onVisibilityChange, true);
~~~
  利用[visibilitychange](https://developer.mozilla.org/zh-CN/docs/Web/API/Document/visibilitychange_event)事件,就能準備得到隱藏時間,然后在讀取 LCP 時,大于這個時間的就直接忽略掉。不過在實踐中發現,在 iOS 的 WebView 中并不支持此事件。
  公司要監測的 H5 頁面主要在移動平臺,選項卡的情況比較少,并且頁面結構比較簡單,一般都不會很久就能加載完成。
  largest-contentful-paint 不會計算 iframe 中的元素,返回上一頁也不會重新計算。不過,我們的頁面中基本不會加載 iframe,并且頁面都是以單頁的活動為主,跳轉也比較少。
  有個成熟的庫:[web-vitals](https://github.com/GoogleChrome/web-vitals),提供了 LCP、FID、CLS、FCP 和 TTFB 指標,對上述所說的特殊場景做了處理,若要了解原理,可以參考其中的計算過程。
  注意,LCP 會被一直監控(其監控的元素如下所列),這樣會影響結果的準確性。例如有個頁面首次進入是個彈框,確定后會出現動畫,增加些圖片,DOM結構也都會跟著改變。
* img 元素
* 內嵌在 svg 中的 image 元素
* video 元素(使用到封面圖片)
* 擁有背景圖片的元素(調用 CSS 的 url() 函數)
* 包含文本節點或或行內文本節點的塊級元素
  如果在關閉頁面時上報,那么 LCP 將會很長,所以需要選擇合適的上報時機,例如 load 事件中。
~~~
window.addEventListener("load", function () {
sendBeacon();
}, false
);
~~~
  優化后還有 5、6 千條記錄中的 load 是 0。查看參數記錄發現 loadEventEnd 是 0,而 loadEventStart 有時候是 0,有時候有值。
  可以在 load 事件中加個定時器,避免在上報性能參數時,loadEventEnd 為 0,因為此時事件還沒執行完畢。
~~~
window.addEventListener("load", function () {
setTimeout(function () {
sendBeacon();
}, 0);
}, false
);
~~~
  優化后,白屏 1 秒內的占比從 74.2% 提升到了 92.4%,首屏 1 秒內的占比從 48.6% 提升到了 78.8%。
  2022-11-30 發現讓 PerformanceObserver 的回調僅觸發一次而得到的結果會不準,例如有一個頁面,默認展示的是一張表示空內容的圖片,然后再去請求列表信息。
  那么第一次回調中選取的最大內容將是這張圖片,而用戶真正關心的其實是那個后請求的列表。
  所以暫停 LCP 的讀取時機要修改一下,參考[web-vitals](https://github.com/GoogleChrome/web-vitals/blob/main/src/onLCP.ts#L92)的代碼,當有按鍵或點擊(包括滾動)時,就停止 LCP 的采樣。
~~~
function getLCP() {
var lcpType = "largest-contentful-paint";
var isSupport = checkSupportPerformanceObserver(lcpType);
// 瀏覽器兼容判斷
if (!isSupport) {
return;
}
var po = new PerformanceObserver(function (entryList) {
var entries = entryList.getEntries();
var lastEntry = entries[entries.length - 1];
shin.lcp = {
time: _rounded(lastEntry.renderTime || lastEntry.loadTime), // 取整
url: lastEntry.url,
element: lastEntry.element ? lastEntry.element.outerHTML : ""
};
});
// buffered 為 true 表示調用 observe() 之前的也算進來
po.observe({ type: lcpType, buffered: true });
/**
* 當有按鍵或點擊(包括滾動)時,就停止 LCP 的采樣
* once 參數是指事件被調用一次后就會被移除
*/
["keydown", "click"].forEach((type) => {
window.addEventListener(
type,
function () {
// 斷開此觀察者的連接
po.disconnect();
},
{ once: true, capture: true }
);
});
}
~~~
**5)FID**
  這個[FID](https://developer.mozilla.org/en-US/docs/Glossary/First_input_delay)(First Input Delay)是用戶第一次與頁面交互(例如點擊鏈接、按鈕等操作)到瀏覽器對交互作出響應的時間,于 2022-08-16 新增該指標。
  延遲時間越長,用戶體驗越差。減少站點初始化時間和消除冗長的任務有助于消除首次輸入延遲。
  計算方式和 LCP 類似,也是借助 PerformanceObserver 實現,提煉了一個通用的判斷方法 checkSupportPerformanceObserver()。
~~~
/**
* 判斷當前宿主環境是否支持 PerformanceObserver
* 并且支持某個特定的類型
*/
function checkSupportPerformanceObserver(type) {
if (!PerformanceObserver) return false;
var types = PerformanceObserver.supportedEntryTypes;
// 瀏覽器兼容判斷
if (types.indexOf(type) === -1) {
return false;
}
return true;
}
/**
* 瀏覽器 FID 計算
* FID(First Input Delay)用戶第一次與頁面交互到瀏覽器對交互作出響應的時間
* https://developer.mozilla.org/en-US/docs/Glossary/First_input_delay
*/
function getFID() {
var fidType = "first-input";
var isSupport = checkSupportPerformanceObserver(fidType);
// 瀏覽器兼容判斷
if (!isSupport) {
return;
}
new PerformanceObserver(function (entryList, obs) {
const firstInput = entryList.getEntries()[0];
// 測量第一個輸入事件的延遲
shin.fid = _rounded(firstInput.processingStart - firstInput.startTime);
// 斷開此觀察者的連接,因為回調僅觸發一次
obs.disconnect();
}).observe({ type: fidType, buffered: true });
}
getFID();
~~~
  還有一個與交互有關的指標:[TTI](https://developer.mozilla.org/en-US/docs/Glossary/Time_to_interactive),TTI(Time to Interactive)可測量頁面從開始加載到主要子資源完成渲染,并能夠快速、可靠地響應用戶輸入所需的時間。
  它的計算規則比較繁瑣:
1. 先找到 FCP 的時間點。
2. 沿時間軸正向搜索時長至少為 5 秒的安靜窗口,其中安靜窗口的定義為:沒有長任務([Long Task](https://developer.mozilla.org/en-US/docs/Web/API/Long_Tasks_API))且不超過兩個正在處理的網絡 GET 請求。
3. 沿時間軸反向搜索安靜窗口之前的最后一個長任務,如果沒有找到長任務,則在 FCP 處終止。
4. TTI 是安靜窗口之前最后一個長任務的結束時間,如果沒有找到長任務,則與 FCP 值相同。
  下圖有助于更直觀的了解上述步驟,其中數字與步驟對應,豎的橙色虛線就是 TTI 的時間點。
:-: 
  TBT(Total Blocking Time)是指頁面從 FCP 到 TTI 之間的阻塞時間,一般用來量化主線程在空閑之前的繁忙程度。
  它的計算方式就是取 FCP 和 TTI 之間的所有長任務消耗的時間總和。
  不過網上[有些資料](https://web.dev/tti/)認為 TTI 可能會受當前環境的影響而導致測量結果不準確,因此更適合在實驗工具中測量,例如[LightHouse](https://github.com/GoogleChrome/lighthouse)、[WebPageTest](https://www.webpagetest.org/)等。
  Google 的[TTI Polyfill](https://github.com/GoogleChromeLabs/tti-polyfill)庫的第一句話就是不建議在線上搜集 TTI,建議使用 FID。
  下表是關鍵指標的基準線,參考字節的標準。
| Metric Name | Good(ms) | Needs Improvement(ms) | Poor(ms) |
| --- | --- | --- | --- |
| FP | 0-1000 | 1000-2500 | Over 2500 |
| FCP | 0-1800 | 1800-3000 | Over 3000 |
| LCP | 0-2500 | 2500-4000 | Over 4000 |
| TTI | 0-3800 | 3800-7300 | Over 7300 |
| FID | 0-100 | 100-300 | Over 300 |
**6)FMP**
  2023-01-06 研究了網上開源的各類算法中,初步總結了一套計算 FMP 的步驟。
  首先,通過[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver)監聽每一次頁面整體的 DOM 變化,觸發 MutationObserver 的回調。
  然后在回調中,為每個 HTML 元素(不包括忽略的元素)打上標記,記錄元素是在哪一次回調中增加的,并且用數組記錄每一次的回調時間。
~~~
var IGNORE_TAG_SET = ["SCRIPT", "STYLE", "META", "HEAD", "LINK"];
var WW = window.innerWidth;
var WH = window.innerHeight;
var cacheTrees = []; // 緩存每次更新的DOM元素
var FMP_ATTRIBUTE = "_ts";
var fmpObserver;
var callbackCount = 0;
/**
* 開始監控DOM的變化
*/
function initFMP() {
fmpObserver = new MutationObserver(() => {
var mutationsList = [];
// 為 HTML 元素打標記,記錄是哪一次的 DOM 更新
var doTag = function (target, callbackCount) {
var childrenLen = target.children ? target.children.length : 0;
// 結束遞歸
if (childrenLen === 0) return;
for (var children = target.children, i = childrenLen - 1; i >= 0; i--) {
var child = children[i];
var tagName = child.tagName;
if (
child.getAttribute(FMP_ATTRIBUTE) === null &&
IGNORE_TAG_SET.indexOf(tagName) === -1 // 過濾掉忽略的元素
) {
child.setAttribute(FMP_ATTRIBUTE, callbackCount);
mutationsList.push(child); // 記錄更新的元素
}
// 繼續遞歸
doTag(child, callbackCount);
}
};
// 從 body 元素開始遍歷
document.body && doTag(document.body, callbackCount++);
cacheTrees.push({
ts: performance.now(),
children: mutationsList
});
});
fmpObserver.observe(document, {
childList: true, // 監控子元素
subtree: true // 監控后代元素
});
}
initFMP();
~~~
  接著在觸發 load 事件時,先過濾掉首屏外和沒有高度的元素,以及元素列表之間有包括關系的祖先元素,再計算各次變化時剩余元素的總分。
  之前是只記錄沒有后代的元素,但是后面發現有時候 DOM 變化時,沒有這類元素。
~~~
/**
* 是否超出屏幕外
*/
function isOutScreen(node) {
var { left, top } = node.getBoundingClientRect();
return WH < top || WW < left;
}
/**
* 讀取 FMP 信息
*/
function getFMP() {
fmpObserver.disconnect(); // 停止監聽
var maxObj = {
score: -1, //最高分
elements: [], // 首屏元素
ts: 0 // DOM變化時的時間戳
};
// 遍歷DOM數組,并計算它們的得分
cacheTrees.forEach((tree) => {
var score = 0;
// 首屏內的元素
var firstScreenElements = [];
tree.children.forEach((node) => {
// 只記錄元素
if (node.nodeType !== 1 || IGNORE_TAG_SET.indexOf(node.tagName) >= 0) {
return;
}
var { height } = node.getBoundingClientRect();
// 過濾高度為 0,在首屏外的元素
if (height > 0 && !isOutScreen(node)) {
firstScreenElements.push(node);
}
});
// 若首屏中的一個元素是另一個元素的后代,則過濾掉該祖先元素
firstScreenElements = firstScreenElements.filter((node) => {
// 只要找到一次包含關系,就過濾掉
var notFind = !firstScreenElements.some(
(item) => node !== item && node.contains(item)
);
// 計算總得分
if (notFind) {
score += caculateScore(node);
}
return notFind;
});
// 得到最高值
if (maxObj.score < score) {
maxObj.score = score;
maxObj.elements = firstScreenElements;
maxObj.ts = tree.ts;
}
});
// 在得分最高的首屏元素中,找出最長的耗時
return getElementMaxTimeConsuming(maxObj.elements, maxObj.ts);
}
~~~
  不同類型的元素,權重也是不同的,權重越高,對頁面呈現的影響也越大。
  在 caculateScore() 函數中,通過[getComputedStyle](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getComputedStyle)得到 CSS 類中的背景圖屬性,注意,node.style 只能得到內聯樣式中的屬性。
~~~
var TAG_WEIGHT_MAP = {
SVG: 2,
IMG: 2,
CANVAS: 4,
OBJECT: 4,
EMBED: 4,
VIDEO: 4
};
/**
* 計算元素分值
*/
function caculateScore(node) {
var { width, height } = node.getBoundingClientRect();
var weight = TAG_WEIGHT_MAP[node.tagName] || 1;
if (
weight === 1 &&
window.getComputedStyle(node)["background-image"] && // 讀取CSS樣式中的背景圖屬性
window.getComputedStyle(node)["background-image"] !== "initial"
) {
weight = TAG_WEIGHT_MAP["IMG"]; //將有圖片背景的普通元素 權重設置為img
}
return width * height * weight;
}
~~~
  最后在得到分數最大值后,從這些元素中挑選出最長的耗時,作為 FMP。
~~~
/**
* 讀取首屏內元素的最長耗時
*/
function getElementMaxTimeConsuming(elements, observerTime) {
// 記錄靜態資源的響應結束時間
var resources = {};
// 遍歷靜態資源的時間信息
performance.getEntries().forEach((item) => {
resources[item.name] = item.responseEnd;
});
var maxObj = {
ts: observerTime,
element: ""
};
elements.forEach((node) => {
var stage = node.getAttribute(FMP_ATTRIBUTE);
ts = stage ? cacheTrees[stage].ts : 0; // 從緩存中讀取時間
switch (node.tagName) {
case "IMG":
ts = resources[node.src];
break;
case "VIDEO":
ts = resources[node.src];
!ts && (ts = resources[node.poster]); // 讀取封面
break;
default:
// 讀取背景圖地址
var match = window.getComputedStyle(node)["background-image"].match(/url\(\"(.*?)\"\)/);
if (!match) break;
var src;
// 判斷是否包含協議
if (match && match[1]) {
src = match[1];
}
if (src.indexOf("http") == -1) {
src = location.protocol + match[1];
}
ts = resources[src];
break;
}
if (ts > maxObj.ts) {
maxObj.ts = ts;
maxObj.element = node;
}
});
return maxObj;
}
~~~
## 二、存儲
**1)性能數據日志**
  性能數據會被存儲到 web\_performance 表中,同樣在接收時會通過隊列來異步新增。
~~~
CREATE TABLE `web_performance` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`load` int(11) NOT NULL DEFAULT '0' COMMENT '頁面加載總時間',
`ready` int(11) NOT NULL DEFAULT '0' COMMENT '用戶可操作時間',
`paint` int(11) NOT NULL DEFAULT '0' COMMENT '白屏時間',
`screen` int(11) NOT NULL DEFAULT '0' COMMENT '首屏時間',
`measure` varchar(1000) COLLATE utf8mb4_bin NOT NULL COMMENT '其它測量參數,用JSON格式保存',
`ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`day` int(11) NOT NULL COMMENT '格式化的天(冗余字段),用于排序,20210322',
`hour` tinyint(2) NOT NULL COMMENT '格式化的小時(冗余字段),用于分組,11',
`minute` tinyint(2) DEFAULT NULL COMMENT '格式化的分鐘(冗余字段),用于分組,20',
`identity` varchar(30) COLLATE utf8mb4_bin NOT NULL COMMENT '身份',
`project` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '項目關鍵字,關聯 web_performance_project 表中的key',
`ua` varchar(600) COLLATE utf8mb4_bin NOT NULL COMMENT '代理信息',
`referer` varchar(200) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '來源地址',
`referer_path` varchar(45) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '來源地址中的路徑',
`timing` text COLLATE utf8mb4_bin COMMENT '瀏覽器讀取到的性能參數,用于排查',
`resource` text COLLATE utf8mb4_bin COMMENT '靜態資源信息',
PRIMARY KEY (`id`),
KEY `idx_project_day` (`project`,`day`),
KEY `idx_project_day_hour` (`project`,`day`,`hour`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能監控'
~~~
  表中的 project 字段會關聯 web\_performance\_project 表中的key。
  2023-01-09 增加 referer_path 字段,用于分析指定頁面的性能。
**2)性能項目**
  性能項目就是要監控的頁面,與之前不同,性能的監控粒度會更細,因此需要有個后臺專門管理這類數據。
~~~
CREATE TABLE `web_performance_project` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`key` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '唯一值',
`name` varchar(45) COLLATE utf8mb4_bin NOT NULL COMMENT '項目名稱',
`ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1:正常 0:刪除',
PRIMARY KEY (`id`),
UNIQUE KEY `name_UNIQUE` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能監控項目';
~~~
  目前做的也比較簡單,通過名稱得到 16位MD5 字符串,引入 Node.js 的 cryto 庫。
~~~
const crypto = require('crypto');
const key = crypto.createHash('md5').update(name).digest('hex').substring(0, 16);
~~~
  2022-11-25 新增查詢、分頁和編輯,允許更改項目名稱,但是 key 要保持不變。
  可以對長期維護的網頁創建單獨的性能項目,對于那些臨時活動可以共用一個項目。
:-: 
## 三、分析
**1)性能看板**
  在性能看板中,會有四張折線圖,當要統計一天的數據時,橫坐標為小時(0~23),縱坐標為在這個小時內正序后處于 95% 位置的日志,也就是 95% 的用戶打開頁面的時間。
  這種寫法也叫 TP95,TP 是 Top Percentile 的縮寫,不用性能平均數是因為那么做不科學。
:-: 
  過濾條件還可以選擇具體的小時,此時橫坐標為分鐘,縱坐標為在這個分鐘內正序后處于 95% 位置的日志。
  點擊圖表的 label 部分,可以在后面列表中顯示日志細節,其中原始參數就是從瀏覽器中得到的計算前的性能數據。
:-: 
  后面又增加了對比功能,就是將幾天的數據放在一起對比,可更加直觀的展示趨勢。
:-: 
**2)定時任務**
  在每天的凌晨 3點30 分,統計昨天的日志信息。
  本來是計劃 web\_performance\_statis 表中每天只有一條記錄,所有性能項目的統計信息都塞到 statis 字段中,并且會包含各個對應的日志。
  但奈何數據量實在太大,超出了 MySQL 中 TEXT 類型的范圍,沒辦法塞進去,后面就只存儲 id 并且一個項目每天各一條記錄。
  數據結構如下,其中 loadZero 是指未執行load事件的數量。
~~~
{
hour: {
x: [11, 14],
load: ["158", "162"],
ready: ["157", "162"],
paint: ["158", "162"],
screen: ["157", "162"],
loadZero: 1
},
minute: {
11: {
x: [11, 18, 30],
load: ["157", "159", "160"],
ready: ["156", "159", "160"],
paint: ["157", "159", "160"],
screen: ["156", "159", "160"],
loadZero: 1
},
14: {
x: [9, 16, 17, 18],
load: ["161", "163", "164", "165"],
ready: ["161", "163", "164", "165"],
paint: ["161", "163", "164", "165"],
screen: ["161", "163", "164", "165"],
loadZero: 0
}
}
}
~~~
  還有個定時任務會在每天的凌晨 4點30 分執行,將四周前的 web\_performance\_statis 和 web\_performance 兩張表中的數據清除。
**3)資源瀑布圖**
  2022-07-08 新增了資源瀑布圖的功能,就是查看當時的資源加載情況。
  在上報性能參數時,將靜態資源的耗時,也一起打包。[getEntriesByType()](https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType)?方法可返回給定類型的 PerformanceEntry 列表。
~~~
const resources = performance.getEntriesByType('resource');
const newResources = [];
resources && resources.forEach(value => {
const { name, duration, startTime, initiatorType} = value;
// 過濾 fetch 請求
if(initiatorType === 'fetch') return;
// 只存儲 1 分鐘內的資源
if(startTime > 60000) return;
newResources.push({
name,
duration: Math.round(duration),
startTime: Math.round(startTime),
})
});
obj.resource = newResources;
~~~
  代碼中會過濾掉 fetch 請求,因為我本地業務請求使用的是[XMLHTTPRequest](https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest),只在上報監控數據時采用了 fetch() 函數。
  并且我只會搜集 1 分鐘內的資源,1 分鐘以外的資源我都會舍棄,只記錄名稱、耗時和開始時間。
  最終的效果如下圖所示,包含一個橫向的柱狀圖和查詢區域,目前只開放了根據 ID 查詢。
:-: 
  2022-08-17 在資源瀑布圖中標注白屏和首屏的時間點,可對資源的加載做更直觀的比較,便于定位性能問題。
  2022-12-28 在資源瀑布圖中,增加 load 和 DOMContentLoaded 兩個事件觸發的時間點。
:-: 
**4)堆疊柱狀圖**
  先將所有的性能記錄統計出來,然后分別統計白屏和首屏 1 秒內的數量、1-2 秒內、2-3 秒內、3-4 秒內、4+秒的數量,白屏的 SQL 如下所示。
~~~
SELECT COUNT(*) FROM `web_performance` WHERE `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
SELECT COUNT(*) FROM `web_performance` WHERE `paint` <= 1000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 1000 and `paint` <= 2000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 2000 and `paint` <= 3000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 3000 and `paint` <= 4000 and `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
SELECT COUNT(*) FROM `web_performance` WHERE `paint` > 4000 `ctime` >= '2022-06-12 00:00' and `ctime` < '2022-06-13 00:00';
~~~
  算出后,分母為總數,分子為上述五個值,組成一張堆疊柱狀圖,類似于下面這樣,每種顏色代碼一個占比。
:-: 
  這樣就能直觀的看到優化后的性能變化了,更快的反饋優化結果。
  2024-03-19 將白屏和首屏的堆疊柱狀圖修改成堆疊面積圖,為了能更好的查看變化趨勢。
:-: 
**5)階段時序圖**
  在將統計的參數全部計算出來后,為了能更直觀的發現性能瓶頸,設計了一張階段時序圖。
  描繪出 TTFB、responseDocumentTime、initDomTreeTime、parseDomTime 和 loadEventTime 所占用的時間,如下所示。
  橙色豎線表示白屏時間,黑色豎線表示首屏時間。移動到 id 或來源地址,就會提示各類參數。
:-: 
  2023-01-09 增加身份和來源路徑兩個條件,當查到某條錯誤日志后,可以通過身份將兩類日志關聯,查看當時的性能數據。
  來源路徑便于查看某一張頁面的性能日志,便于做針對性的優化。
:-: 
  TTFB 的計算包括 redirectTime、appcacheTime、lookupDomainTime、connectTime 以及 requestDocumentTime 的和。
  并且因為 requestStart 和 connectEnd 之間的時間(即 TCP 連接建立后到發送請求這段時間)沒有算,所以會比這個和大。
  responseDocumentTime 就是接收響應內容的時間。initDomTreeTime 是構建 DOM 樹并執行網頁阻塞的腳本的時間,在這個階段,具有 defer 屬性的腳本還沒有執行。
  parseDomTime 是解析 DOM 樹結構的時間,DOM 中的所有腳本,包括具有 async 屬性的腳本也會執行,還會加載頁面中的靜態資源,例如圖像、iframe 等。
  parseDomTime 是 domComplete 和 domInteractive 相減得到的差。loadEventStart 會緊跟在 domComplete 之后,而在大多數情況下,這 2 個指標是相等的。
  loadEventTime 就是執行 onload 事件的時間,一般都比較短。
  觀察下來,如果是 TTFB 比較長,那么就是 NDS 查詢、TCP 連接后的請求等問題。
  initDomTreeTime 過長的話,就需要給腳本瘦身了;parseDomTime過長的話,就需要減少資源的請求。
**參考:**
[前端性能監控及推薦幾個開源的監控系統](https://cloud.tencent.com/developer/news/682347)
[如何進行 web 性能監控?](http://www.alloyteam.com/2020/01/14184/)
[螞蟻金服如何把前端性能監控做到極致?](https://www.infoq.cn/article/dxa8am44oz*lukk5ufhy)
[5 分鐘擼一個前端性能監控工具](https://juejin.cn/post/6844903662020460552)
[10分鐘徹底搞懂前端頁面性能監控](https://zhuanlan.zhihu.com/p/82981365)
[Navigation\_and\_resource\_timings](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings)
[PerformanceNavigationTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceNavigationTiming)
[Time to Interactive: Focusing on the Human-Centric Metrics](https://calibreapp.com/blog/time-to-interactive)
[Time to Interactive Explainer](https://github.com/WICG/time-to-interactive)
[前端監控 SDK 的技術要點原理分析](https://www.freecodecamp.org/chinese/news/tech-analysis-of-front-end-monitoring-sdk)
*****
> 原文出處:
[博客園-從零開始搞系列](https://www.cnblogs.com/strick/category/1928903.html)
已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎閱讀。

推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、擴展運算符和剩余參數
- 3、解構
- 4、模板字面量
- 5、對象字面量的擴展
- 6、Symbol
- 7、代碼模塊化
- 8、數字
- 9、字符串
- 10、正則表達式
- 11、對象
- 12、數組
- 13、類型化數組
- 14、函數
- 15、箭頭函數和尾調用優化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、類
- 21、類的繼承
- 22、Promise
- 23、Promise的靜態方法和應用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基礎實踐
- 3、WebRTC視頻通話
- 4、Web音視頻基礎
- CSS進階
- 1、CSS基礎拾遺
- 2、偽類和偽元素
- 3、CSS屬性拾遺
- 4、浮動形狀
- 5、漸變
- 6、濾鏡
- 7、合成
- 8、裁剪和遮罩
- 9、網格布局
- 10、CSS方法論
- 11、管理后臺響應式改造
- React
- 1、函數式編程
- 2、JSX
- 3、組件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表單
- 8、樣式
- 9、組件通信
- 10、高階組件
- 11、Redux基礎
- 12、Redux中間件
- 13、React Router
- 14、測試框架
- 15、React Hooks
- 16、React源碼分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基礎
- 4、webpack進階
- 5、Git
- 6、Fiddler
- 7、自制腳手架
- 8、VSCode插件研發
- 9、WebView中的頁面調試方法
- Vue.js
- 1、數據綁定
- 2、指令
- 3、樣式和表單
- 4、組件
- 5、組件通信
- 6、內容分發
- 7、渲染函數和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、數據類型
- 2、接口
- 3、類
- 4、泛型
- 5、類型兼容性
- 6、高級類型
- 7、命名空間
- 8、裝飾器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系統和網絡
- 3、命令行工具
- 4、自建前端監控系統
- 5、定時任務的調試
- 6、自制短鏈系統
- 7、定時任務的進化史
- 8、通用接口
- 9、微前端實踐
- 10、接口日志查詢
- 11、E2E測試
- 12、BFF
- 13、MySQL歸檔
- 14、壓力測試
- 15、活動規則引擎
- 16、活動配置化
- 17、UmiJS版本升級
- 18、半吊子的可視化搭建系統
- 19、KOA源碼分析(上)
- 20、KOA源碼分析(下)
- 21、花10分鐘入門Node.js
- 22、Node環境升級日志
- 23、Worker threads
- 24、低代碼
- 25、Web自動化測試
- 26、接口攔截和頁面回放實驗
- 27、接口管理
- 28、Cypress自動化測試實踐
- 29、基于Electron的開播助手
- Node.js精進
- 1、模塊化
- 2、異步編程
- 3、流
- 4、事件觸發器
- 5、HTTP
- 6、文件
- 7、日志
- 8、錯誤處理
- 9、性能監控(上)
- 10、性能監控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 監控系統
- 1、SDK
- 2、存儲和分析
- 3、性能監控
- 4、內存泄漏
- 5、小程序
- 6、較長的白屏時間
- 7、頁面奔潰
- 8、shin-monitor源碼分析
- 前端性能精進
- 1、優化方法論之測量
- 2、優化方法論之分析
- 3、瀏覽器之圖像
- 4、瀏覽器之呈現
- 5、瀏覽器之JavaScript
- 6、網絡
- 7、構建
- 前端體驗優化
- 1、概述
- 2、基建
- 3、后端
- 4、數據
- 5、后臺
- Web優化
- 1、CSS優化
- 2、JavaScript優化
- 3、圖像和網絡
- 4、用戶體驗和工具
- 5、網站優化
- 6、優化閉環實踐
- 數據結構與算法
- 1、鏈表
- 2、棧、隊列、散列表和位運算
- 3、二叉樹
- 4、二分查找
- 5、回溯算法
- 6、貪心算法
- 7、分治算法
- 8、動態規劃
- 程序員之路
- 大學
- 2011年
- 2012年
- 2013年
- 2014年
- 項目反思
- 前端基礎學習分享
- 2015年
- 再一次項目反思
- 然并卵
- PC網站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端學習之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 2024年
- 日志
- 2020