從 B 站出發,用 Chrome devTools performance 分析頁面如何渲染

語言: CN / TW / HK

頁面是如何渲染的?通常會得到“解析 HTML、css 合成 Render Tree,就可以渲染了”的回答。但是具體都做了些什麼,卻很少有人細說,我們今天就從 Chrome 的效能工具開始,具體看看一個頁面是如何進行渲染的,以及進行頁面優化時需要關注哪些指標。

以“老二次元”網站 bilibili 為例,我們將通過分析 performance 面板,串聯起 Chrome 頁面渲染流程,以及頁面的部分量化指標的含義,來看頁面具體是如何渲染的。

獲取performance資料

首先,開啟Chrome devTools, 選擇 performace面板,點選錄製按鈕開始錄製。

之後為了防止我們分析頁面時出現無關的干擾,我們通過以下步驟降低干擾項:

1、開啟 Chrome 無痕模式。

2、關閉所有在 Chrome 無痕模式下啟用的拓展(如果有的話)。

3、在位址列輸入 www.bilibili.com 前,先開啟 devTools,選擇 performance 面板,點選錄製按鈕。

4、在已經錄製的情況下,位址列回車,請求 B 站,大概 10s 後,停止錄製。

我們從上到下,將圖分成以下幾塊,如下圖所示:

1、控制面板

2、概覽面板

3、網路面板

4、Web Vitals

5、執行緒面板

6、記憶體面板

7、聚合面板

控制面板

控制面板有 4 部分內容,分別為:

  • disable javascript samples:啟用後會隱藏一些 JS 呼叫棧的展示。在一些效能較弱的裝置例如移動端上,可以開啟這項功能。

  • Network:可以用來模擬各種網路狀況。

  • enableadvanced paint instrumention (slow):啟用後 paint 面板會顯示與繪製相關事件的更詳細的資訊。 CPU:可以用來模擬不同的 CPU 效能。

概覽面板

概覽面板是各項指標的一個概覽,包含了 FPS 幀數、CPU 佔用、NET 情況、記憶體使用情況等。

簡單舉個例子,比如 FPS 幀數可以直觀的看出 FPS 的高低,綠色代表低的部分。而 CPU 欄的黃色代表著 js,紫色代表計算樣式和佈局,綠色代表繪製。

網路面板

網路面板用於展示正在請求中的各部分的組成情況。

Web vitals

Web vitals 是網站的 Web 體驗指標,其中包括 LCP(最大內容繪製)、FID(首次輸入延遲)、cls (累計佈局偏移)等。

執行緒面板

執行緒面板用於展示渲染當前頁面所使用到的執行緒,包含有 Main 執行緒、GPU 執行緒、Raster 執行緒、Chrome_ChildIOThread、Compositor 執行緒等等。其中 Main 執行緒,就是我們平時說的大部分 js 的執行環境,即主執行緒。

記憶體面板

展示 js 記憶體、GPU 記憶體、節點數、監聽事件數的變化。

聚合面板

當點選主執行緒中的火焰圖時,此面板會顯示顯示具體包含執行時間、執行組成、呼叫棧等等的資訊集合。

Chrome是如何渲染頁面的?

第一個請求

以第一個請求為例,我們來具體看一下 Chrome 是如何進行頁面渲染的?依然是以對 https://www.bilibili.com 的請求為例,來看一下 1ms 的 performance 面板,即下圖中紅線部分、中間 NET 欄藍色細長條開始的部分和 Network 中水平箱線圖開始的部分。

其中兩邊橫線中間深淺色方框的部分是水平箱線圖,是用來展示某部分在整體中的比例關係。比如我們看到這個長長的箱型圖,通過直觀感受,就能知道對前面一部分橫線挺長的,藍色部分裡淺色部分很長,深色的短,右邊的橫線幾乎看不到。那這些又分別能展示什麼資訊?

首先,點開箱型圖最下方的聚合面板(Summary),上面赫然寫著:此乃頁面源。欲求小破站, 終生皆讓我……耗時一秒半。

然後在 Network tab 裡檢視該請求的 timing 部分,可以得到如下圖:

這裡的各個部分分別代表:

  • Queueing(排隊):瀏覽器會在一些情況下讓請求排隊等待,比如這個請求的優先順序不高,有更高優先順序的請求存在;在使用 HTTP/1.0 或者 HTTP/1.1 時,同域請求最大併發數量為 6 個,此時已經達到了最大值;而上圖中的請求是屬於最高優先順序的第一個請求,即瀏覽器正在硬碟快取中分配空間,從圖上可以看到有 14.72ms 用於在磁碟快取中分配空間。

Stalled(停頓):它可能會因為上述排隊中的任何原因而停頓。

  • DNS lookup(DNS 查詢):解析這個域名的IP地址。需要注意的是,當我們多次訪問同一域名時,這部分不會出現在 timing 中。

  • Initial connection(初始連線):瀏覽器建立連線,包括 tcp 三次握手、重試以及協商 SSL。圖中的紫色部分,就代表了在初始連線過程中的 SSL 協商部分。

  • Request sent(傳送請求):正在傳送請求

  • Waiting (TTFB) 等待第一位元組時間:瀏覽器在等待第一個響應的位元組,TTFB 即 Time To First Byte。這個時間包括一個往返的延遲和服務準備響應的時間之和。

  • Content Download (內容下載):瀏覽器正在接收響應,瀏覽器可以通過網路或者 serviceWorker 來直接接收。這個值是讀取響應體的總時間。由於網路不佳或者瀏覽器正在忙於執行其他工作而延遲了對響應體的讀取,讀取的時間可能會比預期的要長。

這裡相信已經有小夥伴注意到了,當瀏覽器忙於其他事情時也會讓讀取時間變長。也就是說,當你的 js 把主執行緒長期佔據的時候,就會影響 content download。

下圖是 Network 下的對應資源的 waterfall:

現在我們回到最開始說的各色橫條上,在水平箱線圖中左上角的深藍色小方塊代表著這個請求有著更高的優先順序。遇到有淺藍色的,則表示較低優先順序。同時左邊橫線對應 Network 面板中顯示的 Request Sent之前的所有事情的時間。淺色的 bar對應 Network 中 Request Sent 和 Waiting(TTFB)的時間。深色的 bar對應 Network中Content Download 的時間。右邊的橫線表示等待主執行緒所花費的時間,在 Network 面板中沒有體現。

此外,可能還有些同學注意到,在藍色箱線圖上面還可以看到還有幾個灰色的箱線圖。不是說www.bilibili.com 是頁面的第一個請求嗎,難道它之前還有請求?

事實上,這個灰色箱線圖相當於上一個頁面的結束。如果我們是通過重新錄製的方式記錄 performance,那就會經歷頁面重新整理的過程。而這幾個灰色的其實就是頁面重新整理 unload 時發起的,是 bilibili 用來記錄頁面解除安裝時的一些資料。

說回到箱線圖,可以看到在 summary 中顯示 Duration 1.08 s (822.88 ms Network transfer + 260.20 ms resource loading)。這個的意思是 260ms 的時間是在 resource loading ,這裡resource loading所花費的時間其實就是箱線圖右側的那條橫線,等待主執行緒的時間。

而在 main 程序中,有橫線結束的地方,可以看到解碼的資料 138,933 Bytes。

這裡就出現了幾個問題:為什麼Encode Data 33479 bytes 算下來是 33479/1024 = 32.69 k,而不是前面 Network 面板裡的 33.5k ? 而且 Decode body 138993/1024 = 135.7k 也不是前面的 139k?缺少的一部分資料是什麼呢?

為了驗證這個問題,需要清空過去所有請求記錄,重新點選錄製,錄製完成後,匯出網路請求的 HAR 檔案。使用 vscdoe 開啟 json 格式的 HAR 檔案,尋找 GET https://www.bilibili.com/ content-Type: text/HTML 的那個請求。經過前後的檔案對比,找到了這個請求的 response content:

可以看到,圖上的 size 有140682 位元組。text則是 base64 編碼的 HTML 內容,已經被 decode 過。需要注意的是,這裡的 decode 不是對 base64 的 decode,是對 gzip 的 decode。

而在這個 text 內容之後,還有一段如下內容:

其中的 _transferSize: 35593 是網路傳輸的體積,即傳輸的體積 35593 和 decode 體積 140682。同時我們在 performance 裡的主程序中的 finish loading中可以看到下圖資料:

這樣一看,二者是相同的。說明這個 HTML 的傳輸體積就是 35593 Bytes。

那為什麼在 Network 面板裡,我們看到的是 35.6k transferred over Network 呢?

這是因為在 Network 裡展示的體積,不是除以 1024 計算的,而是除以 1000,然後四捨五入後的結果。

不過 Summary 裡的 pending for xxx ms,似乎是也是等待主執行緒的時間,但它又是如何在 performance 體現的。目前,我還沒搞清楚,如果有了解的小夥伴歡迎留言討論~

請求其它資源

言歸正傳,我們現在獲取到了 bilibili 網站的 HTML,接下來就需要對這個 HTML 進行處理。

通過 response header 得到 content-type:html,此時會建立一個個渲染程序,也就是主執行緒的這個程序。但是可以看到在主執行緒中的藍色 parse HTML 之前,已經有很多 set request 被髮起了,而且這些 send request 都是 HTML 文件中的一些 js 和 css。

為什麼會這樣呢?不應該是先解析 HTML,才能知道對哪些資源進行發起請求嗎?

在 HTML 中引入的 js,存在修改 Dom 的可能,所以瀏覽器一般在遇到 script 標籤後,會先暫停 HTML 解析,優先 js 的下載和執行。但是下載是相對耗時的,如果因為下載時間久而卡住了頁面解析,很容易導致使用者體驗變差,因此 Chrome 採用了一些優化策略。

具體來說,就是當 Chrome 渲染引擎接收到 HTML 的位元組流時候,會開啟一個專門用來分析位元組流中所包含 js、css 檔案的預解析執行緒。解析到相關資訊之後,預解析執行緒會提前開始下載這些資原始檔,這樣在需要使用的時候就可以直接執行,避免了下載的等待時間。

但是也能觀察到,在Parse HTML藍色方塊下方,還有一些 send request,這些怎麼就不是提前下載的呢? 我的理解是,這些資源其實都是在預解析執行緒下載的,儘管在時間上會存在重疊,但和主執行緒不屬於同一個執行緒,所以 performance 工具會這麼顯示。但這又帶來了另一個問題,為什麼有些 js 明明在 HTML 的後面,卻在前面就 send request 了,而有些 link/script 明明寫在 HTML 裡的前面,卻在 performance 裡後 send request?

這是跟資源的優先順序有關。比如普通的 script 標籤引用的資源,普通 link 引用的資源,或是rel=prelaod 或 as="style"預載入的資源,可能會被優先處理。而當資源是 prefetch,或者用 <link rel="stylesheet" href="//s1.hdslb.com/bfs/static/jinkela/long/font/regular.css" media="print" onload="this.media='all'"/>這種方式的,由於優先順序低,就會被延後下載。一般的其他資源,則按順序下載。

回到 Network,可以看到在 www.bilibil.com 的箱線圖之後,是一連串 js、css、Webp 資源需要載入的請求被髮起了。把滑鼠移動到這些箱線圖上,會看到上面有優先順序 lowest low high highest,這就表示了資源的重要程度。

那麼這些資源的優先順序是如何評定的?一般來說,訪問域名獲取的 HTML、 以及預載入資源時as="style",擁有最高優先順序。普通的 <script> 、 <link> 標籤、 使用preload的預載入,擁有 高優先順序。使用了 async/defer 的 <script> 、as="script"的預載入資源擁有低優先順序。使用了

<link rel="stylesheet" href="//s1.hdslb.com/bfs/static/jinkela/long/font/medium.css" media="print" onload="this.media='all'">

這種方式的,和不加 as="xxx"的 prefetch 預載入,就相當於非同步載入,擁有最低優先順序。

HTML Parse

好,到現在為止,我們已經將用到的 js、css、圖片等資源下載了,然後就該進入解析 HTML 的過程了。

在 Chrome 渲染引擎內部,有個 HTMLParser 的模組。HTML 解析器負責將 HTML 轉化為 Dom 結構。HTML 解析器並不是等整個文件全部獲取之後才開始解析,而是載入了"足夠"的資料後,就開始解析了。

