瀏覽器渲染過程與效能優化

SylvanasSun發表於2017-10-04

大家都知道全球資訊網的應用層使用了HTTP協議,並且用瀏覽器作為入口訪問網路上的資源。使用者在使用瀏覽器訪問一個網站時需要先通過HTTP協議向伺服器傳送請求,之後伺服器返回HTML檔案與響應資訊。這時,瀏覽器會根據HTML檔案來進行解析與渲染(該階段還包括向伺服器請求非內聯的CSS檔案與JavaScript檔案或者其他資源),最終再將頁面呈現在使用者面前。

現在知道了網頁的渲染都是由瀏覽器完成的,那麼如果一個網站的頁面載入速度太慢會導致使用者體驗不夠友好,本文通過詳解瀏覽器渲染頁面的過程來引入一些基本的瀏覽器效能優化方案。讓瀏覽器更快地渲染你的網頁並快速響應從而提高使用者體驗。

本文作者為: SylvanasSun(sylvanas.sun@gmail.com).轉載請務必將下面這段話置於文章開頭處(保留超連結).
本文首發自SylvanasSun Blog,原文連結: sylvanassun.github.io/2017/10/03/…

關鍵渲染路徑


瀏覽器接收到伺服器返回的HTMLCSSJavaScript位元組資料並對其進行解析和轉變成畫素的渲染過程被稱為關鍵渲染路徑。通過優化關鍵渲染路徑即可以縮短瀏覽器渲染頁面的時間。

瀏覽器在渲染頁面前需要先構建出DOM樹與CSSOM(如果沒有DOM樹和CSSOM樹就無法確定頁面的結構與樣式,所以這兩項是必須先構建出來的)。

DOM樹全稱為Document Object Model文件物件模型,它是HTMLXML文件的程式設計介面,提供了對文件的結構化表示,並定義了一種可以使程式對該結構進行訪問的方式(比如JavaScript就是通過DOM來操作結構、樣式和內容)。DOM將文件解析為一個由節點和物件組成的集合,可以說一個WEB頁面其實就是一個DOM

CSSOM樹全稱為Cascading Style Sheets Object Model層疊樣式表物件模型,它與DOM樹的含義相差不大,只不過它是CSS的物件集合。

構建DOM樹與CSSOM樹


瀏覽器從網路或硬碟中獲得HTML位元組資料後會經過一個流程將位元組解析為DOM樹:

  • 編碼: 先將HTML的原始位元組資料轉換為檔案指定編碼的字元。

  • 令牌化: 然後瀏覽器會根據HTML規範來將字串轉換成各種令牌(如<html><body>這樣的標籤以及標籤中的字串和屬性等都會被轉化為令牌,每個令牌具有特殊含義和一組規則)。令牌記錄了標籤的開始與結束,通過這個特性可以輕鬆判斷一個標籤是否為子標籤(假設有<html><body>兩個標籤,當<html>標籤的令牌還未遇到它的結束令牌</html>就遇見了<body>標籤令牌,那麼<body>就是<html>的子標籤)。

  • 生成物件: 接下來每個令牌都會被轉換成定義其屬性和規則的物件(這個物件就是節點物件)。

  • 構建完畢: DOM樹構建完成,整個物件集合就像是一棵樹形結構。可能有人會疑惑為什麼DOM是一個樹形結構,這是因為標籤之間含有複雜的父子關係,樹形結構正好可以詮釋這個關係(CSSOS同理,層疊樣式也含有父子關係。例如: div p {font-size: 18px},會先尋找所有p標籤並判斷它的父標籤是否為div之後才會決定要不要採用這個樣式進行渲染)。

整個DOM樹的構建過程其實就是: 位元組 -> 字元 -> 令牌 -> 節點物件 -> 物件模型,下面將通過一個示例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樹構建過程

當上述HTML程式碼遇見<link>標籤時,瀏覽器會傳送請求獲得該標籤中標記的CSS檔案(使用內聯CSS可以省略請求的步驟提高速度,但沒有必要為了這點速度而丟失了模組化與可維護性),style.css中的內容如下:

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

瀏覽器獲得外部CSS檔案的資料後,就會像構建DOM樹一樣開始構建CSSOM樹,這個過程沒有什麼特別的差別。

CSSOM樹
CSSOM樹

如果想要更詳細地去體驗一下關鍵渲染路徑的構建,可以使用Chrome開發者工具中的Timeline功能,它記錄了瀏覽器從請求頁面資源一直到渲染的各種操作過程,甚至還可以錄製某一時間段的過程(建議不要去看太大的網站,資訊會比較雜亂)。

Timeline
Timeline

構建渲染樹


