- 瀏覽器加載網頁資源的原理
- JS與CSS阻塞
- 重排與重繪
一、瀏覽器加載網頁資源的原理
1、HTML支持的組要資源類型
在瀏覽器內核有一個管理資源的對象CachedResource類,在CachedResource類下有很多子類來分工不同的資源管理,這些資源管理子類分別是:
資源 | 資源管理類 |
HTML | MainResource ===> CachedRawResource |
JavaScript | CachedScript |
CSS | CachedCSStyleSheet |
圖片 | CachedImage |
SVG | CachedSVGDocument |
CSS Shader | CachedShader |
視頻、音頻、字幕 | CachedTextTrack |
字體文件 | CachedFont |
XSL樣式表 | CachedXSLStyleSheet |
2、資源緩存
資源的緩存機制是提高資源使用效率的有效方法。基本思想就是建立一個資源緩存池,當web需要請求資源時,會先從資源池中查找是否存在相應的資源,如果有的話就直接取緩存,如果沒有就創建一個新的CachedResource子類的對象,並發送請求給服務器(由網絡模塊完成),請求回來的資源會被添加到資源池,並且將資源(數據信息:比如在資源池中的物理地址)設置到該資源的對象中去,以便下次使用。
下面是一個縮減版的資源請求原理圖:
實質上的操作是在資源對象中找到對應資源的物理地址(url),然后返回給渲染引擎,渲染引擎在渲染頁面時根據url獲取物理內存中的資源數據。由於資源的唯一特性是url,所以當兩個資源有不同的url,但是他們的內容完全相同時,也不會被認定是同一個資源。
注:這里所說的緩存是內存,不是磁盤。
3、資源加載器
在WebKit中共有三種類型的資源加載器,分別是:
3.1針對每種資源類型的特定加載器,用來加載某一類資源。例如“image”這個元素,該元素需要圖片資源,對應的頂資源加載器是ImageLoader類。
3.2資源緩存機制的資源加載器,特點是所有特定加載器都共享它來查找並插入緩存資源——CachedResourceLoader類。特定加載器是通過緩存機制的資源加載器來查找是否有緩存資源,它屬於HTML的文檔對象。
3.3通用的資源加載器——ResourceLoader類,是在WebKit需要從網絡或者文件系統獲取資源的時候使用該類只負責獲得資源的數據,因此被所有特定資源加載器所共享,它屬於CachedResource類,與CachedResourceLoader類沒有繼承關系。
如果說資源緩存和網絡資源是瀏覽器要渲染頁面的資源實體,那資源加載器就是為瀏覽器實現頁面渲染提供資源數據的搬運工。前面的資源請求相當於就是資源地址尋址的過程,真正為渲染提供資源的過程是下面這樣的:
這個資源加載看起來很復雜,但是模塊分工很明確,基於資源對象與內存資源緩存的對應關系(每個緩存資源在資源對象上有一個實例),當瀏覽器觸發資源請求時先通過判斷資源是否有緩存資源,如果有的話就就直接拿緩存資源給渲染引擎,如果沒有就通過網絡請求獲取資源給渲染引擎,並且同時會將資源緩存到內存中。
同CachedResourceLoader對象一樣,資源池也屬於HTML文檔對象,所以資源池不能無限大,對於資源容量不能無限大的問題瀏覽器的解決方法有兩種:第一種是采用LRU(Least Recent Rsed最近最少使用原則)算法。第二種方法是通過HTTP協議決定是否緩存,緩存多久,以及什么時候更新緩存,然后我們開發時還可決定資源如何拆分,拆分可以讓我決定哪些資源緩存,哪些資源不緩存。
當請求協議指定可以取緩存數據,請求資源會先判斷內存中是否有資源,然后將資源的信息(版本,緩存時常等)通過HTTP報文一起發送給服務器,服務器通過報文判斷緩存的資源是否是最新的,資源緩存是否超時來決定是否重新獲取服務端的資源,如果不需要重新獲取服務端的資源,服務器會返回狀態碼304,告訴瀏覽器取本地緩存資源。
下面通過Chrome瀏覽器來請求餓了嗎官網,在控制台查看數據請求的資源加載過程,並且通過刷新頁面查看當頁面刷新時瀏覽器在緩存中取了哪些信息:
接着我們再來刷新頁面看看取了哪些緩存數據:
可以看到餓了嗎官網的緩存機制是將document主文件和js文件做了緩存處理。這樣的處理方式可以很大程度上提高頁面性能和降低服務器請求壓力,至於為什么就是接下來的內容了。
二、解析HTML標簽和CSS樣式表、生成DOMTree和CSSTree
前面介紹了瀏覽器資源請求與資源加載的基本原理,看上去好像是一個簡單的線性步驟,但是實質上瀏覽器內部是多進程異步加載這些資源的,我們知道網頁的效果是基於DOM結構和CSS樣式表來完成基本的頁面效果呈現,但是JS代碼又可以對DOM節點進行增刪該查操作,還可以修改DOM的CSS樣式,那必然就是需要先有DOM結構,然后添加CSS樣式,再就這兩個資源的基礎通過JS修改后才能呈現出來,但是什么時候加載(指的是下載資源,並不是前面的資源加載到頁面上的整個過程)?什么時候執行?什么時候渲染頁面?按照什么規則來完成這些工作呢。
通常我們給某個服務器發送一個web請求時,首先返回的是一個HTML資源。假設這個資源的內部代碼如下:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title></title> <link rel="stylesheet" type="text/css" href=".../css/xxx.css"> </head> <body> <div> <p> <span></span> </p> <ul> <li><img src=".../image/xxx.png" alt=""></li> <li><img src=".../image/xxx.png" alt=""></li> <li><img src=".../image/xxx.png" alt=""></li> </ul> </div> <script src=".../javascripts/xxx.js" type="text/javascript"></script> </body> </html>
本地獲取到了HTML資源后第一步就是解析HTML,也就是常說的DOM解析,首先是創建一個document對象,然后通過DOM解析出每個節點。通過DOM解析發現頁面中有css外部樣式表需要加載,就立即通過CSS加載器執行加載。解析到img元素發現需要加載圖片,就立即通過圖片加載器執行加載,這個過程不會等待前面加載的資源加載完成才啟動第二個加載,而是通過異步的方法開啟多個加載線程,並且瀏覽器底層會開啟多個進程來處理這些線程(Chrome會開啟五個進程)。同樣解析到了script元素時發現需要外部js資源會立即加載js文件資源。
深度優先原則解析構建DOM樹和CSS樹:
深度優先原則就是對每一個結構順着第一個內部節點一直往內部解析,直到結構盡頭,然后再回退到上一個節點,再對第二個節點執行深入優先原則的解析構建。下圖是上面示例請求到的HTML資源的解析流程圖:
按照示例HTML解析流程圖,根據編號順序按照1-->1.1-->1.2-->1.3-->1.4-->2-->2.1-->2.1.1-->2.1.1.1-->2.1.2-->2.1.2.-->2.1.2.1-->2.1.2.2-->2.1.2.3-->2.2。用一句來表達這種解析原則就是一條道走到黑,開玩笑,但是的確很形象哈。CSS樣式表解析和構建CSS樹也同樣使用這個原則。當DOMTree和CSSTree都構建完成以后就會被合並成渲染樹(randerTree)。渲染樹解析完畢以后就開始繪制頁面。
三、JS與CSS阻塞
了解了DOMTree和CSSTree的構建原理,然后合成randerTree繪制頁面,但是這個過程怎么能缺少JS呢?有了JS的參與,這個過程就會變得復雜了。首先,CSS資源是異步加載(下載),在CSS資源加載的過程中,DOM解析會繼續執行操作。但是當遇到script標簽的時候,如果是外部資源就要立即加載(下載),如果是內部資源就會立即執行JS代碼,立即執行JS代碼會阻斷HTML的解析(因為JS會操作DOM節點增刪改查什么的,還會操作元素樣式),霸道總裁JS就這樣讓傻媳婦HTML傻呆着讓它為所欲為了。就算是外部JS資源加載(下載)的過程HTML的解析也是被阻斷的,這個過程是必須等到JS加載(下載)完,然后還要等他執行完才能繼續解析HTML。
<img class="img1" src="https://img.baidu.com/search/img/baidulogo_clarity_80_29.gif" alt="Baidu" align="bottom" border="0"> <script type="text/javascript"> // 循環5秒鍾 var n =Number(new Date()); var n2 = Number(new Date()); while((n2 - n) < (10*1000)){ n2 = Number(new Date()); } console.log(document.querySelectorAll(".img1"));//NodeList [img.img1] console.log(document.querySelectorAll(".img2"));//NodeList [] </script> <img class="img2" src="https://gss1.bdstatic.com/9vo3dSag_xI4khGkpoWK1HF6hhy/baike/w%3D268%3Bg%3D0/sign=7aa2c00bdd58ccbf1bbcb23c21e3db03/908fa0ec08fa513defeb0567316d55fbb3fbd9c2.jpg"> <script> var n3 = Number(new Date() - n2); console.log(n3);//13 console.log(document.querySelectorAll(".img1"));//NodeList [img.img1] console.log(document.querySelectorAll(".img2"));//NodeList [img.img2] </script>
由上面的示例可以說明js執行會阻塞DOMTree構建,不然在JS等待的10秒里足夠解析一個img元素,但是10秒后只能查詢到img1,img2查詢不到(打印空DOM節點對象)。當第二次打印的時候兩個img節點就都獲取到了。接着我們來看看外部JS加載會不會阻塞DOMTree構建:
<script> var n =Number(new Date()); </script> <!-- 設置網速30kb/s測試js是否阻塞渲染 --> <script src="https://cdn.staticfile.org//vue/2.2.2//vue.min.js"></script> <script> var n3 = Number(new Date() - n); console.log(n3);//30~40秒 ---- 注釋外部js加載代碼測試時間差為0秒 </script>
測試結果是外部JS的加載也會阻塞HTML解析構建DOMTree。所以結論是JS的加載和執行都會阻塞DOMTree的構建,接着問題又來了,我們前面提到過JS代碼會操作DOM還會操作CSS,所以從理論上講JS肯定得需要等到CSS加載解析完才會執行,CSS阻塞JS執行是肯定的,再思考CSS的加載(下載)會阻塞JS的加載(下載)嗎?
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Title</title> <link type="text/css" rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" /> <script src="https://cdn.staticfile.org//vue/2.2.2//vue.min.js" type="text/javascript" charset="utf-8" async defer></script> </head> <body> </body> </html>
我們來看Chrome控制台的時間線:
由Chrome控制台的時間線可以看到外部JS和外部CSS幾乎是同時開始加載,CSS加載並沒有阻塞JS的加載。既然這樣我們再來測試以下CSS加載阻塞JS執行是否是真的?
<script> var n = Number(new Date()); </script> <link type="text/css" rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" /> <script> console.log(Number(new Date()) - n);//外部CSS阻塞JS執行40~200毫秒 --- 注釋外部CSS代碼測試差值0~1毫秒 </script>
可能有人會疑惑我為什么不測試外部CSS會不會阻塞HTML解析,你想想如果CSS阻塞HTML解析那JS加載必須會被阻塞吧,所以CSS加載也就不會阻塞HTML解析了。但是,CSS會阻塞JS執行,也就間接的阻塞了JS后面的DOM解析。
其實相對來說JS與CSS阻塞還是比較好理解的,畢竟還有可參考的數值和可視的圖像信息,接下來的問題就只能依靠邏輯推理了。
四、JS時間線
在闡述JS時間線之前,我另外總結了一部分非常重要的內容:JS的異步加載(JS異步加載的三種方案),JS異步加載與下面的內容相關聯的內容比較多,建議在了解下面內容之前先了解一下JS異步加載。
在前面的內容中解析了訪問網站獲取資源的基本原理,然后資源被訪問到本地后怎么解析,解析時發什么的異步資源加載,同步資源加載,同步執行等一系列內容。然后在JS異步加載中提到了script.onload事件、script.onreadystatechange事件、script.readyState狀態,然后還有document.readyState="interactive"文檔狀態和docuement.readyState="complete"文檔狀態。這些內容都發生在打開網頁的那一瞬間,但是這一瞬間不只是檢驗物理配置的性能、瀏覽器內核的性能以及網絡的性能,還關系到web開發者基於這些已定的基礎平台的代碼優化,所以我們有必要對這整個過程有非常清晰的理解,才能實現友好的程序設計。下面我們就通過JS時間線來描述這個過程如何發生的:
頁面加載的五個步驟和JS時間線的十個環節:
五個步驟:
- 解析HTML生成DOMTree
- 解釋CSS樣式表生成CSSTree
- 合並DOMTree和CSSTree生成randerTree
- randerTree生成完以后開始繪制頁面
- 瀏覽器在解析頁面時同時下載頁面內容內容數據(異步:圖片,src)
JS時間線之十個環節:
要說是JS時間線的話,可能不是很恰當,或者應該說是文檔模型初始化構建過程的JS表示,能夠操作DOM對象接口的語言有很多,這里就是用JS來表示DOM對象模型初始化的整個過程。
- 1.創建document對象,開始解析web頁面。解析HTML原始和他們的文件內容添加Element對象和Text節點到文檔中。階段:document.readyState = "loading"。(表示可以觸發一次document.onreadystatechange事件)
- 2.遇到link外部css,創建線程加載,並繼續解析文檔。
- 3.遇到script外部JS,並沒有設置async、defer,瀏覽器加載,並阻塞,等待JS加載完成並執行腳本,完后繼續解析文檔。
- 4.遇到script外部JS,並且設置async、defer,瀏覽器創建線程加載,並繼續解析文檔。
- 5.遇到img等外部內容數據,先正常解析DOM結構,然后瀏覽器異步加載src,並且繼續解析文檔。
- 6.當文檔解析完成后。document.readyState = "interactive"。(表示可以觸發一次document.onreadystatechange事件)
- 7.文檔解析完成后,所有設置有defer的腳本會按照順序執行。(禁止使用document.wrlte())。
- 8.document對象觸發DOMContentLoaded事件,這也標志着程序執行從同步執行階段,轉化為事件取動階段。(這里開始繪制頁面)
- 9.當所有async的腳本加載完成,img等加載完成后,document.readyState = "complete",window對象觸發事件。(表示可以觸發一次document.onreadystatechange事件或者標准瀏覽器可以觸發window.onload事件了)
- 10.從此,以異步響應方式處理用戶輸入、網絡事件等。
//readyState屬性返回當前文檔的狀態
uninitialized - 還未開始載入
loading - 載入中
interactive - 已加載,文檔與用戶可以開始交互
complete - 載入完成--loaded
五、重排/回流與重繪
關於重排/回流(reflow)重繪(repaint)簡單來說就是會將已經計算好的布局和構建好的渲染樹(randerTree)重新計算和構建全部或者部分。這部分發生在DOMTree和CSSTree解析完成以后,也就是會發生在構建randerTree時和之后,這里我們重點關注發生在randerTree構建時的重排/回流和重繪問題,也是網頁渲染除了JS、CSS阻塞之后的性能優化區間。
發生重排/回流與重繪其本質上重新布局和構建randerTree,如果將DOM之前的執行過程理解為同步,這個時候瀏覽器轉為事件取動的異步階段,瀏覽器內核在構建randerTree的同時JS也會被事件取動參與修改文檔的結構和樣式,也是觸發重排/回流與重繪行為的關鍵所在,而本質上做的事情就是重新計算布局和構建randerTree樹,所以在解析重排與重繪之前先來了解以下布局計算和randerTree構建:
布局
在構建randerTree時並不會把CSS樣式表或者行內樣式表示元素大小和位置的數據添加到RanderObject上,而是要基於樣式設置(如):width、height、font-size、display、left、top、bottun、right還有borde、padding、margin的大小,結合上下文的相互作用(比如有子元素自適應父級元素大小和位置或者父元素基於子元素定義自身大小和位置),最后使用RanderObject上的layout()方法計算出確定的元素大小和位置,這個過程layout()方法是遞歸完成整個計算操作。
因為布局計算需要基於元素上下節點來進行,元素的大小和位置變化都有可能會影響到父級和子級的元素大小和位置變化,所以randerTree上的某個RanderObject的相關數據發生變化除了自身的layout()方法需要重新執行計算,還可能會觸發上下級的節點的layout()方法的重新執行計算。
所以當構建randerTree的時候由document.onreadystatechange事件、defer的腳本、DOMContentLoaded事件還有不確定的src異步加載的JS腳本都可能在這時候修改元素的大小和位置,甚至修改DOM結構。
除了腳本的影響外,還有可能是瀏覽器窗口發生產生變化導致全局的randerTree重新布局計算,另外如果腳本修改了全局的樣式也同樣可能會觸發全局的重新布局計算。
重排/回流(reflow)
有了前面對布局的介紹,重排/回流就一目了然了,當由於腳本執行或者瀏覽器窗口變化,引發RanderObject上的layout()方法重新計算機布局數據,就叫做重排/回流。從字面上的含義來理解重排很容易,就是由於元素的大小和位置變化頁面重新排列布局。回流就存在一些邏輯上的理解了,在布局中因為元素節點的位置和大小是存在上下級和同級之間相互影響的,所以如果有腳本修改DOM節點或者大小位置樣式,就會對相關連的元素進行判斷查找修改的范圍指定修改邏輯,制定layout()方法的遞歸順序的最優方案,這個查詢判斷和修改過程就是需要在節點之間來回操作,這也就是回流。實質上重排/回流說的都是一回事。
重繪(repaint)
重繪不會影響布局,但是當腳本觸發了樣式修改,而修改的部分是背景(圖片和顏色)、字體顏色、邊框顏色等,而這些修改也存在嵌套的節點鏈級相互影響,所以也是需要遍歷操作,重繪不至於影響到布局,但也是一個相對損耗性能的操作,畢竟都需要DOM文檔和JS引擎結構之間的橋梁通道來執行操作。不過重繪相對於重排來說就要快的多了。
重排/回流與重繪是會發生在randerTree構造時,也會發生在randerTree構造結束后,都是相對損耗CPU甚至GPU的操作,只是頁面首次渲染更值得的我們關注。
繪制(paint)
當randerTree構建完成以后就會開始繪制頁面了,在繪制頁面過程中仍然可能發生重排與重繪,但這里需要重點關注的是圖層合並,繪制主要是基於CPU的計算來實現,同時瀏覽器基本上都采用GPU加速的混合模式,其實瀏覽器本身不需要操作圖層合並,因為繪圖不管是CPU還是GPU來實現都是基於元素的大小和位置將它們實現的圖層,圖們本身就在同一個位置,所以無需合並操作。
CPU主要負責randerTree的繪制工作,它與GPU的配合在不同瀏覽器內核中會略微不同,但是在同一個位置出現的圖層越多,肯定是對性能的損耗就越大。而且由於CPU主要負責randerTree的繪制,多圖層就會對GPU帶來很大的工作負載,具體包括:CSS3 3D變形、CSS3 3D 變換、WebGL 和 視頻。也有浮動,定位,溢出隱藏,z坐標重疊等都是在繪制過程中比較損耗性能的行為。
最后經過這樣艱難的過程過后,網頁終於呈現在我們桌面,但是注意window事件交互不會等待繪制完成,決定window事件交互的是資源是否全部加載完成,這里指的資源是HTML文檔包含內容資源,並不包含外部腳本加載的資源。
(減少重排與重繪的一些要點)