在 HTML Parser 的 summary 面板裡可以看到,有個Range:www.bilibili.com[0...45]。點進去看一下可以發現,定位到了 HTML 的 45 行。

這也從側面印證瞭解析 HTML 的過程並不是一次全部執行完的。

HTML 的解析生成 Dom 樹的過程,可以參考文章(https://medium.com/nybles/introduction-to-Dom-bee3b2dd9911)。簡單來說就是將位元組流轉換成 token,然後把 token 解析成 Dom 節點並新增到 Dom 樹中。

在 HTML 解析器工作過程中,會遇到 js、css 需要處理,比如藍色條下面有黃色的 js 執行,有 parse stylesheet 的 css(這裡的兩個是 vendor.css 和 index.css)解析和 cssom 的構建。

當拿到了 vendor.css 和 index.css 這兩個外部樣式檔案之後,就開始了 Recalculate Style 的過程,也就是在進行一些可能包括遞迴(比如想知道父容器的大小就得先知道子元素的大小)的樣式計算。注意,這時候 HTML 還是沒有完全解析完的,但是一旦樣式計算結束,就開始 Layout過程。

這裡的Layout對應的是將 Dom tree 和 cssom 結合成 render tree的過程。render tree 是不包含例如<meta>、display: none這些無需展示的元素。

分層

在樣式計算之後,還需要經歷一個pre-paint的過程,然後才能paint。

以前這裡叫做 update layer tree, 2022年3月份之後改成了 pre-paint。這裡其實是遍歷 render tree 生成 layer tree 的過程。

render tree 和 layer tree 有啥不同呢?

render tree 是 Dom 和 cssom 結合的產物,是將計算後的樣式新增到了 Dom 節點上。但是目前只是知道了節點是否可見以及可見樣式,還不知道節點的精確位置和大小,這時候就需要佈局。渲染引擎從 render tree 的根節點開始遍歷,通過一定的規則處理後,將會得到一個 layout tree,這個 layout tree 精確的描述了每個視口內元素的位置和確切尺寸,所有的相對位置都會轉變成螢幕上的絕對位置,在得知了節點是否可見、樣式、位置幾何資訊之後,渲染引擎才有機會將 render tree 上的每個節點都轉換成螢幕上的畫素,這個過程也就是一般說的 繪製 paint或者柵格化 Rastering。

那 layer tree 在哪兒呢?layer tree 就在柵格化的過程當中。

在說柵格化之前,有必要提一下 Chrome 是如何將渲染視口內的內容的。

過去 Chrome 是隻在使用者可視區域內進行柵格化,隨著使用者滾動不斷滾動頁面而調整柵格化區域,繼續柵格化並將內容填充到缺失部分。這樣的缺點是當用戶快速滾動的時候,頁面會有卡頓感。

而現在 Chrome 採用了一種合成 composting 的方式,將頁面中的某些部分分成不同的層,分別柵格化它們,然後在合成器執行緒中合成。這樣在頁面滾動時,原材料已經有了(準備好的那些層),只需要將視口內的蹭合成為一個新幀即可。這樣在使用者滾動時,新幀的合成效率更高。

既然需要分層,那就要知道那些元素應該在哪一層裡,所以渲染引擎需要按照一定規則再遍歷一次 layout tree 來建立 layer tree ,這個過程也就是 pre-paint,以前叫做 update layer tree。

分層也需要按照一定的規則,不是任意一個元素都可以被拎出來當做一層,主要是兩個條件:

  • 擁有層疊上下文屬性的元素會被建立成圖層

頁面是個二維的,但是層疊上下文屬性會讓 HTML 元素具有三維的概念。這些元素按照自身的屬性優先順序分佈在垂直頁面的 Z 軸之上,哪些元素擁有具體參考 MDN。

  • 需要被裁剪的地方會被建立為圖層

當你實際的內容比容器還大的時候,就會出現裁剪,引擎會裁剪一部分內容顯示在容器區域。一般來說,出現滾動條就會被建立為圖層。

滿足以上任意一個條件就會被提升成單獨一層。

那這在 Chrome devtools 哪裡可以體現呢?在 devtools -> 右側三個點 -> more tools -> layers 裡可以看到頁面實際上被分成了許多層。

點選左側的具體圖層,可以看到詳細的繪製過程。Details 裡還有被提升為一層的原因composition reason。

Paint

通過前面分層,我們得知了元素的層級關係,但是還不知道同一層內元素的層級關係。一般來說,後面的內容會覆蓋前面內容,但是瀏覽器該如何知道誰該覆蓋誰呢?

這就需要渲染引擎為每一個圖層建立繪製記錄 patint record 並確定誰先畫誰後畫,那麼後畫的肯定就會覆蓋先畫的。繪製記錄可以看做一個單向連結串列 div -> div -> p -> span,遍歷連結串列即可獲得繪製順序。

現在有了圖層,也有了繪製記錄順序,這些資訊將會被提交到合成器執行緒中進行繪圖和合成。由於一個圖層可能會非常大,超過了視口面積,那麼圖層就會經歷一次分割過程,分割成一個個小的圖塊 Tile,通常是 256256 或 512512 大小,這些圖塊進行會傳遞給柵格化執行緒池。池中的柵格化執行緒執行柵格化任務 Raster Task,將圖塊生成點陣圖 bitmap,並優先生成視口附近的點陣圖。

這個過程在performamce裡叫做Rasterize Paint。

柵格化過程也會使用 GPU 來加速,一般又稱為快速柵格化,GPU 柵格化。這也是為什麼會有些 css 裡寫 will-change:transfrom 或者 transform: translateZ(0),就是為了 GPU 參與繪製。本質上是利用 will-change 和 translateZ(0) 建立了新的渲染層,從而不影響其他層級的繪製內容。

當所有的 Tile 柵格化完畢,合成器執行緒收集 Draw Quads 的圖塊資訊。Draw Quads 記錄了圖塊在記憶體中的位置和在頁面那個位置進行繪製。然後主執行緒收集這些 Draw quards 資訊併合成合成器幀,並交給 GPU渲染,然後才是畫素出現在螢幕之上。這個過程在 perfomrance 裡是 Compositie layers。

可惜我沒有在 performance 裡找到更詳細的資訊來展示這個過程。

頁面渲染大概就是上述的過程,主要是結合 performance 面板串聯起過去的那些知識。瞭解了頁面渲染流程,我們該如何優化頁面效能呢?又需要關注那些指標呢?

頁面優化關注哪些指標

這個指標不是憑空創造,也不是僅憑感覺,這應該是一些明確的、可以量化的指標。Chrome devtool lightHouse 列舉了 6 個指標。

FCP

First content paint 代表瀏覽器渲染出第一個 Dom content 的時間。這裡的 Dom content 包括圖片、非空白 canvas、svgs 等。如果你的頁面裡有 iframe,iframe 裡的任何東西都不會被當成 Dom content。

FCP 好壞標準也是隨著收集到的頁面資料來不斷變化的,我們可以從 httparchive 地址來檢視現在世界上的頁面的中位數是多少。

根據目前的指標來看,FCP 時間可以簡單的分為 3 檔:

0 - 1.9s,1.9s - 3s,3s以上,它們分別代表還行、很一般、不太行。

當我們在某個頁面中使用 LightHouse 進行評估的時候,可能會看到儘管 FCP 只有 1s,顯示的也是橙色標記。

LightHouse 裡的得分是根據百分比來的。也就是說當你的 FCP 時間,是所有頁面中的前 10%, 那麼可以得到 90 分, 前 1% 可以得到 99 分,得到 90-100 分才會是綠色。其他的幾個指標也是同樣的評判標準。

影響 FCP 的原因有很多,其中一個比較常見的原因是自定義字型的載入,字型檔案的載入需要一定時間,在字型檔案載入完成之前,不同的瀏覽器會採用不同的策略。

edge:在字型準備好前使用系統字型

Chrome:隱藏文字內容。如果 3s 後自定義字型還沒準備好,則使用系統字型,直到字型準備好,然後替換字型。

火狐:同 Chrome

safari:隱藏文字直到字型準備好。

一個簡單的辦法是在@font-face 演示裡增加 font-display: swap

@font-face {
  font-family: 'Pacifico';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/pacifico/v12/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2) format('woff2');
  font-display: swap;
}