在構建了DOM樹和CSSOM樹之後,瀏覽器只是擁有了兩個互相獨立的物件集合,DOM樹描述了文件的結構與內容,CSSOM樹則描述了對文件應用的樣式規則,想要渲染出頁面,就需要將DOM樹與CSSOM樹結合在一起,這就是渲染樹。

渲染樹
渲染樹

  • 瀏覽器會先從DOM樹的根節點開始遍歷每個可見節點(不可見的節點自然就沒必要渲染到頁面了,不可見的節點還包括被CSS設定了display: none屬性的節點,值得注意的是visibility: hidden屬性並不算是不可見屬性,它的語義是隱藏元素,但元素仍然佔據著佈局空間,所以它會被渲染成一個空框)。

  • 對每個可見節點,找到其適配的CSS樣式規則並應用。

  • 渲染樹構建完成,每個節點都是可見節點並且都含有其內容和對應規則的樣式。

渲染樹構建完畢後,瀏覽器得到了每個可見節點的內容與其樣式,下一步工作則需要計算每個節點在視窗內的確切位置與大小,也就是佈局階段。

CSS採用了一種叫做盒子模型的思維模型來表示每個節點與其他元素之間的距離,盒子模型包括外邊距(Margin),內邊距(Padding),邊框(Border),內容(Content)。頁面中的每個標籤其實都是一個個盒子。

盒子模型
盒子模型

佈局階段會從渲染樹的根節點開始遍歷,然後確定每個節點物件在頁面上的確切大小與位置,佈局階段的輸出是一個盒子模型,它會精確地捕獲每個元素在螢幕內的確切位置與大小,所有相對的測量值也都會被轉換為螢幕內的絕對畫素值。

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

上述程式碼的佈局結果
上述程式碼的佈局結果

Layout佈局事件完成後,瀏覽器會立即發出Paint SetupPaint事件,開始將渲染樹繪製成畫素,繪製所需的時間跟CSS樣式的複雜度成正比,繪製完成後,使用者就可以看到頁面的最終呈現效果了。

我們對一個網頁傳送請求並獲得渲染後的頁面可能也就經過了1~2秒,但瀏覽器其實已經做了上述所講的非常多的工作,總結一下瀏覽器關鍵渲染路徑的整個過程:

  • 處理HTML標記資料並生成DOM樹。

  • 處理CSS標記資料並生成CSSOM樹。

  • DOM樹與CSSOM樹合併在一起生成渲染樹。

  • 遍歷渲染樹開始佈局,計算每個節點的位置資訊。

  • 將每個節點繪製到螢幕。

渲染阻塞的優化方案


瀏覽器想要渲染一個頁面就必須先構建出DOM樹與CSSOM樹,如果HTMLCSS檔案結構非常龐大與複雜,這顯然會給頁面載入速度帶來嚴重影響。

所謂渲染阻塞資源,即是對該資源傳送請求後還需要先構建對應的DOM樹或CSSOM樹,這種行為顯然會延遲渲染操作的開始時間。HTMLCSSJavaScript都是會對渲染產生阻塞的資源,HTML是必需的(沒有DOM還談何渲染),但還可以從CSSJavaScript著手優化,儘可能地減少阻塞的產生。

優化CSS


如果可以讓CSS資源只在特定條件下使用,這樣這些資源就可以在首次載入時先不進行構建CSSOM樹,只有在符合特定條件時,才會讓瀏覽器進行阻塞渲染然後構建CSSOM樹。

CSS的媒體查詢正是用來實現這個功能的,它由媒體型別以及零個或多個檢查特定媒體特徵狀況的表示式組成。

<!-- 沒有使用媒體查詢,這個css資源會阻塞渲染  -->
<link href="style.css"    rel="stylesheet">
<!-- all是預設型別,它和不設定媒體查詢的效果是一樣的 -->
<link href="style.css"    rel="stylesheet" media="all">
<!-- 動態媒體查詢, 將在網頁載入時計算。
根據網頁載入時裝置的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染。-->
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
<!-- 只在列印網頁時應用,因此網頁首次在瀏覽器中載入時,它不會阻塞渲染。 -->
<link href="print.css"    rel="stylesheet" media="print">複製程式碼

使用媒體查詢可以讓CSS資源不在首次載入中阻塞渲染,但不管是哪種CSS資源它們的下載請求都不會被忽略,瀏覽器仍然會先下載CSS檔案

優化JavaScript


當瀏覽器的HTML解析器遇到一個script標記時會暫停構建DOM,然後將控制權移交至JavaScript引擎,這時引擎會開始執行JavaScript指令碼,直到執行結束後,瀏覽器才會從之前中斷的地方恢復,然後繼續構建DOM。每次去執行JavaScript指令碼都會嚴重地阻塞DOM樹的構建,如果JavaScript指令碼還操作了CSSOM,而正好這個CSSOM還沒有下載和構建,瀏覽器甚至會延遲指令碼執行和構建DOM,直至完成其CSSOM的下載和構建。顯而易見,如果對JavaScript的執行位置運用不當,這將會嚴重影響渲染的速度。

