性能優化的重要性不言而喻,Google 的[研究表明](https://support.google.com/webmasters/answer/9205520?hl=zh-Hans),當網站達到核心 Web 指標(Core Web Vitals)閾值時,用戶放棄加載網頁的可能性會降低 24%。
  如何科學地定位到網頁的性能瓶頸,就需要找到一個合理的方式來測量和監控頁面的性能,確定優化的方向。
  前端的性能監控分為 2 種:
* 第一種是合成監控(Synthetic Monitoring,SYN),模擬網頁加載或腳本運行等方式來測量網頁性能,輸出性能報告以供參考,常用的工具有 Chrome DevTools 的 Performance 面板、[Lighthouse](https://github.com/GoogleChrome/lighthouse)、[WebPageTest](https://www.webpagetest.org/)等。
* 第二種是真實用戶監控(Real User Monitoring,RUM),采集真實用戶所訪問到的頁面數據,通過[Performance](https://developer.mozilla.org/en-US/docs/Web/API/Performance)、[PerformanceObserver](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver)等 API 計算得到想要的性能參數,各種第三方的性能監控的 SDK 就屬于此類。
  本文的示例代碼摘取自[shin-monitor](https://github.com/pwstrick/shin-monitor),一款開源的前端監控腳本。
  為了便于記憶,特將此系列的所有重點內容濃縮成一張思維導圖。
:-: 
## 一、Performance
  W3C 在 2012 年制訂了第一版測量網頁性能的規范:[Navigation Timing](https://www.w3.org/TR/navigation-timing/)。下圖提供了頁面各階段可用的性能計時參數。
:-: 
  注意,若重定向是非同源,那么帶下劃線的 redirectStart、redirectEnd、unloadStart、unloadEnd 四個值將一直都是 0。
  W3C 在幾年后又制訂了第二版的規范:[Navigation Timing Level 2](https://www.w3.org/TR/navigation-timing-2/),如下圖所示。
:-: 
  注意,在瀏覽器中,讀取 unloadEventStart 的值后,會發現這個時間點并不會像圖中那樣在 fetchStart 之前,因為 unload 不會阻塞頁面加載。
  接下來,會用代碼來演示性能參數的計算,后文中的 navigationStart 參數其實就是 startTime。
**1)性能對象**
  第一版獲取性能參數的方法是調用 performance.timing,第二版的方法是調用 performance.getEntriesByType('navigation')\[0\]。
  前者得到一個[PerformanceTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming)對象,后者得到一個[PerformanceNavigationTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming)對象。
  在下面的代碼中,若當前瀏覽器不支持第二版,則回退到第一版。不過,目前主流的瀏覽器對第一版的支持也比較好。
~~~
const timing = performance.getEntriesByType('navigation')[0] || performance.timing;
~~~
  以我公司為例,投放到線上的頁面,其中只有大概 5.5% 的用戶讀取的第一版。
  2023-02-27 注意,[PerformanceNavigationTiming](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings)繼承了[PerformanceResourceTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming)。
  在 iOS 設備中,若 SDK 涉及跨域,并且其響應沒有聲明[timing-allow-origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin)首部,那么 PerformanceResourceTiming 中的大部分屬性都是 0。
  包括[responseStart](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart)、connectStart、domainLookupStart 等都為 0,若 responseStart 為 0,那就會影響 TTFB 的計算,其值也會一直為 0。
  可以將 timing-allow-origin 設為星號,或指定域名,如下所示。
~~~
Timing-Allow-Origin: *
Timing-Allow-Origin: https://www.pwstrick.com
~~~
  2023-03-14 雖然添加了 timing-allow-origin,但是統計結果中 TTFB 仍然包含大量的 0。
  經過抓包發現,是因為沒有請求服務器中的 SDK,而是直接讀取了客戶端中的緩存。
  為了讓客戶端每次都去校驗資源是否需要更新(即破緩存),就在 SDK 的響應頭中增加[Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control): no-cache。
**2)fetchStart**
  從上面的計時圖中可知,在 fetchStart 之前,瀏覽器會先處理重定向。
  重定向的本質是在服務器第一次響應時,返回 301 或 302 狀態碼,讓客戶端進行下一步請求。
  會多走一遍響應流程,若不是像鑒權、短鏈等有意義的重定向,都應該要避免。
  比較常見的有瀏覽器強制從 HTTP 頁面重定向到對應的 HTTPS 頁面,以及主域名的重定向,例如從 https://pwstrick.com 重定向至 https://www.pwstrick.com。
  由于瀏覽器安全策略的原因,不同域名之間的重定向時間,是無法精確計算的,只能統計 fetchStart 之前的總耗時。
  fetchStart 還會包含新標簽頁初始化的時間,但并不包括上一個頁面的 unload 時間。
  由此可知,startTime 其實是在卸載上個頁面之后開始統計的。 fetchStart 最主要的優化手段就是減少重定向次數。
  例如若頁面需要登錄,則做成一個彈框,不要做頁面跳轉,還例如在編寫頁面時,不要顯式地為 URL 添加協議。
**3)TCP**
  TCP 在建立連接之前,要經過三次握手,若是 HTTPS 協議,還要包括 SSL 握手,計算規則如下所示。
~~~
/**
* SSL連接耗時
*/
const sslTime = timing.secureConnectionStart;
connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0;
/**
* TCP連接耗時
*/
connectTime = timing.connectEnd - timing.connectStart;
~~~
  在建立連接后,TCP 就可復用,所以有時候計算得到的值是 0。
  若要減少 TCP 的耗時,可通過減少物理距離、使用 HTTP/3 協議等方法實現。
  還有一種方法是通過 preconnect 提前建立連接,如下所示,瀏覽器會搶先啟動與該來源的連接。
~~~html
<link rel="preconnect" href="https://pwstrick.com" />
~~~
**4)TTFB**
  TTFB(Time To First Byte)是指讀取頁面第一個字節的時間,即從發起請求到服務器響應后收到的第一個字節的時間差,用于衡量服務器處理能力和網絡的延遲。
  TTFB 包括重定向、DNS 解析、TCP 連接、網絡傳輸、服務器響應等時間消耗的總和,計算規則就是 responseStart 減去 redirectStart。
~~~
TTFB = timing.responseStart - timing.redirectStart;
~~~
  其實,TTFB 計算的是整個通信的往返時間(Round-Trip Time,RTT),以及服務器的處理時間。
  所以,設備之間的距離、網絡傳輸路徑、數據庫慢查詢等因素都會影響 TTFB。
  一般來說,TTFB 保持在 75ms 以內會比較完美,而在 200ms 以內會比較理想,若超過 500ms,用戶就會感覺到明顯地白屏。
  TTFB 常用的優化手段包括增加 CDN 動態加速、減少請求的數據量、服務器硬件升級、優化后端代碼(引入緩存、慢查詢優化等服務端的工作)。
**5)FP 和 FCP**
  白屏(First Paint,FP)也叫首次繪制,是指屏幕從空白到顯示第一個畫面的時間,即渲染樹轉換成屏幕像素的時刻。
  這是用戶可感知到的一個性能參數,1 秒內是比較理想的白屏時間。
  白屏時間的計算規則有 3 種:
* 第一種是讀取[PerformancePaintTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformancePaintTiming)對象,再減去 fetchStart。
* 第二種是通過 responseEnd 和 fetchStart 相減。
* 第三種是當 startTime 和 responseEnd 都是 0 時,改用進入頁面的時間和 fetchStart 相減(2023-07-06)。
~~~
const paint = performance.getEntriesByType("paint");
// entryType 是為了區分新舊兩個版本的性能對象,只有新版本才有此屬性
if (paint && timing.entryType && paint[0]) {
api.firstPaint = paint[0].startTime - timing.fetchStart;
api.firstPaintStart = paint[0].startTime; // 記錄白屏時間點
}
// 如果白屏時間是 0 或不存在,則還需要計算
if (!api.firstPaint || api.firstPaint === 0) {
// 臨時變量,選擇白屏的結束時間,若 responseEnd 是 0,則用進入頁面的時間
const fpEnd = timing.responseEnd === 0 ? this.beginStayTime : timing.responseEnd;
api.firstPaint = fpEnd - timing.fetchStart;
}
~~~
  在實踐中發現,每天有大概 2 千條記錄中的白屏時間為 0,而且清一色的都是蘋果手機。
  一番搜索后,了解到,當 iOS 設備通過瀏覽器的前進或后退按鈕進入頁面時,fetchStart、responseEnd 等性能參數很可能為 0。
  還發現當初始頁面的結構中,若包含漸變的效果時,1 秒內的白屏占比會從最高 94% 降低到 85%。
  2023-07-11 發現數據庫中有 40 多條記錄,白屏時間是 0,查看 timing 發現使用的是第一版的規范,并且 responseEnd 和 fetchStart 數值相同,相減得到了 0。
~~~
{
"fetchStart": 1688976670518,
"responseEnd": 1688976670518,
}
~~~
  注意,PerformancePaintTiming 包含兩個性能數據,FP 和[FCP](https://web.dev/fcp/),理想情況下,兩者的值可相同。
:-: 
  FCP(First Contentful Paint)是指首次有實際內容渲染的時間,測量頁面從開始加載到頁面內容的任何部分在屏幕上完成渲染的時間。
  內容是指文本、圖像(包括背景圖像)、svg 元素或非白色的 canvas 元素,不包括 iframe 中的內容。
  網站性能測試工具[GTmetrix](https://gtmetrix.com/)認為[FCP](https://gtmetrix.com/first-contentful-paint.html)比較理想的時間是控制在 943ms 以內,字節的標準是控制在 1.8s 內。
~~~
if (paint && timing.entryType && paint[1]) {
firstContentfulPaint = paint[1].startTime - timing.fetchStart;
} else {
firstContentfulPaint = 0;
}
~~~
  影響上述兩個指標的主要因素包括網絡傳輸和頁面渲染,優化的核心就是降低網絡延遲以及加速渲染。
  優化手段包括剔除阻塞渲染的 JavaScript 和 CSS、優化圖像、壓縮合并文件、延遲加載非關鍵資源、使用 HTTP/2 協議、SSR 等。
  2023-07-03 對于白屏時間超過 4 秒的頁面,會對其進行錄像存儲。
  但是在這個場景中,有時候錄像腳本都還沒加載完成,性能參數就已上報,所以經常會收到空的錄像。
  理想狀態下,這個腳本不需要網絡傳輸,內嵌在客戶端中,這樣就能在頁面加載開始階段,就進行錄制。
  上線后發現,有一個項目中的頁面都沒有上傳錄像,經過排查發現這些頁面上傳的錄像都要 280KB 以上。
  而傳輸性能參數采用的是[navigator.sendBeacon()](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon),此方法只能傳輸 64KB 左右的數據,遠超其容量,導致請求最終被阻塞,沒有傳輸到服務器中。
  從另外幾張上傳錄制成功的頁面發現,雖然白屏時間較長,但是最終還是能呈現,不過有些樣式有點錯位,有些內容渲染不完整。
  為了能更方便的對錄像進行控制,在[shin-monitor](https://github.com/pwstrick/shin-monitor)的參數中增加一個開關。
  若需要發送錄像信息,則使用 fetch() 方法,普通的性能信息傳輸仍然使用 sendBeacon(),畢竟數據量增大會影響上傳成功率。
**6)DOM**
  在性能計時圖中,有 4 個與 DOM 相關的參數,包括[domInteractive](https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-interactive)、[domComplete](https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-complete)、[domContentLoadedEventStart](https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-content-loaded-dcl)和 domContentLoadedEventEnd。
  domInteractive 記錄的是在加載 DOM 并執行網頁的阻塞腳本的時間。
  在這個階段,具有 defer 屬性的腳本還沒有執行,某些樣式表加載可能仍在處理并阻止頁面呈現。
  domComplete 記錄的是完成解析 DOM 樹結構的時間。
  在這個階段,DOM 中的所有腳本,包括具有 async 屬性的腳本,都已執行。并且開始加載 DOM 中定義的所有頁面靜態資源,包括圖像、iframe 等。
  loadEventStart 會緊跟在 domComplete 之后。在大多數情況下,這 2 個指標是相等的。在 loadEventStart 之前可能的延遲將由 onReadyStateChange 引起。
  由 domInteractive 和 domComplete 兩個參數可計算出兩個 DOM 階段的耗時,如下所示。
~~~
initDomTreeTime = timing.domInteractive - timing.responseEnd; // 請求完畢至 DOM 加載的耗時
parseDomTime = timing.domComplete - timing.domInteractive; // 解析 DOM 樹結構的耗時
~~~
  若 initDomTreeTime 過長的話,就需要給腳本瘦身了。若 parseDomTime過長的話,就需要減少資源的請求了。
  DOMContentLoaded(DCL)緊跟在 domInteractive 之后,該事件包含開始和結束兩個參數,jQuery.ready() 就是封裝了此事件。
  該事件會在 HTML 加載完畢,并且 HTML 所引用的內聯和外鏈的非 async/defer 的同步 JavaScript 腳本和 CSS 樣式都執行完畢后觸發,無需等待圖像和 iframe 完成加載。
  由 domContentLoadedEventEnd 可計算出用戶可操作時間,即 DOM Ready 時間。
~~~
domReadyTime = timing.domContentLoadedEventEnd - navigationStart; // 用戶可操作時間(DOM Ready時間)
~~~
  注意,若 domContentLoadedEventEnd 高于 domContentLoadedEventStart,則說明該頁面中也注冊了此事件。
  與 DCL 相比,load 事件觸發的時機要晚的多。
  它會在頁面的 HTML、CSS、JavaScript(包括 async/defer)、圖像等靜態資源都已經加載完之后才觸發。
  2023-06-21 增加對 DOM 節點的計算結果,通過逐層遍歷得到頁面中的總節點數、最大節點深度以及最大子節點數。
~~~
/**
* 計算 DOM 相關的數據
*/
private countAllElementsOnPage(): TypeDOMCount {
let nodes: (HTMLElement | Element)[] = [document.documentElement];
// 總節點數
let totalElementCount = 0;
// 最大節點深度
let maxDOMTreeDepth = 0;
// 最大子節點數
let maxChildrenCount = 0;
// 逐層遍歷
while (nodes.length) {
maxDOMTreeDepth++;
const children: Element[] = [];
for (let node of nodes) {
totalElementCount++;
children.push(...Array.from(node.children));
maxChildrenCount = Math.max(maxChildrenCount, node.children.length);
}
// nodes 是一個由 HTMLElement 組成的數組
nodes = children;
}
return {
maxDOMTreeDepth,
maxChildrenCount,
totalElementCount,
};
}
~~~
  本來以為相同頁面的數據,每次都會相同,其實不然,會有些差異,應該就是提交的時候,頁面搭建的程度不同。
**7)停留時長**
  2023-07-05 增加停留時長,當頁面性能不佳時,觀察用戶在頁面中耗費的時間。
  停留時長的計算規則比較復雜,需要考慮很多場景,不過我們當前公司場景比較單一。
  所以不考慮多頁面跳轉和多標簽的場景,因為我們主要監控的是移動端的活動頁面,不需要頁面跳轉,也不會多標簽。
  計算結果不能說非常準確,但也有參考價值,為方便起見,在觸發 beforeunload 或 pagehide 事件時,上報數據。
~~~
// 計算行為數據
const caculateBehavior = (): TypeBehavior => {
const behavior: TypeBehavior = {};
behavior.duration = rounded(getNowTimestamp() - this.beginStayTime); // 頁面停留時長
return behavior;
};
// 發送用戶行為數據
const sendBehavior = (): void => {
const behavior = caculateBehavior();
this.http.sendBehavior(behavior);
localStorage.removeItem(CONSTANT.SHIN_BEHAVIOR_DATA); // 移除行為緩存
};
/**
* iOS 設備不支持 beforeunload 事件,需要使用 pagehide 事件
* 在頁面卸載之前,推送性能信息
*/
const isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
const eventName = isIOS ? "pagehide" : "beforeunload";
window.addEventListener(eventName, (): void => {
sendPerformance();
sendBehavior();
}, false);
~~~
  其中 beginStayTime 會在進入頁面時將其初始化,如果某些設備不支持這兩個事件,那么就無法得到停留時長。
  2023-12-05 公司 iOS 版本的客戶端的停留時長經常是空白,也就是上述 pagehide 事件沒有被觸發,那么就需要一種補救措施。
  最簡單的是加個定時器,隔一段時間向服務器發送數據,但是這樣會增加服務器的壓力,于是換了一種,先將數據緩存到本地。
~~~
/**
* 發送緩存的行為數據
* 例如停留時長需要在 pagehide 或 beforeunload 兩個事件中發送
* 但如果兩個事件都不支持,那么這個數據就是空的
*/
const sendExistBehavior = (): void => {
const exist = localStorage.getItem(CONSTANT.SHIN_BEHAVIOR_DATA);
if (!exist) { return; }
this.http.sendBeacon(exist); // 直接發送,不需要再次封裝數據
localStorage.removeItem(CONSTANT.SHIN_BEHAVIOR_DATA); // 移除行為緩存
};
~~~
  當進入頁面時,再發送請求。不過這樣的話,若沒有再次進入,那么仍然是無法上報停留時長的。
~~~
window.addEventListener('load', (): void => {
// 發送緩存的行為數據
sendExistBehavior();
// 通過定時器緩存數據
setInterval((): void => {
const behavior = caculateBehavior();
localStorage.setItem(CONSTANT.SHIN_BEHAVIOR_DATA, this.http.paramifyBehavior(behavior));
}, 1000);
});
~~~
  在首次上線期間發生了意外,數據庫的 CPU 被下面這條語句拉到了 100%,直接讓很多服務無響應。
~~~
SELECT * FROM `web_performance` WHERE `identity` = ? AND `referer` = ? AND `project` = ? ORDER BY `id` DESC LIMIT 1
~~~
  用 EXPLAIN 查看了這條語句的性能,受影響行數是 2,但是居然造成了非常惡劣的影響。
  優化手段是在查詢條件中增加時間縮小范圍以及增加聯合索引。
~~~
ALTER TABLE `web_performance` ADD INDEX `idx_identity_referer_project` (`identity`, `referer`, `project`)
~~~
  在執行索引語句時,會報錯,因為建表時用了一個 4 字節的 utf8mb4 字符集。
~~~
Index column size too large. The maximum column size is 767 bytes.
~~~
  當索引最大限制是 767 bytes 時,那么一個 varchar 字段最大長度是 767/4=191.75,而 referer 字段的長度是 200,故而報錯。
  將 referer 字段替換成長度更短的 referer\_path,優化后 CPU 非常穩定。
~~~
ALTER TABLE `web_performance` ADD INDEX `idx_identity_referer_project` (`identity`, `referer_path`, `project`)
~~~
## 二、Core Web Vitals
  Google 在眾多的性能指標中選出了幾個[核心 Web 指標](https://www.debugbear.com/docs/metrics/core-web-vitals)(Core Web Vitals),讓網站開發人員可以專注于這幾個指標的優化。
  2024-03-20 下圖有助于了解核心指標與其他指標之間的關系。
:-: 
  下表是關鍵指標的基準線,來源于[字節](https://mp.weixin.qq.com/s/TRY2mEMl4rZz3442SzC1EA)和[Google](https://support.google.com/webmasters/answer/9205520?hl=zh-Hans)的標準,除了 CLS,其他數據的單位都是毫秒。
| Metric Name | Good | Needs Improvement | Poor |
| --- | --- | --- | --- |
| FP | 0-1000 | 1000-2500 | > 2500 |
| FCP | 0-1800 | 1800-3000 | > 3000 |
| LCP | 0-2500 | 2500-4000 | > 4000 |
| FID | 0-100 | 100-300 | > 300 |
| TTI | 0-3800 | 3800-7300 | > 7300 |
| CLS | <= 0.1 | <= 0.25 | > 0.25 |
**1)LCP**
  LCP(Largest Contentful Paint)是指最大的內容在可視區域內變得可見的時間點,理想的時間是 2.5s 以內。
:-: 
  一般情況下,[LCP](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)的時間都會比 FCP 大(如上圖所示),除非頁面非常簡單,FCP 的重要性也比 LCP 低很多。
  LCP 的讀取并不需要手動計算,瀏覽器已經提供了[PerformanceObserver.observe()](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe)方法,如下所示。
~~~
/**
* 判斷當前宿主環境是否支持 PerformanceObserver
* 并且支持某個特定的類型
*/
private checkSupportPerformanceObserver(type: string): boolean {
if(!(window as any).PerformanceObserver) return false;
const types = (PerformanceObserver as any).supportedEntryTypes;
// 瀏覽器兼容判斷,不存在或沒有關鍵字
if(!types || types.indexOf(type) === -1) {
return false;
}
return true;
}
/**
* 瀏覽器 LCP 計算
*/
public observerLCP(): void {
const lcpType = 'largest-contentful-paint';
const isSupport = this.checkSupportPerformanceObserver(lcpType);
// 瀏覽器兼容判斷
if(!isSupport) {
return;
}
const po = new PerformanceObserver((entryList): void=> {
const entries = entryList.getEntries();
const lastEntry = (entries as any)[entries.length - 1] as TypePerformanceEntry;
this.lcp = {
time: rounded(lastEntry.renderTime || lastEntry.loadTime), // 時間取整
url: lastEntry.url, // 資源地址
element: lastEntry.element ? removeQuote(lastEntry.element.outerHTML) : '' // 參照的元素
};
});
// buffered 為 true 表示調用 observe() 之前的也算進來
po.observe({ type: lcpType, buffered: true } as any);
/**
* 當有按鍵或點擊(包括滾動)時,就停止 LCP 的采樣
* once 參數是指事件被調用一次后就會被移除
*/
['keydown', 'click'].forEach((type): void => {
window.addEventListener(type, (): void => {
// 斷開此觀察者的連接
po.disconnect();
}, { once: true, capture: true });
});
}
~~~
  entries 是一組[LargestContentfulPaint](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint)類型的對象,它有一個 url 屬性,如果記錄的元素是個圖像,那么會存儲其地址。
  注冊 keydown 和 click 事件是為了停止 LCP 的采樣,once 參數會在事件被調用一次后將事件移除。
  在 iOS 的 WebView 中,只支持三種類型的 entryType,不包括 largest-contentful-paint,所以加了段瀏覽器兼容判斷。
  在頁面轉移到后臺后,得停止 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/en-US/docs/Web/API/Document/visibilitychange_event)事件,就能準確得到隱藏時間,然后在讀取 LCP 時,大于這個時間的就直接忽略掉。不過在實踐中發現,iOS 的 WebView 并不支持此事件。
  注意,largest-contentful-paint 不會計算 iframe 中的元素,返回上一頁也不會重新計算。
  有個成熟的庫:[web-vitals](https://github.com/GoogleChrome/web-vitals),提供了 LCP、FID、CLS、FCP 和 TTFB 指標,對上述所說的特殊場景做了處理,若要了解原理,可以參考其中的計算過程。
  LCP 會被一直監控(其監控的元素如下所列),這樣會影響結果的準確性。
  例如有個頁面首次進入是個彈框,確定后會出現動畫,增加些圖像,DOM結構也都會跟著改變。
* img 元素
* 內嵌在 svg 中的 image 元素
* video 元素(使用到封面圖像)
* 擁有背景圖像的元素(調用 CSS 的 url() 函數)
* 包含文本節點或或行內文本節點的塊級元素
  如果在等待一段時間,關閉頁面時才上報,那么 LCP 將會很長,所以需要選擇合適的上報時機,例如 load 事件中。
**2)FMP**
  FMP(First Meaningful Paint)是首次繪制有意義內容的時間,這是一個比較復雜的指標。
  因為算法的通用性不夠高,探測結果也不理想,所以 Google 已經廢棄了 FMP,轉而采用含義更清晰的 LCP。
  雖然如此,但網上仍然有很多開源的解決方案,畢竟 Google 是要找出一套通用方案,但我們并不需要通用。
  只要結合那些方案,再寫出最適合自己環境的算法就行了,目前初步總結了一套計算 FMP 的步驟(僅供參考)。
  首先,通過[MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver)監聽每一次頁面整體的 DOM 變化,觸發 MutationObserver 的回調。
  然后在回調中,為每個 HTML 元素(不包括忽略的元素)打上標記,記錄元素是在哪一次回調中增加的,并且用數組記錄每一次的回調時間。
~~~
const IGNORE_TAG_SET = ['SCRIPT', 'STYLE', 'META', 'HEAD', 'LINK'];
const WW = window.innerWidth;
const WH = window.innerHeight;
const FMP_ATTRIBUTE = '_ts';
class FMP {
private cacheTrees: TypeTree[];
private callbackCount: number;
private observer: MutationObserver;
public constructor() {
this.cacheTrees = []; // 緩存每次更新的DOM元素
this.callbackCount = 0; // DOM 變化的計數
// 開始監控DOM的變化
this.observer = new MutationObserver((): void => {
const mutationsList = [];
// 從 body 元素開始遍歷
document.body && this.doTag(document.body, this.callbackCount++, mutationsList);
this.cacheTrees.push({
ts: performance.now(),
children: mutationsList
});
// console.log("mutationsList", performance.now(), mutationsList);
});
this.observer.observe(document, {
childList: true, // 監控子元素
subtree: true // 監控后代元素
});
}
/**
* 為 HTML 元素打標記,記錄是哪一次的 DOM 更新
*/
private doTag(target: Element, callbackCount: number, mutationsList: Element[]): void {
const childrenLen = target.children ? target.children.length : 0;
// 結束遞歸
if(childrenLen === 0)
return;
for (let children = target.children, i = childrenLen - 1; i >= 0; i--) {
const child = children[i];
const tagName = child.tagName;
if (child.getAttribute(FMP_ATTRIBUTE) === null &&
IGNORE_TAG_SET.indexOf(tagName) === -1 // 過濾掉忽略的元素
) {
child.setAttribute(FMP_ATTRIBUTE, callbackCount.toString());
mutationsList.push(child); // 記錄更新的元素
}
// 繼續遞歸
this.doTag(child, callbackCount, mutationsList);
}
}
}
~~~
  接著在觸發 load 事件時,先過濾掉首屏外和沒有高度的元素,以及元素列表之間有包括關系的祖先元素,再計算各次變化時剩余元素的總分。
  一開始是只記錄沒有后代的元素,但是后面發現有時候 DOM 變化時,沒有這類元素。
~~~
/**
* 是否超出屏幕外
*/
private isOutScreen(node: Element): boolean {
const { left, top } = node.getBoundingClientRect();
return WH < top || WW < left;
}
/**
* 讀取 FMP 信息
*/
public getFMP(): TypeMaxElement {
this.observer.disconnect(); // 停止監聽
const maxObj = {
score: -1, //最高分
elements: [], // 首屏元素
ts: 0 // DOM變化時的時間戳
};
// 遍歷DOM數組,并計算它們的得分
this.cacheTrees.forEach((tree): void => {
let score = 0;
// 首屏內的元素
let firstScreenElements = [];
tree.children.forEach((node): void => {
// 只記錄元素
if(node.nodeType !== 1 || IGNORE_TAG_SET.indexOf(node.tagName) >= 0) {
return;
}
const { height } = node.getBoundingClientRect();
// 過濾高度為 0,在首屏外的元素
if(height > 0 && !this.isOutScreen(node)) {
firstScreenElements.push(node);
}
});
// 若首屏中的一個元素是另一個元素的后代,則過濾掉該祖先元素
firstScreenElements = firstScreenElements.filter((node): boolean => {
// 只要找到一次包含關系,就過濾掉
const notFind = !firstScreenElements.some((item ): boolean=> node !== item && node.contains(item));
// 計算總得分
if(notFind) {
score += this.caculateScore(node);
}
return notFind;
});
// 得到最高值
if(maxObj.score < score) {
maxObj.score = score;
maxObj.elements = firstScreenElements;
maxObj.ts = tree.ts;
}
});
// 在得分最高的首屏元素中,找出最長的耗時
return this.getElementMaxTimeConsuming(maxObj.elements, maxObj.ts);
}
~~~
  不同類型的元素,權重也是不同的,權重越高,對頁面呈現的影響也越大。
  在 caculateScore() 函數中,通過getComputedStyle得到 CSS 類中的背景圖屬性,注意,node.style 只能得到內聯樣式中的屬性。
~~~
const TAG_WEIGHT_MAP = {
SVG: 2,
IMG: 2,
CANVAS: 4,
OBJECT: 4,
EMBED: 4,
VIDEO: 4
};
/**
* 計算元素分值
*/
private caculateScore(node: Element): number {
const { width, height } = node.getBoundingClientRect();
let 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。
~~~
/**
* 讀取首屏內元素的最長耗時
*/
private getElementMaxTimeConsuming(elements: Element[], observerTime: number): TypeMaxElement {
// 記錄靜態資源的響應結束時間
const resources = {};
// 遍歷靜態資源的時間信息
performance.getEntries().forEach((item: PerformanceResourceTiming): void => {
resources[item.name] = item.responseEnd;
});
const maxObj: TypeMaxElement = {
ts: observerTime,
element: ''
};
elements.forEach((node: Element): void => {
const stage = node.getAttribute(FMP_ATTRIBUTE);
let ts = stage ? this.cacheTrees[stage].ts : 0; // 從緩存中讀取時間
switch(node.tagName) {
case 'IMG':
ts = resources[(node as HTMLImageElement).src];
break;
case 'VIDEO':
ts = resources[(node as HTMLVideoElement).src];
!ts && (ts = resources[(node as HTMLVideoElement).poster]); // 讀取封面
break;
default: {
// 讀取背景圖地址
const match = window.getComputedStyle(node)['background-image'].match(/url\(\"(.*?)\"\)/);
if(!match) break;
let src: string;
// 判斷是否包含協議
if (match && match[1]) {
src = match[1];
}
if (src.indexOf('http') == -1) {
src = location.protocol + match[1];
}
ts = resources[src];
break;
}
}
// console.log(node, ts)
if(ts > maxObj.ts) {
maxObj.ts = ts;
maxObj.element = node;
}
});
return maxObj;
}
~~~
  在將 LCP 和 FMP 兩個指標都算出后,就會取這兩者的較大值,作為首屏的時間。
  在還未完成 FMP 算法之前,首屏采用的是兩種有明顯缺陷的計算方式。
* 第一種是算出首屏頁面中所有圖像都加載完后的時間,這種方法難以覆蓋所有場景,例如 CSS 中的背景圖、Image 元素等。
* 第二種是自定義首屏時間,也就是自己來控制何時算首屏全部加載好了,雖然相對精確點,但要修改源碼。
**3)FID**
  FID(First Input Delay)是用戶第一次與頁面交互(例如點擊鏈接、按鈕等操作)到瀏覽器對交互作出響應的時間,比較理想的時間是控制在 100ms 以內。
  FID 只關注不連續的操作,例如點擊、觸摸和按鍵,不包含滾動和縮放之類的連續操作。
  這個[指標](https://web.dev/fid/)是用戶對網站響應的第一印象,若延遲時間越長,那就會降低用戶對網站的整體印象。
  減少站點初始化時間(即加速渲染)和消除冗長的任務(避免阻塞主線程)有助于消除首次輸入延遲。
  在下圖的 Chrome DevTools Performance 面板中,描繪了一個繁忙的主線程。
  如果用戶在較長的幀(600.9 毫秒和 994.5 毫秒)期間嘗試交互,那么頁面的響應需要等待比較長的時間。
:-: 
  FID 的計算方式和 LCP 類似,也是借助 PerformanceObserver 實現,如下所示。
~~~
public observerFID(): void {
const fidType = 'first-input';
const isSupport = this.checkSupportPerformanceObserver(fidType);
// 瀏覽器兼容判斷
if(!isSupport) {
return;
}
const po = new PerformanceObserver((entryList, obs): void => {
const entries = entryList.getEntries();
const firstInput = (entries as any)[0] as TypePerformanceEntry;
// 測量第一個輸入事件的延遲
this.fid = rounded(firstInput.processingStart - firstInput.startTime);
// 斷開此觀察者的連接,因為回調僅觸發一次
obs.disconnect();
});
po.observe({ type: fidType, buffered: true } as any);
// po.observe({ entryTypes: [fidType] });
}
~~~
  INP(Interaction to Next Paint)是 Google 的一項新指標,用于衡量頁面對用戶輸入的響應速度。
  它測量用戶交互(如單擊或按鍵)與屏幕的下一次更新之間經過的時間,如下圖所示。
:-: 
  在未來,[INP](https://github.com/GoogleChrome/web-vitals/blob/main/src/onINP.ts)將會取代 FID,因為 FID 有兩個限制:
* 它只考慮用戶在頁面上的第一次交互。
* 它只測量瀏覽器開始響應用戶輸入所需的時間,而不是完成響應所需的時間。
**4)TTI**
  TTI(Time to Interactive)是一個與交互有關的指標,它可測量頁面從開始加載到主要子資源完成渲染,并能夠快速、可靠地響應用戶輸入所需的時間。
  它的計算規則比較繁瑣:
* 先找到 FCP 的時間點。
* 沿時間軸正向搜索時長至少為 5 秒的安靜窗口,其中安靜窗口的定義為:沒有長任務([Long Task](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming))且不超過兩個正在處理的網絡 GET 請求。
* 沿時間軸反向搜索安靜窗口之前的最后一個長任務,如果沒有找到長任務,則在 FCP 處終止。
* TTI 是安靜窗口之前最后一個長任務的結束時間,如果沒有找到長任務,則與 FCP 值相同。
  下圖有助于更直觀的了解上述步驟,其中數字與步驟對應,豎的橙色虛線就是 TTI 的時間點。
:-: 
  TBT(Total Blocking Time)是指頁面從 FCP 到 TTI 之間的阻塞時間,一般用來量化主線程在空閑之前的繁忙程度。
  它的計算方式就是取 FCP 和 TTI 之間的所有長任務消耗的時間總和。
  不過網上[有些資料](https://web.dev/tti/)認為 TTI 可能會受當前環境的影響而導致測量結果不準確,因此更適合在實驗工具中測量,例如 LightHouse、WebPageTest 等
  Google 的[TTI Polyfill](https://github.com/GoogleChromeLabs/tti-polyfill)庫的第一句話就是不建議在線上搜集 TTI,建議使用 FID。
**5)CLS**
  CLS(Cumulative Layout Shift)會測量頁面意外產生的累積布局的偏移分數,即衡量布局的穩定性。
  布局不穩定會影響用戶體驗,例如按鈕在用戶試圖點擊時四處移動,或者文本在用戶開始閱讀后四處移動,而這類移動的元素會被定義成不穩定元素。
  在下圖中,描繪了內容在頁面中四處移動的場景。
:-: 
  布局偏移分數 = 影響分數 \* 距離分數,而這個[CLS](https://web.dev/cls/)分數應盡可能低,最好低于 0.1。
* 影響分數指的是前一幀和當前幀的所有不穩定元素在可視區域的并集占比。
* 距離分數指的是任何不穩定元素在一幀中位移的最大距離(水平或垂直)除以可視區域的最大尺寸(寬高取較大者)。
  若要計算 CLS,可以參考[Layout Instability Metric](https://github.com/WICG/layout-instability)給出的思路或[onCLS.ts](https://github.com/GoogleChrome/web-vitals/blob/main/src/onCLS.ts),借助 PerformanceObserver 偵聽 layout-shift 的變化,如下所示。
~~~
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 只將不帶有最近用戶輸入標志的布局偏移計算在內。
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// 如果條目與上一條目的相隔時間小于 1 秒且
// 與會話中第一個條目的相隔時間小于 5 秒,那么將條目
// 包含在當前會話中。否則,開始一個新會話。
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
// 如果當前會話值大于當前 CLS 值,
// 那么更新 CLS 及其相關條目。
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = sessionEntries;
// 將更新值(及其條目)記錄在控制臺中。
console.log('CLS:', clsValue, clsEntries)
}
}
}
}).observe({type: 'layout-shift', buffered: true});
~~~
  優化 CLS 的手段有很多,例如一次性呈現所有內容、在某些內容仍在加載時使用占位符、圖像或視頻預設尺寸等。
## 總結
  在開篇就提出了量化性能的重要性,隨后就引出了兩個版本的性能規范,目前主流的是第二個版本。
  根據瀏覽器提供的性能參數,分析了 fetchStart、TCP、TTFB、白屏的計算細節,并且說明了幾個影響 DOM 的性能參數。
  最后詳細介紹了 Google 的核心Web指標,例如 LCP、FID、CLS 等。還介紹了一個已經廢棄,但還在廣泛使用的 FMP 指標。
*****
> 原文出處:
[博客園-前端性能精進](https://www.cnblogs.com/strick/category/2267607.html)
[知乎專欄-前端性能精進](https://www.zhihu.com/column/c_1610941255021780992)
已建立一個微信前端交流群,如要進群,請先加微信號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