設定 swap 就是告訴瀏覽器是使用該字型的文字應該立即使用系統字型進行顯示,當自定義字型準備就緒後,替換系統字型。

或者使用 prefetch / preload 來提前獲取字型相關資源。

Time to intercative

TTI 標記著頁面多久可以進行完全互動。

這裡的完全互動是指:

  • 頁面已經展示了 FCP

  • 事件處理函式已經為大部分可見頁面元素進行了註冊繫結

  • 頁面能夠在 50ms 內對使用者行為進行反應

同樣我們在 httparchive 來看一下這個世界上網頁 TTI 的中位數是多少。

降低 TTI 的常見思路是程式碼分割、按需載入,刪除未使用程式碼、壓縮程式碼、壓縮網路負載、減少 JS 對主執行緒的長時間佔用。

Speed Index

這個指標代表了使用者感知的可見區域的頁面載入的快慢。

也分成 0-3.4s 、 3.4 - 5.8、超過 5.8 三擋。但是得分也同樣是跟全球網頁資料來對比的。

提高 speed index 的主要是通過減少 js 對主執行緒阻塞,讓一些非必要的 js 在 Dom 渲染後再執行。

Total Block Time

總阻塞時間。這個時間是從 FCP 到 TTI 之間所有的長任務阻塞部分的時間之和。