下面程式碼中的JavaScript指令碼並不會生效,這是因為DOM樹還沒有構建到<p>標籤時,JavaScript指令碼就已經開始執行了。這也是為什麼經常有人在HTML檔案的最下方寫內聯JavaScript程式碼,又或者使用window.onload()JQuery中的$(function(){})(這兩個函式有一些區別,window.onload()是等待頁面完全載入完畢後觸發的事件,而$(function(){})DOM樹構建完畢後就會執行)。

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Hello,World</title>
    <script type="text/javascript">
        var p = document.getElementsByTagName('p')[0];
        p.textContent = 'SylvanasSun';    
    </script>
  </head>
  <body>
    <p>Hello,World!</p>
  </body>
</html>複製程式碼

使用async可以通知瀏覽器該指令碼不需要在引用位置執行,這樣瀏覽器就可以繼續構建DOMJavaScript指令碼會在就緒後開始執行,這樣將顯著提升頁面首次載入的效能(async只可以在src標籤中使用也就是外部引用的JavaScript檔案)。

<!-- 下面2個用法效果是等價的 -->
<script type="text/javascript" src="demo_async.js" async="async"></script>
<script type="text/javascript" src="demo_async.js" async></script>複製程式碼

優化關鍵渲染路徑總結


上文已經完整講述了瀏覽器是如何渲染頁面的以及渲染之前的準備工作,接下來我們以下面的案例來總結一下優化關鍵渲染路徑的方法。

假設有一個HTML頁面,它只引入了一個CSS外部檔案:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>複製程式碼

它的關鍵渲染路徑如下:

首先瀏覽器要先對伺服器傳送請求獲得HTML檔案,得到HTML檔案後開始構建DOM樹,在遇見<link>標籤時瀏覽器需要向伺服器再次發出請求來獲得CSS檔案,然後則是繼續構建DOM樹和CSSOM樹,瀏覽器合併出渲染樹,根據渲染樹進行佈局計算,執行繪製操作,頁面渲染完成。

有以下幾個用於描述關鍵渲染路徑效能的詞彙:

  • 關鍵資源:可能阻塞網頁首次渲染的資源(上圖中為2個,HTML檔案與外部CSS檔案style.css)。

  • 關鍵路徑長度: 獲取關鍵資源所需的往返次數或總時間(上圖為2次或以上,一次獲取HTML檔案,一次獲取CSS檔案,這個次數基於TCP協議的最大擁塞視窗,一個檔案不一定能在一次連線內傳輸完畢)。

  • 關鍵位元組:所有關鍵資原始檔大小的總和(上圖為9KB)。

接下來,案例程式碼的需求發生了變化,它新增了一個JavaScript檔案。

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js"></script>
  </body>
</html>複製程式碼

