瀏覽器載入解析渲染網頁原理
- 瀏覽器載入網頁資源的原理
- 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,建議將他們合併到一起操作,可以減少迴流的次數。 View Code