長任務是指執行時間超過 50ms 的任務,50ms 之後的時間量就是阻塞時間。

既然是阻塞時間,降低 TBT 的辦法就是想辦法減少不必要的 js 的載入、解析和執行。拆分大型指令碼,對某些非同步必要的 js 使用 defer/async 或者 prefetch/preload 、或者允許的情況下進行懶載入/延遲載入、將靜態資源部署到 CDN 等等。

Largest Contentful Paint

最大內容繪製。記錄的是視口中最大的內容元素被渲染到螢幕的時間,也大致分為 0-2.5s、2.5s-4s、超過 4s 三個大範圍。

這裡的類容元素是指:

  • <img >

  • <svg> 內嵌的 <image> 元素

  • 使用了封面的<video> 元素

  • url() 載入的帶背景圖的元素

  • 包含文字或者其他行內文字元素子元素的塊級元素

注意,如果元素溢位到可視區域之外,則不算 LCP。

LCP 主要手4個方面的影響:

  • 緩慢的伺服器響應速度

應對方案:CDN、預載入、serviceWorker

  • js/css 的渲染阻塞

應對方案:

1、用optimize-css-assets-Webpack-plugin、uglyifyJS之類的 Webpack 外掛壓縮 css、js

2、對非必要的 js、css 延遲載入,如非必要 css 用預載入,在觸發事件後再去 import xx from 'xxx'。

3、合適的情況下使用內聯 css。

  • 緩慢的資源載入速度

  • 壓縮影象

  • 預載入重要資源

  • 壓縮文字檔案 Gzip、br

  • serviceWorker 進行快取

  • 客戶端渲染

  • 壓縮 js

  • 延遲載入未立即使用的 js

  • 儘可能減少polyfill。"targets":">0.25%"

Cumulative Layout Shift

有些時候我們會遇到,初始載入時字型忽然變大/變小, 元素位置突然移動位等。

CLS 就是通過測量發生偏移的頻率來表示出頁面的不穩定性。

常見的導致 CLS 比較差的原因有:

  • 沒指定寬高的圖片

  • 沒有設定寬高的 iframe

  • 沒有設定寬高的資源位(頂部 banner、廣告等)

前面提到的 無樣式文字閃爍(FOUT, 用預設字型替換新字型)/ 不可見文字閃爍(FOIT,獲取新字型前的顯示不可見文字)。<link rel=preload>和font-display: optional結合使用

  • 動畫使用了修改 width、height、top、right、bottom、left 等屬性值的方式來實現。應優先使用 css transfrom來實現動畫。

以上就是目前 Chrome lighthouse 用來判斷頁面體驗的 6 個指標。如果我們要優化頁面,也應從這 6 個方面來入手,逐一改進,在現有的可量化指標下有的放矢。

參考資料:https://www.debugbear.com/blog/devtools-performance

推薦閱讀

低程式碼是開發的未來嗎?淺談低程式碼平臺

當談論 React hook,我們究竟說的是什麼?