JavaScript檔案阻塞了DOM樹的構建,並且在執行JavaScript指令碼時還需要先等待構建CSSOM樹,上圖的關鍵渲染路徑特性如下:

  • 關鍵資源: 3(HTMLstyle.cssapp.js

  • 關鍵路徑長度: 2或以上(瀏覽器會在一次連線中一起下載style.cssapp.js

  • 關鍵位元組:11KB

現在,我們要優化關鍵渲染路徑,首先將<script>標籤新增非同步屬性async,這樣瀏覽器的HTML解析器就不會阻塞這個JavaScript檔案了。

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js" async></script>
  </body>
</html>複製程式碼

  • 關鍵資源:2(app.js為非同步載入,不會成為阻塞渲染的資源)

  • 關鍵路徑長度: 2或以上

  • 關鍵位元組: 9KB(app.js不再是關鍵資源,所以沒有算上它的大小)

接下來對CSS進行優化,比如新增上媒體查詢。

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet" media="print">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js" async></script>
  </body>
</html>複製程式碼

  • 關鍵資源:1(app.js為非同步載入,style.css只有在列印時才會使用,所以只剩下HTML一個關鍵資源,也就是說當DOM樹構建完畢,瀏覽器就會開始進行渲染)

  • 關鍵路徑長度:1或以上

  • 關鍵位元組:5KB

優化關鍵渲染路徑就是在對關鍵資源、關鍵路徑長度和關鍵位元組進行優化。關鍵資源越少,瀏覽器在渲染前的準備工作就越少;同樣,關鍵路徑長度和關鍵位元組關係到瀏覽器下載資源的效率,它們越少,瀏覽器下載資源的速度就越快。

其他優化方案


除了非同步載入JavaScript和使用媒體查詢外還有很多其他的優化方案可以使頁面的首次載入變得更快,這些方案可以綜合起來使用,但核心的思想還是針對關鍵渲染路徑進行了優化。

載入部分HTML


服務端在接收到請求時先只響應回HTML的初始部分,後續的HTML內容在需要時再通過AJAX獲得。由於服務端只傳送了部分HTML檔案,這讓構建DOM樹的工作量減少很多,從而讓使用者感覺頁面的載入速度很快。

注意,這個方法不能用在CSS上,瀏覽器不允許CSSOM只構建初始部分,否則會無法確定具體的樣式。

壓縮


通過對外部資源進行壓縮可以大幅度地減少瀏覽器需要下載的資源量,它會減少關鍵路徑長度與關鍵位元組,使頁面的載入速度變得更快。

對資料進行壓縮其實就是使用更少的位數來對資料進行重編碼。如今有非常多的壓縮演算法,且每一個的作用領域也各不相同,它們的複雜度也不相同,不過在這裡我不會講壓縮演算法的細節,感興趣的朋友可以自己Google。

在對HTMLCSSJavaScript這些檔案進行壓縮之前,還需要先進行一次冗餘壓縮。所謂冗餘壓縮,就是去除多餘的字元,例如註釋、空格符和換行符。這些字元對於程式設計師是有用的,畢竟沒有格式化的程式碼可讀性是非常恐怖的,但它們對於瀏覽器是沒有任何意義的,去除這些冗餘可以減少檔案的資料量。在進行完冗餘壓縮之後,再使用壓縮演算法進一步對資料本身進行壓縮,例如GZIPGZIP是一個可以作用於任何位元組流的通用壓縮演算法,它會記憶之前已經看到的內容,然後再嘗試查詢並替換重複的內容。)。

HTTP快取


通過網路來獲取資源通常是緩慢的,如果資原始檔過於膨大,瀏覽器還需要與伺服器之間進行多次往返通訊才能獲得完整的資原始檔。快取可以複用之前獲取的資源,既然後端可以使用快取來減少訪問資料庫的開銷,那前端自然也可以使用快取來複用資原始檔。

瀏覽器自帶了HTTP快取的功能,只需要確保每個伺服器響應的頭部都包含了以下的屬性:

  • ETag: ETag是一個傳遞驗證令牌,它對資源的更新進行檢查,如果資源未發生變化時不會傳送任何資料。當瀏覽器傳送一個請求時,會把ETag一起傳送到伺服器,伺服器會根據當前資源核對令牌(ETag通常是對內容進行Hash後得出的一個指紋),如果資源未發生變化,伺服器將返回304 Not Modified響應,這時瀏覽器不必再次下載資源,而是繼續複用快取。

  • Cache-Control: Cache-Control定義了快取的策略,它規定在什麼條件下可以快取響應以及可以快取多久

    • no-cache: no-cache表示必須先與伺服器確認返回的響應是否發生了變化,然後才能使用該響應來滿足後續對同一網址的請求(每次都會根據ETag對伺服器傳送請求來確認變化,如果未發生變化,瀏覽器不會下載資源)。

    • no-store: no-store直接禁止瀏覽器以及所有中間快取儲存任何版本的返回響應。簡單的說,該策略會禁止任何快取,每次傳送請求時,都會完整地下載伺服器的響應。

    • public&private: 如果響應被標記為public,則即使它有關聯的HTTP身份驗證,甚至響應狀態程式碼通常無法快取,瀏覽器也可以快取響應。如果響應被標記為private,那麼這個響應通常只為單個使用者快取,因此不允許任何中間快取(CDN)對其進行快取,private一般用在快取使用者私人資訊頁面。

    • max-age: max-age定義了從請求時間開始,快取的最長時間,單位為秒。

資源預載入


Pre-fetching是一種提示瀏覽器預先載入使用者之後可能會使用到的資源的方法。

使用dns-prefetch來提前進行DNS解析,以便之後可以快速地訪問另一個主機名(瀏覽器會在載入網頁時對網頁中的域名進行解析快取,這樣你在之後的訪問時無需進行額外的DNS解析,減少了使用者等待時間,提高了頁面載入速度)。

<link rel="dns-prefetch" href="other.hostname.com">複製程式碼

使用prefetch屬性可以預先下載資源,不過它的優先順序是最低的。

<link rel="prefetch"  href="/some_other_resource.jpeg">複製程式碼

Chrome允許使用subresource屬性指定優先順序最高的下載資源(當所有屬性為subresource的資源下載完完畢後,才會開始下載屬性為prefetch的資源)。

<link rel="subresource"  href="/some_other_resource.js">複製程式碼

prerender可以預先渲染好頁面並隱藏起來,之後開啟這個頁面會跳過渲染階段直接呈現在使用者面前(推薦對使用者接下來必須訪問的頁面進行預渲染,否則得不償失)。

<link rel="prerender"  href="//domain.com/next_page.html">複製程式碼

參考文獻


相關文章