1 1:不要通過父級來改變子元素樣式,最好直接改變子元素樣式,改變子元素樣式盡可能不要影響父元素和兄弟元素的大小和尺寸 2 2:盡量通過class來設計元素樣式,切忌用style 3 3:實現元素的動畫,對於經常要進行回流的組件,要抽離出來,它的position屬性應當設為fixed或absolute 4 4:權衡速度的平滑。比如實現一個動畫,以1個像素為單位移動這樣最平滑,但reflow就會過於頻繁,CPU很快就會被完全占用。如果以3個像素為單位移動就會好很多。 5 5:不要用tables布局的另一個原因就是tables中某個元素一旦觸發reflow就會導致table里所有的其它元素reflow。在適合用table的場合,可以設置table-layout為auto或fixed, 6 6:這樣可以讓table一行一行的渲染,這種做法也是為了限制reflow的影響范圍。 7 7:css里不要有表達式expression 8 8:減少不必要的 DOM 層級(DOM depth)。改變 DOM 樹中的一級會導致所有層級的改變,上至根部,下至被改變節點的子節點。這導致大量時間耗費在執行 reflow 上面。 9 9:避免不必要的復雜的 CSS 選擇器,尤其是后代選擇器(descendant selectors),因為為了匹配選擇器將耗費更多的 CPU。 10 10: 盡量不要過多的頻繁的去增加,修改,刪除元素,因為這可能會頻繁的導致頁面reflow,可以先把該dom節點抽離到內存中進行復雜的操作然后再display到頁面上。 11 11:請求如下值offsetTop, offsetLeft, offsetWidth, offsetHeight,scrollTop/Left/Width/Height,clientTop/Left/Width/Height,瀏覽器會發生reflow,建議將他們合並到一起操作,可以減少回流的次數。