大家都知道萬維網的應用層使用了HTTP
協議,并且用瀏覽器作為入口訪問網絡上的資源。用戶在使用瀏覽器訪問一個網站時需要先通過HTTP
協議向服務器發(fā)送請求,之后服務器返回HTML
文件與響應信息。這時,瀏覽器會根據HTML
文件來進行解析與渲染(該階段還包括向服務器請求非內聯的CSS
文件與JavaScript
文件或者其他資源),最終再將頁面呈現在用戶面前。
現在知道了網頁的渲染都是由瀏覽器完成的,那么如果一個網站的頁面加載速度太慢會導致用戶體驗不夠友好,本文通過詳解瀏覽器渲染頁面的過程來引入一些基本的瀏覽器性能優(yōu)化方案。讓瀏覽器更快地渲染你的網頁并快速響應從而提高用戶體驗。
本文作者為: SylvanasSun(sylvanas.sun@gmail.com).轉載請務必將下面這段話置于文章開頭處(保留超鏈接).
本文首發(fā)自SylvanasSun Blog,原文鏈接: https://sylvanassun.github.io/2017/10/03/2017-10-03-BrowserCriticalRenderingPath
關鍵渲染路徑
瀏覽器接收到服務器返回的HTML
、CSS
和JavaScript
字節(jié)數據并對其進行解析和轉變成像素的渲染過程被稱為關鍵渲染路徑。通過優(yōu)化關鍵渲染路徑即可以縮短瀏覽器渲染頁面的時間。
瀏覽器在渲染頁面前需要先構建出DOM
樹與CSSOM
樹(如果沒有DOM
樹和CSSOM
樹就無法確定頁面的結構與樣式,所以這兩項是必須先構建出來的)。
DOM
樹全稱為Document Object Model
文檔對象模型,它是HTML
和XML
文檔的編程接口,提供了對文檔的結構化表示,并定義了一種可以使程序對該結構進行訪問的方式(比如JavaScript
就是通過DOM
來操作結構、樣式和內容)。DOM
將文檔解析為一個由節(jié)點和對象組成的集合,可以說一個WEB
頁面其實就是一個DOM
。
CSSOM
樹全稱為Cascading Style Sheets Object Model
層疊樣式表對象模型,它與DOM
樹的含義相差不大,只不過它是CSS
的對象集合。
構建DOM樹與CSSOM樹
瀏覽器從網絡或硬盤中獲得HTML
字節(jié)數據后會經過一個流程將字節(jié)解析為DOM
樹:
編碼: 先將
HTML
的原始字節(jié)數據轉換為文件指定編碼的字符。令牌化: 然后瀏覽器會根據
HTML
規(guī)范來將字符串轉換成各種令牌(如<html>
、<body>
這樣的標簽以及標簽中的字符串和屬性等都會被轉化為令牌,每個令牌具有特殊含義和一組規(guī)則)。令牌記錄了標簽的開始與結束,通過這個特性可以輕松判斷一個標簽是否為子標簽(假設有<html>
與<body>
兩個標簽,當<html>
標簽的令牌還未遇到它的結束令牌</html>
就遇見了<body>
標簽令牌,那么<body>
就是<html>
的子標簽)。生成對象: 接下來每個令牌都會被轉換成定義其屬性和規(guī)則的對象(這個對象就是節(jié)點對象)。
構建完畢:
DOM
樹構建完成,整個對象集合就像是一棵樹形結構。可能有人會疑惑為什么DOM
是一個樹形結構,這是因為標簽之間含有復雜的父子關系,樹形結構正好可以詮釋這個關系(CSSOS
同理,層疊樣式也含有父子關系。例如:div p {font-size: 18px}
,會先尋找所有p
標簽并判斷它的父標簽是否為div
之后才會決定要不要采用這個樣式進行渲染)。
整個DOM
樹的構建過程其實就是: 字節(jié) -> 字符 -> 令牌 -> 節(jié)點對象 -> 對象模型,下面將通過一個示例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></div>
</body>
</html>
當上述HTML
代碼遇見<link>
標簽時,瀏覽器會發(fā)送請求獲得該標簽中標記的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
開發(fā)者工具中的Timeline
功能,它記錄了瀏覽器從請求頁面資源一直到渲染的各種操作過程,甚至還可以錄制某一時間段的過程(建議不要去看太大的網站,信息會比較雜亂)。

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

