詳解 CRP:如何最大化提升首屏渲染速度

Jeremygo發表於2019-01-08

在前端效能優化樹上有很多值得展開的話題,從輸入 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 的內容屬性以及各個節點之間的關係。

ToDOM

比如以下程式碼:

<!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 樹:

DOM

瀏覽器現在有了頁面的內容,那麼該如何展示這個頁面本身呢?

將 CSS 轉換成 CSSOM 樹

與轉換 HTML 類似,瀏覽器首先會識別 CSS 正確的令牌,然後將這些令牌轉成 CSS 節點,子節點會繼承父節點的樣式規則,這就是層疊規則和層疊樣式表。

ToCSSOM

比如上面的 HTML 程式碼有以下的 CSS :

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
複製程式碼

最終就轉成下面的 CSSOM 樹:

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">
複製程式碼

我們在實際進行自適應網頁設計時都會加上這行程式碼表示佈局視口的寬度等於裝置的寬度,因此呈現出來就是這樣:

viewport

最後一步就是將所有準備好的內容 繪製 到頁面上。

任何時候我們想要更新渲染樹時,可能都會重新進行佈局和繪製這一過程,瀏覽器本身會採取各種智慧的功能嘗試重新繪製最低請求區域,但具體還是取決於我們向渲染樹應用了哪種型別的更新。

如何優化

在談優化之前,我們先定義一下用來描述 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,但執行會)。
  • 當我們的指令碼不會修改 DOM 或 CSSOM 時,推薦使用 async

這裡給出一個參考圖:

render

瀏覽器還有一個特殊的流程,叫做預載入掃描器,它會提前掃描文件並發現關鍵的 CSS 和 JS 資源來下載,這個過程不會阻塞渲染,想詳細瞭解它的原理可以瀏覽這篇文章 How the Browser Pre-loader Makes Pages Load Faster,實際的應用可瀏覽 前端效能優化之關鍵路徑渲染優化

總結一下,為了首屏最快地渲染,我們通常會採取下列步驟:

  • 分析並用 關鍵資源數 關鍵位元組數 關鍵路徑長度 來描述我們的 CRP 。
  • 最小化關鍵資源數: 消除它們(內聯)、推遲它們的下載(defer)或者使它們非同步解析(async)等等 。
  • 優化關鍵位元組數(縮小、壓縮)來減少下載時間 。
  • 優化載入剩餘關鍵資源的順序: 讓關鍵資源(CSS)儘早下載以減少 CRP 長度 。

更詳細的優化建議可以閱讀 PageSpeed Rules and Recommendations

參考

相關文章