大家都知道全球資訊網的應用層使用了HTTP
協議,並且用瀏覽器作為入口訪問網路上的資源。使用者在使用瀏覽器訪問一個網站時需要先通過HTTP
協議向伺服器傳送請求,之後伺服器返回HTML
檔案與響應資訊。這時,瀏覽器會根據HTML
檔案來進行解析與渲染(該階段還包括向伺服器請求非內聯的CSS
檔案與JavaScript
檔案或者其他資源),最終再將頁面呈現在使用者面前。
現在知道了網頁的渲染都是由瀏覽器完成的,那麼如果一個網站的頁面載入速度太慢會導致使用者體驗不夠友好,本文通過詳解瀏覽器渲染頁面的過程來引入一些基本的瀏覽器效能優化方案。讓瀏覽器更快地渲染你的網頁並快速響應從而提高使用者體驗。
本文作者為: SylvanasSun(sylvanas.sun@gmail.com).轉載請務必將下面這段話置於文章開頭處(保留超連結).
本文首發自SylvanasSun Blog,原文連結: sylvanassun.github.io/2017/10/03/…
關鍵渲染路徑
瀏覽器接收到伺服器返回的HTML
、CSS
和JavaScript
位元組資料並對其進行解析和轉變成畫素的渲染過程被稱為關鍵渲染路徑。通過優化關鍵渲染路徑即可以縮短瀏覽器渲染頁面的時間。
瀏覽器在渲染頁面前需要先構建出DOM
樹與CSSOM
樹(如果沒有DOM
樹和CSSOM
樹就無法確定頁面的結構與樣式,所以這兩項是必須先構建出來的)。
DOM
樹全稱為Document Object Model
文件物件模型,它是HTML
和XML
文件的程式設計介面,提供了對文件的結構化表示,並定義了一種可以使程式對該結構進行訪問的方式(比如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>複製程式碼
當上述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
樹,這個過程沒有什麼特別的差別。
如果想要更詳細地去體驗一下關鍵渲染路徑的構建,可以使用Chrome
開發者工具中的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 Setup
與Paint
事件,開始將渲染樹繪製成畫素,繪製所需的時間跟CSS
樣式的複雜度成正比,繪製完成後,使用者就可以看到頁面的最終呈現效果了。
我們對一個網頁傳送請求並獲得渲染後的頁面可能也就經過了1~2秒,但瀏覽器其實已經做了上述所講的非常多的工作,總結一下瀏覽器關鍵渲染路徑的整個過程:
處理
HTML
標記資料並生成DOM
樹。處理
CSS
標記資料並生成CSSOM
樹。將
DOM
樹與CSSOM
樹合併在一起生成渲染樹。遍歷渲染樹開始佈局,計算每個節點的位置資訊。
將每個節點繪製到螢幕。
渲染阻塞的優化方案
瀏覽器想要渲染一個頁面就必須先構建出DOM
樹與CSSOM
樹,如果HTML
與CSS
檔案結構非常龐大與複雜,這顯然會給頁面載入速度帶來嚴重影響。
所謂渲染阻塞資源,即是對該資源傳送請求後還需要先構建對應的DOM
樹或CSSOM
樹,這種行為顯然會延遲渲染操作的開始時間。HTML
、CSS
、JavaScript
都是會對渲染產生阻塞的資源,HTML
是必需的(沒有DOM
還談何渲染),但還可以從CSS
與JavaScript
著手優化,儘可能地減少阻塞的產生。
優化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
可以通知瀏覽器該指令碼不需要在引用位置執行,這樣瀏覽器就可以繼續構建DOM
,JavaScript
指令碼會在就緒後開始執行,這樣將顯著提升頁面首次載入的效能(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(
HTML
、style.css
、app.js
)關鍵路徑長度: 2或以上(瀏覽器會在一次連線中一起下載
style.css
和app.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。
在對HTML
、CSS
和JavaScript
這些檔案進行壓縮之前,還需要先進行一次冗餘壓縮。所謂冗餘壓縮,就是去除多餘的字元,例如註釋、空格符和換行符。這些字元對於程式設計師是有用的,畢竟沒有格式化的程式碼可讀性是非常恐怖的,但它們對於瀏覽器是沒有任何意義的,去除這些冗餘可以減少檔案的資料量。在進行完冗餘壓縮之後,再使用壓縮演算法進一步對資料本身進行壓縮,例如GZIP
(GZIP
是一個可以作用於任何位元組流的通用壓縮演算法,它會記憶之前已經看到的內容,然後再嘗試查詢並替換重複的內容。)。
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">複製程式碼