在前端性能優化樹上有很多值得展開的話題,從輸入 URL 到頁面加載完成發生了什麼 這一道經典的面試題就涉及到很多內容,但前端主要關注的部分就是 瀏覽器解析響應的內容並渲染展示給用戶 這一步,本文將會詳細分析這一步的具體過程並在分析的過程中理解該如何做性能優化。
首先介紹一個名詞 CRP,即 關鍵渲染路徑 (Critical Rendering Path)(後文統一以 CRP 指代):
關鍵渲染路徑是瀏覽器將 HTML CSS JavaScript 轉換為在屏幕上呈現的像素內容所經歷的一系列步驟。
將 HTML 轉換成 DOM 樹#
當我們請求某個 URL 以後,瀏覽器獲得響應的數據並將所有的標記轉換到我們在屏幕上所看到的 HTML
,有沒有想過這中間發生了什麼?
瀏覽器會遵循定義好的完善步驟,從處理 HTML 和構建 DOM 開始:
- 瀏覽器從磁碟或網絡中讀取 HTML 原始字節,並根據文件的指定編碼將它們轉成字符。
- 當遇到 HTML 標記時,瀏覽器會發出一個令牌,生成諸如
StartTag: HTML
StartTag:head
Tag: meta
EndTag: head
這樣的令牌,整個瀏覽由令牌生成器來完成。 - 在令牌生成的同時,另一個流程會同時消耗這些令牌並轉換成
HTML
head
這些節點對象,起始和結束令牌表明了節點之間的關係。 - 當所有的令牌消耗完以後就轉換成了 DOM(文檔對象模型)。
DOM 是一個樹結構,表示了 HTML 的內容和屬性以及各個節點之間的關係。
比如以下代碼:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="style.css" rel="stylesheet">
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>
最終就轉成下面的 DOM 樹:
瀏覽器現在有了頁面的內容,那麼該如何展示這個頁面本身呢?
將 CSS 轉換成 CSSOM 樹#
與轉換 HTML 類似,瀏覽器首先會識別 CSS 正確的令牌,然後將這些令牌轉成 CSS 節點,子節點會繼承父節點的樣式規則,這就是層疊規則和層疊樣式表。
比如上面的 HTML 代碼有以下的 CSS :
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
最終就轉成下面的 CSSOM 樹:
這裡需要特別區分的是,DOM 樹會逐步構建來使頁面更快地呈現,但是 CSSOM 樹構建時會阻止頁面呈現。
原因很簡單,如果 CSSOM 樹也可以逐步呈現頁面的話,那麼之後新生成的子節點樣式規則有可能會覆蓋之前的規則,這就會導致頁面的錯誤渲染。
讓我們來做一個思考題,請看以下的 HTML 代碼:
<div>
<h1>H1 title</h1>
<p>Lorem...</p>
</div>
對於以下兩個樣式規則,哪個樣式規則會渲染得更快?
h1 { font-size: 16px }
div p { font-size: 12px }
直覺上很容易覺得第二個規則是更具體的,應該會渲染更快,但實際上恰恰相反:
- 第一條規則是非常簡單的,一旦遇到 h1 標記,就會將字號設成 16px。
- 第二條規則更複雜,首先它規定了我們應該滿足所有 p 標記,但是當我們找到 p 標記時,還需要向上遍歷 DOM 樹,只有當父節點是 div 時才會應用這個規則。
- 所以更加具體的標記要求瀏覽器處理的工作更多,實際編寫中應該儘可能避免編寫過於具體的選擇器。
那麼到現在為止,DOM 樹包含了頁面的所有內容,CSSOM 樹包含了頁面的所有樣式,接下來如何將內容和樣式轉成像素顯示到屏幕上呢?
將 DOM 和 CSSOM 樹組成渲染樹#
瀏覽器會從 DOM 樹的根部開始看有沒有相符的 CSS 規則,如果有的話就將節點和樣式複製到渲染樹上,沒有的話就只將節點複製過來,然後繼續向下遍歷。
特別要注意的是,渲染樹最重要的特性是只捕獲可見內容 :
- 對於特殊節點(html head)等,因為它們不會被渲染,因此會直接跳過。
- 如果一個節點的屬性標記為
display: none
,表示這個節點不應該呈現,則這個節點和其子項都會直接跳過。
比如以下將 DOM 樹和 CSSOM 樹合併成渲染樹的結果:
現在我們已經有了渲染樹,接下來要做的是確定元素在頁面上的位置。
布局與繪製#
我們考慮以下的代碼:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>
瀏覽器在渲染時會將這裡父 div 的寬度設置成 body 的 50%,將子 div 的寬度設成父 div 的 50%,那麼這裡 body 的寬度是如何確定的?
注意我們在 meta 標籤中設置了一行代碼:
<meta name="viewport" content="width=device-width,initial-scale=1">
我們在實際進行自適應網頁設計時都會加上這行代碼表示佈局視口的寬度等於設備的寬度,因此呈現出來就是這樣:
最後一步就是將所有準備好的內容 繪製 到頁面上。
任何時候我們想要更新渲染樹時,可能都會重新進行佈局和繪製這一過程,瀏覽器本身會採取各種智能的功能嘗試重新繪製最低請求區域,但具體還是取決於我們向渲染樹應用了哪種類型的更新。
如何優化#
在談優化之前,我們先定義一下用來描述 CRP 的詞彙:
- 關鍵資源:可能阻止網頁首次渲染的資源。
- 關鍵路徑長度:獲取所有關鍵資源所需的往返次數或總時間。
- 關鍵字節:實現網頁首次渲染所需的總字節數,等同於所有關鍵資源傳送文件大小的總和。
結合我們談過的步驟,我們著重會考慮的優化策略是在合成渲染樹之前。
首先我們可以優化 DOM,具體體現在以下幾步:
- 刪除不必要的代碼和註釋包括空格,儘量做到最小化文件。
- 可以利用 GZIP 壓縮文件。
- 結合 HTTP 緩存文件。
然後是優化 CSSOM,縮小、壓縮以及緩存同樣重要,對於 CSSOM 我們前面重點提過了它會阻止頁面呈現,因此我們可以從這方面考慮去優化,讓我們看下面的代碼:
body { font-size: 16px }
@media screen and (orientation: landscape) {
.menu { float: right }
}
@media print {
body { font-size: 12px }
}
當瀏覽器遇到 CSS 時,會阻止呈現頁面直到 CSSOM 解析完畢,但是對於一些特定場合才會運用的 CSS (比如上面兩個媒體查詢),瀏覽器會依舊請求,但不會阻塞渲染了,這也是為什麼我們有時會將 CSS 文件拆分到不同的文件,上面的樣式表聲明可以優化成這樣:
<link href="style.css" rel="stylesheet">
<link href="landscape.css" rel="stylesheet" media="orientation:landscape">
<link href="print.css" rel="stylesheet" media="print">
當我們用 PageSpeed Insights 檢測我們的網站時,經常出現的一條就是 建議減少關鍵 CSS 元素數量 。
Google 官方文檔 也建議: 當我們聲明樣式表時,請密切關注媒體查詢的類型,它們極大地影響了 CRP 的性能 。
接下來讓我們考慮 JavaScript
外部依賴可以優化的地方,再看下面的代碼:
<p>
Awesome page
<script src="write.js"></script>
is awesome
</p>
當瀏覽器遇到 script 標記時,會阻止解析器繼續操作,直到 CSSOM 構建完畢,JavaScript
才會運行並繼續完成 DOM 構建過程,對於 JavaScript
依賴的優化,我們最常用的一種方法是當網頁加載完成,瀏覽器發出 onload 事件後再去執行腳本(或者直接放在底部),但實際上還有更簡單的策略:
async
: 當我們在 script 標記添加async
屬性以後,瀏覽器遇到這個 script 標記時會繼續解析 DOM,同時腳本也不會被 CSSOM 阻止,即不會阻止 CRP。defer
: 與async
的區別在於,腳本需要等到文檔解析後(DOMContentLoaded
事件前)執行,而async
允許腳本在文檔解析時位於後台運行。- 當我們的腳本不會修改 DOM 或 CSSOM 時,推薦使用
async
。
這裡給出一個參考圖:
瀏覽器還有一個特殊的流程,叫做預加載掃描器,它會提前掃描文檔並發現關鍵的 CSS 和 JS 資源來下載,這個過程不會阻塞渲染,想詳細了解它的原理可以瀏覽這篇文章 How the Browser Pre-loader Makes Pages Load Faster,實際的應用可瀏覽 前端性能優化之關鍵路徑渲染優化
總結一下,為了首屏最快地渲染,我們通常會採取下列步驟:
- 分析並用 關鍵資源數 關鍵字節數 關鍵路徑長度 來描述我們的 CRP 。
- 最小化關鍵資源數:消除它們(內聯)、推遲它們的下載(defer)或者使它們異步解析(async)等等 。
- 優化關鍵字節數(縮小、壓縮)來減少下載時間 。
- 優化加載剩餘關鍵資源的順序:讓關鍵資源(CSS)儘早下載以減少 CRP 長度 。
詳細的優化建議可以閱讀 PageSpeed Rules and Recommendations