瀏覽器會先從
DOM
樹的根節(jié)點開始遍歷每個可見節(jié)點(不可見的節(jié)點自然就沒必要渲染到頁面了,不可見的節(jié)點還包括被CSS
設置了display: none
屬性的節(jié)點,值得注意的是visibility: hidden
屬性并不算是不可見屬性,它的語義是隱藏元素,但元素仍然占據著布局空間,所以它會被渲染成一個空框)。對每個可見節(jié)點,找到其適配的
CSS
樣式規(guī)則并應用。渲染樹構建完成,每個節(jié)點都是可見節(jié)點并且都含有其內容和對應規(guī)則的樣式。
渲染樹構建完畢后,瀏覽器得到了每個可見節(jié)點的內容與其樣式,下一步工作則需要計算每個節(jié)點在窗口內的確切位置與大小,也就是布局階段。
CSS
采用了一種叫做盒子模型的思維模型來表示每個節(jié)點與其他元素之間的距離,盒子模型包括外邊距(Margin
),內邊距(Padding
),邊框(Border
),內容(Content
)。頁面中的每個標簽其實都是一個個盒子。

布局階段會從渲染樹的根節(jié)點開始遍歷,然后確定每個節(jié)點對象在頁面上的確切大小與位置,布局階段的輸出是一個盒子模型,它會精確地捕獲每個元素在屏幕內的確切位置與大小,所有相對的測量值也都會被轉換為屏幕內的絕對像素值。
<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
布局事件完成后,瀏覽器會立即發(fā)出Paint Setup
與Paint
事件,開始將渲染樹繪制成像素,繪制所需的時間跟CSS
樣式的復雜度成正比,繪制完成后,用戶就可以看到頁面的最終呈現效果了。
我們對一個網頁發(fā)送請求并獲得渲染后的頁面可能也就經過了1~2秒,但瀏覽器其實已經做了上述所講的非常多的工作,總結一下瀏覽器關鍵渲染路徑的整個過程:
處理
HTML
標記數據并生成DOM
樹。處理
CSS
標記數據并生成CSSOM
樹。將
DOM
樹與CSSOM
樹合并在一起生成渲染樹。遍歷渲染樹開始布局,計算每個節(jié)點的位置信息。
將每個節(jié)點繪制到屏幕。
渲染阻塞的優(yōu)化方案
瀏覽器想要渲染一個頁面就必須先構建出DOM
樹與CSSOM
樹,如果HTML
與CSS
文件結構非常龐大與復雜,這顯然會給頁面加載速度帶來嚴重影響。
所謂渲染阻塞資源,即是對該資源發(fā)送請求后還需要先構建對應的DOM
樹或CSSOM
樹,這種行為顯然會延遲渲染操作的開始時間。HTML
、CSS
、JavaScript
都是會對渲染產生阻塞的資源,HTML
是必需的(沒有DOM
還談何渲染),但還可以從CSS
與JavaScript
著手優(yōu)化,盡可能地減少阻塞的產生。
優(yōu)化CSS
如果可以讓CSS
資源只在特定條件下使用,這樣這些資源就可以在首次加載時先不進行構建CSSOM
樹,只有在符合特定條件時,才會讓瀏覽器進行阻塞渲染然后構建CSSOM
樹。
CSS
的媒體查詢正是用來實現這個功能的,它由媒體類型以及零個或多個檢查特定媒體特征狀況的表達式組成。
<!-- 沒有使用媒體查詢,這個css資源會阻塞渲染 -->
<link href="style.css" rel="stylesheet">
<!-- all是默認類型,它和不設置媒體查詢的效果是一樣的 -->
<link href="style.css" rel="stylesheet" media="all">
<!-- 動態(tài)媒體查詢, 將在網頁加載時計算。
根據網頁加載時設備的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染。-->
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
<!-- 只在打印網頁時應用,因此網頁首次在瀏覽器中加載時,它不會阻塞渲染。 -->
<link href="print.css" rel="stylesheet" media="print">
使用媒體查詢可以讓CSS
資源不在首次加載中阻塞渲染,但不管是哪種CSS
資源它們的下載請求都不會被忽略,瀏覽器仍然會先下載CSS文件
優(yōu)化JavaScript
當瀏覽器的HTML
解析器遇到一個script
標記時會暫停構建DOM
,然后將控制權移交至JavaScript
引擎,這時引擎會開始執(zhí)行JavaScript
腳本,直到執(zhí)行結束后,瀏覽器才會從之前中斷的地方恢復,然后繼續(xù)構建DOM
。每次去執(zhí)行JavaScript
腳本都會嚴重地阻塞DOM
樹的構建,如果JavaScript
腳本還操作了CSSOM
,而正好這個CSSOM
還沒有下載和構建,瀏覽器甚至會延遲腳本執(zhí)行和構建DOM
,直至完成其CSSOM
的下載和構建。顯而易見,如果對JavaScript
的執(zhí)行位置運用不當,這將會嚴重影響渲染的速度。
下面代碼中的JavaScript
腳本并不會生效,這是因為DOM
樹還沒有構建到<p>
標簽時,JavaScript
腳本就已經開始執(zhí)行了。這也是為什么經常有人在HTML
文件的最下方寫內聯JavaScript
代碼,又或者使用window.onload()
和JQuery
中的$(function(){})
(這兩個函數有一些區(qū)別,window.onload()
是等待頁面完全加載完畢后觸發(fā)的事件,而$(function(){})
在DOM
樹構建完畢后就會執(zhí)行)。
<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
可以通知瀏覽器該腳本不需要在引用位置執(zhí)行,這樣瀏覽器就可以繼續(xù)構建DOM
,JavaScript
腳本會在就緒后開始執(zhí)行,這樣將顯著提升頁面首次加載的性能(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>
優(yōu)化關鍵渲染路徑總結
上文已經完整講述了瀏覽器是如何渲染頁面的以及渲染之前的準備工作,接下來我們以下面的案例來總結一下優(yōu)化關鍵渲染路徑的方法。
假設有一個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></div>
</body>
</html>
它的關鍵渲染路徑如下:

首先瀏覽器要先對服務器發(fā)送請求獲得HTML
文件,得到HTML
文件后開始構建DOM
樹,在遇見<link>
標簽時瀏覽器需要向服務器再次發(fā)出請求來獲得CSS
文件,然后則是繼續(xù)構建DOM
樹和CSSOM
樹,瀏覽器合并出渲染樹,根據渲染樹進行布局計算,執(zhí)行繪制操作,頁面渲染完成。
有以下幾個用于描述關鍵渲染路徑性能的詞匯:
關鍵資源:可能阻塞網頁首次渲染的資源(上圖中為2個,
HTML
文件與外部CSS
文件style.css
)。關鍵路徑長度: 獲取關鍵資源所需的往返次數或總時間(上圖為2次或以上,一次獲取
HTML
文件,一次獲取CSS
文件,這個次數基于TCP
協議的最大擁塞窗口,一個文件不一定能在一次連接內傳輸完畢)。關鍵字節(jié):所有關鍵資源文件大小的總和(上圖為
9KB
)。
接下來,案例代碼的需求發(fā)生了變化,它新增了一個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></div>
<script src="app.js"></script>
</body>
</html>

JavaScript
文件阻塞了DOM
樹的構建,并且在執(zhí)行JavaScript
腳本時還需要先等待構建CSSOM
樹,上圖的關鍵渲染路徑特性如下:
關鍵資源: 3(
HTML
、style.css
、app.js
)關鍵路徑長度: 2或以上(瀏覽器會在一次連接中一起下載
style.css
和app.js
)關鍵字節(jié):11KB
現在,我們要優(yōu)化關鍵渲染路徑,首先將<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></div>
<script src="app.js" async></script>
</body>
</html>

關鍵資源:2(
app.js
為異步加載,不會成為阻塞渲染的資源)關鍵路徑長度: 2或以上
關鍵字節(jié): 9KB(
app.js
不再是關鍵資源,所以沒有算上它的大小)
接下來對CSS
進行優(yōu)化,比如添加上媒體查詢。
<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></div>
<script src="app.js" async></script>
</body>
</html>
關鍵資源:1(
app.js
為異步加載,style.css
只有在打印時才會使用,所以只剩下HTML
一個關鍵資源,也就是說當DOM
樹構建完畢,瀏覽器就會開始進行渲染)關鍵路徑長度:1或以上
關鍵字節(jié):5KB
優(yōu)化關鍵渲染路徑就是在對關鍵資源、關鍵路徑長度和關鍵字節(jié)進行優(yōu)化。關鍵資源越少,瀏覽器在渲染前的準備工作就越少;同樣,關鍵路徑長度和關鍵字節(jié)關系到瀏覽器下載資源的效率,它們越少,瀏覽器下載資源的速度就越快。
其他優(yōu)化方案
除了異步加載JavaScript
和使用媒體查詢外還有很多其他的優(yōu)化方案可以使頁面的首次加載變得更快,這些方案可以綜合起來使用,但核心的思想還是針對關鍵渲染路徑進行了優(yōu)化。
加載部分HTML
服務端在接收到請求時先只響應回HTML
的初始部分,后續(xù)的HTML
內容在需要時再通過AJAX
獲得。由于服務端只發(fā)送了部分HTML
文件,這讓構建DOM
樹的工作量減少很多,從而讓用戶感覺頁面的加載速度很快。
注意,這個方法不能用在CSS
上,瀏覽器不允許CSSOM
只構建初始部分,否則會無法確定具體的樣式。
壓縮
通過對外部資源進行壓縮可以大幅度地減少瀏覽器需要下載的資源量,它會減少關鍵路徑長度與關鍵字節(jié),使頁面的加載速度變得更快。
對數據進行壓縮其實就是使用更少的位數來對數據進行重編碼。如今有非常多的壓縮算法,且每一個的作用領域也各不相同,它們的復雜度也不相同,不過在這里我不會講壓縮算法的細節(jié),感興趣的朋友可以自己Google。
在對HTML
、CSS
和JavaScript
這些文件進行壓縮之前,還需要先進行一次冗余壓縮。所謂冗余壓縮,就是去除多余的字符,例如注釋、空格符和換行符。這些字符對于程序員是有用的,畢竟沒有格式化的代碼可讀性是非常恐怖的,但它們對于瀏覽器是沒有任何意義的,去除這些冗余可以減少文件的數據量。在進行完冗余壓縮之后,再使用壓縮算法進一步對數據本身進行壓縮,例如GZIP
(GZIP
是一個可以作用于任何字節(jié)流的通用壓縮算法,它會記憶之前已經看到的內容,然后再嘗試查找并替換重復的內容。)。
HTTP緩存
通過網絡來獲取資源通常是緩慢的,如果資源文件過于膨大,瀏覽器還需要與服務器之間進行多次往返通信才能獲得完整的資源文件。緩存可以復用之前獲取的資源,既然后端可以使用緩存來減少訪問數據庫的開銷,那前端自然也可以使用緩存來復用資源文件。
瀏覽器自帶了HTTP
緩存的功能,只需要確保每個服務器響應的頭部都包含了以下的屬性:

ETag: ETag是一個傳遞驗證令牌,它對資源的更新進行檢查,如果資源未發(fā)生變化時不會傳送任何數據。當瀏覽器發(fā)送一個請求時,會把ETag一起發(fā)送到服務器,服務器會根據當前資源核對令牌(ETag通常是對內容進行
Hash
后得出的一個指紋),如果資源未發(fā)生變化,服務器將返回304 Not Modified
響應,這時瀏覽器不必再次下載資源,而是繼續(xù)復用緩存。-
Cache-Control: Cache-Control定義了緩存的策略,它規(guī)定在什么條件下可以緩存響應以及可以緩存多久。
no-cache: no-cache表示必須先與服務器確認返回的響應是否發(fā)生了變化,然后才能使用該響應來滿足后續(xù)對同一網址的請求(每次都會根據ETag對服務器發(fā)送請求來確認變化,如果未發(fā)生變化,瀏覽器不會下載資源)。
no-store: no-store直接禁止瀏覽器以及所有中間緩存存儲任何版本的返回響應。簡單的說,該策略會禁止任何緩存,每次發(fā)送請求時,都會完整地下載服務器的響應。
public&private: 如果響應被標記為public,則即使它有關聯的
HTTP
身份驗證,甚至響應狀態(tài)代碼通常無法緩存,瀏覽器也可以緩存響應。如果響應被標記為private,那么這個響應通常只為單個用戶緩存,因此不允許任何中間緩存(CDN)對其進行緩存,private一般用在緩存用戶私人信息頁面。max-age: max-age定義了從請求時間開始,緩存的最長時間,單位為秒。
資源預加載
Pre-fetching
是一種提示瀏覽器預先加載用戶之后可能會使用到的資源的方法。
使用dns-prefetch
來提前進行DNS
解析,以便之后可以快速地訪問另一個主機名(瀏覽器會在加載網頁時對網頁中的域名進行解析緩存,這樣你在之后的訪問時無需進行額外的DNS解析,減少了用戶等待時間,提高了頁面加載速度)。
<link rel="dns-prefetch" href="other.hostname.com">
使用prefetch
屬性可以預先下載資源,不過它的優(yōu)先級是最低的。
<link rel="prefetch" href="/some_other_resource.jpeg">
Chrome
允許使用subresource
屬性指定優(yōu)先級最高的下載資源(當所有屬性為subresource
的資源下載完完畢后,才會開始下載屬性為prefetch
的資源)。
<link rel="subresource" href="/some_other_resource.js">
prerender
可以預先渲染好頁面并隱藏起來,之后打開這個頁面會跳過渲染階段直接呈現在用戶面前(推薦對用戶接下來必須訪問的頁面進行預渲染,否則得不償失)。
<link rel="prerender" >