一、瀏覽器如何渲染網頁
要了解瀏覽器渲染頁面的過程,首先得知道一個名詞——關鍵路徑渲染。關鍵渲染路徑(Critical Rendering Path)是指與當前用戶操作有關的內容。例如用戶在瀏覽器中打開一個頁面,其中頁面所顯示的東西就是當前用戶操作相關的內容,也就是瀏覽器從服務器那收到的HTML,CSS,JavaScript等相關資源,然后經過一系列處理后渲染出來web頁面。實際抽象出來理解可以將這些步驟看作一個函數,就輸入HTML,經過一層層的處理,最后輸出像素。
而瀏覽器渲染的過程主要包括以下幾步:
- 瀏覽器將獲取的HTML文檔并解析成DOM樹。
- 將 css 文件處理成 StyleSheet 對象,從而進行樣式計算。
- 根據dom樹和StyleSheet 生成布局樹。
- 根據具體的節點信息對頁面進行分層處理,生成圖層樹
- 根據圖層樹生成繪制列表
- 合成線程通過主線程提交的繪制列表對圖層進行分塊,并進行柵格化,生成位圖
- 合成位圖,并將其顯示
具體如下圖過程如下圖所示:
需要注意的是,以上幾個步驟并不一定是一次性順序完成,比如 DOM 被修改時,亦或是哪個過程會重復執行,這樣才能計算出哪些像素需要在屏幕上進行重新渲染。而在實際情況中,JavaScript和CSS的某些操作往往會多次修改DOM或者CSSOM。
值得注意的的是,在每個階段,都會有對應的輸入,處理,以及輸出。下面我們就來詳細的了解一下這幾個過程及需要注意的事項。
二、瀏覽器渲染網頁的具體流程
2.1 構建DOM樹
因為瀏覽器無法直接使用HTML/SVG/XHTML,因此當瀏覽器客戶端從服務器那接受到HTML文檔后,就會遍歷文檔節點,然后對這些文檔節點通過HTML解析器進行解析,最后生成DOM樹,所生成的 DOM 樹結構和HTML標簽一一對應。需要注意的是,在這其中HTML解析器會進行諸如:標記化算法,樹構建算法等操作,其中的規范即遵循了W3C的相應規范,也都有瀏覽器引擎自己的一些特定的操作,詳情可以翻閱這篇非常著名的文章:
在此階段,輸入的即是一個HTML文件,然后會有瀏覽器的HTML解析器對其進行解析,輸出樹形結構的DOM樹。值得注意的是,HTML解析器并不是等整個文檔全部加載完之后才開始解析的,而是網絡進程加載了多少數據,HTML解析器就會解析多少數據。相當與在網絡進程與渲染進程之間會在這期間建立一個數據共享的管道,網絡進程每次收到數據都會將其轉發到渲染進程,從而保證渲染進程中的HTML解析器可以源源不斷的獲取到用于渲染的數據。這個過程可以理解為下方這個過程:
[圖片上傳失敗...(image-79abab-1574600053183)]
- 將字節流通過分詞器轉化為 Token
- 根據 Token 生成節點 node
- 根據生成的節點,組成 DOM 樹
每個頁面的DOM樹,我們也可以直接通過在控制臺輸入document 來進行訪問:
對于DOM樹,我們需要注意以下幾點:
- DOM 樹從內容上來看和 HTML 幾乎一模一樣,但 DOM 是保存在內存中的樹形結構,可以通過 JavaScript 來查詢和修改。
<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">document.getElementsByTagName("h2")[0].innerText = "Hello World"</pre>
- display:none 的元素也會在 DOM 樹中。
- 注釋也會在 DOM 樹中
- script 標簽會在 DOM 樹中
- DOM 樹在構建的過程中可能會被 CSS 和 JS 的加載而執行阻塞。
此外DOM 樹在構建的過程中可能會被 CSS 和 JS 的加載而執行阻塞,也就是我們常說的阻塞渲染。這是因為HTML文件是通過HTML解析器轉化成 DOM 樹的,而在HTML解析器中如果遇到了 JavaScript 腳本,HTML 解析器會先執行 JavaScript 腳本,待這個腳本執行完成之后,再繼續往下解析。因此我們常說,將script標簽放在body下面,通常就是基于這種考慮的。但為什么CSS也有可能會阻塞DOM樹的構建呢,可以看下面一個栗子:
<pre class="cm-s-default" style="color: rgb(89, 89, 89); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);"><html> <head> <style type="text/css" src = "demo.css" /> </head> <body> <p>demo</p> <script> const p = document.getElementsByTagName('p')[0] p.innerText = 'hello world' p.style.color = 'red' </script> </body> </html></pre>
由于任何script代碼都能改變HTML的結構,因此HTML每次遇到script都會停止解析,等待JavaScript腳本被執行完成之后,再進行接下來的解析,而當我們通過 JavaScript 去進行樣式操作的時候,這個 JavaScript 腳本執行完成的前提條件就成了需要現將樣式信息確定下來。因此在這種情況下,HTML解析器能否繼續執行下去,以及繼續執行的時間,也需要取決與這個CSS文件給不給面子了。這也是我們常說的,別在 JavaScript 中操作樣式的原因。
為了優化這種情況,現代瀏覽器也做了一些優化,比如預解析操作。當渲染引擎接收到字節流后,會開啟一個預解析線程,用來分析 HTML文件的代碼中的JS,CSS文件,解析到相關文件的時候,預解析進行會提前下載這些資源。
對于處理這種事情,避免阻塞的產生,我們也有以下幾點可以注意的:
- 在引入順序上,CSS 資源先于 JavaScript 資源。
- JavaScript 應盡量少的去影響 DOM 的構建。
- 可以將 JavaScript 腳本設置為異步加載,通過 async 或 defer 來標記代碼
2.2 計算樣式
在構建渲染樹時,需要計算每一個呈現對象的可視化的屬性值。而這個過程就被稱為樣式計算或者計算樣式。這個過程主要是為了 DOM 樹中每個節點的具體樣式,大致可分為三大步驟:
- 將 CSS 解析為瀏覽器能理解的 StyleSheet
- 轉換樣式表中的屬性值,使其標準化
- 計算出 DOM 樹中每個節點的具體樣式
2.2.1 將 CSS 解析為瀏覽器能理解的 styleSheet
和html一個道理,瀏覽器也無法直接去理解我們所寫的那些CSS樣式,因此瀏覽器在接收到CSS文件后,會將CSS文件轉換為瀏覽器所能理解的 StyleSheet。轉化了的 StyleSheet 我們同樣也可以通過控制臺來訪問:
在這個過程中需要注意的是:
- CSS解析可以與DOM解析同時進行。
- CSS解析與 script 的執行互斥 。
- 在Webkit內核中進行了script執行優化,只有在JS訪問CSS時才會發生互斥。
- CSS樣式不管是來自于 link 的外部引用,還是style標記內的CSS,亦或是元素的style屬性內嵌的CSS,都會被解析成styleSheets。
2.2.2 轉換樣式表中的屬性值,使其標準化
在將CSS文轉化為瀏覽器能夠理解的 styleSheet 后,就需要對期進行進行屬性值的標準化操作了。這里的標準化的意思就是,我們在寫css文件的時候,會寫一些語義化的屬性比如:red/bold等等。但其實這些詞對于渲染引擎來說,卻不是那么好理解的。因此在進行計算樣式之前,瀏覽器還會這對這些不怎么好計算的值進行標準化,將其轉化為渲染引擎容易理解的詞,比如將red轉化成為 rgb(255, 0, 0)等等。
2.2.3 計算出 DOM 樹中每個節點的具體樣式
計算出 DOM 樹中每個節點的具體樣式主要涉及的就是CSS繼承規則和層疊規則了,對于繼承規則其實比較好理解,就是,每個DOM節點都包含的父節點的樣式。
而層疊規則也就是樣式層疊就有點麻煩了,MDN是這么描述層疊的:
層疊是CSS的一個基本特征,它是一個定義了如何合并來自多個源的屬性值的算法。它在CSS處于核心地位,CSS的全稱層疊樣式表正是強調了這一點。
層疊的具體細節在這里也不展開講了(我自己現在還沒搞清楚。。。),大家可以去CSS層疊看看其內部的一些規則。
在有了css繼承規則和層疊規則后,樣式計算的這個階段就會在這兩個規則的基礎上對 DOM 節點中的每個元素計算處具體的樣式,這個階段中最終輸出的結果會保存在 ComputedStyle 中,這個同樣可以通過控制臺進行查看:
2.3 布局階段
通過前面兩個階段,我們已經得到了DOM樹以及DOM樹中具體每個元素的樣式了,但對于每個元素所處的幾何位置我們現在還是不知道的,因此接下來要做的就是計算出DOM樹中可見元素的幾何位置。這個過程可以分為兩個階段:
- 創建布局樹
- 布局計算
2.3.1 創建布局樹
由于DOM樹還包含很多不可見的元素,比如head標簽,script標簽,以及設置為display:none的屬性,因為瀏覽器勢必不能將所有的dom樹的元素都全部拿來進行布局計算,因此在這個階段,瀏覽器會額外構建一顆只包含可見元素的布局樹。在構建布局樹期間,瀏覽器大體會進行以下一些工作:
- 遍歷DOM樹中的所有可見節點,并將這些節點加到布局中。
- 將所有不可見節點忽略掉
下面兩個需要注意:
- display: none的元素不在Render Tree中
- visibility: hidden的元素在Render Tree中
2.3.2 布局計算
在已經獲取了所有可見元素的樹之后,就可以計算布局樹節點的幾何位置了。HTML是基于流的布局方式,因此大多數情況下,只需要進行一次遍歷即刻計算出頁面的幾何信息。通常來說,處于流靠后的元素不會影響到靠前位置元素的幾何特征,因此在進行布局計算的時候,通常是按從左至右,從上至下的順序遍歷文檔(只是通常而言,比如表格啥的就不是這樣)。
布局計算是一個遞歸的過程,它從根節點出發,然后遞歸遍歷部分或所有的節點,為每一個需要計算的呈現器計算幾何信息。這個計算量無疑是龐大的,因此為了避免一些較小的更改也會觸發頁面的整體布局計算,瀏覽器將布局方式分為了全局布局和增量布局。
- 全局布局:全局布局是指觸發了整個布局樹的布局計算的布局,包括:屏幕大小改動,字體大小改動等
- 增量布局:增量布局是指當某個呈現器發生改變了,只對相應的呈現器進行布局計算。
在執行完布局計算后,會將布局計算的結果寫入布局樹中,因此這個過程可以理解為一種裝飾者模式,輸入輸出都是一個布局樹,只是在這個過程中會將布局計算的結果給加進去。
2.4 分層
在有了布局樹之后,瀏覽器的還是不能直接根據布局樹來將頁面給畫出來,因為頁面中還存在中一些特殊的效果,比如頁面滾動,z-index等。為了能夠方便的實現這些花里胡哨的功能,渲染引擎還需要進行一個分層處理,將特定節點生成轉筒的圖層,并生成一個圖層樹(LayerTree),這個我們也能通過瀏覽器的面板看到:
如上圖所示,瀏覽器的頁面實際上被分成了多個圖層,這些圖層疊加在一起就形成了我們最終所看到的頁面。需要注意的是,并不是布局樹中的每一個節點都會包含一個圖層,因此如果一個節點沒有所對應的圖層,那么它就會從屬于父節點的圖層。如果一個節點需要有自己的圖層,通常需要滿足以下聯合條件
- 擁有層疊上下文屬性的元素
- 需要剪裁(clip)
2.5 圖層繪制
在確定好圖層之后,瀏覽器的渲染引擎會對圖層樹中的每個圖層進行繪制,渲染引擎會將一個圖層的繪制拆封成很多個小的繪制指令,然后會將這些繪制指令按照一定順序組成一個待繪制列表。和布局相同,繪制也分為全局和增量兩種,也是為了避免部分圖層的改變而需要對整個圖層樹進行繪制。此外,CSS也對繪制順序做了規定:
- 背景顏色
- 背景圖片
- 邊框
- 子代
- 輪廓
2.6 柵格化(raster)操作
這里的柵格化是指將圖轉化為位圖。繪制列表只是用來記錄繪制順序和繪制指令的列表,而實際繪制操作是由渲染引擎中的合成線程來完成的。實際過程是當圖層對應的繪制列表準備好之后,主線程會將繪制列表提交給合成線程。 合成線程會根據用戶所能見的窗口范圍對一些劃分,將一些大的圖層化分為圖塊。然后合成線程會根據用戶所見范圍附近的圖塊來優先生成位圖,實際生成位圖的操作是由柵格化來執行的。圖塊是柵格化執行的最小單元,渲染進程維護了一個柵格化的線程池,所有的圖塊柵格化操作都會在這個線程池里進行。
通常,柵格化會使用GPU進程中的GPU來進行加速,使用GPU進程生成位圖的過程叫快速柵格化,通過這個方式生成的位圖會被保存在GPU內存中。這樣做的好處就在于,當渲染進程的主線程發生阻塞的時候,合成線程以及GPU進程不會受其影響,可以正常運行。這也是為啥有時候主線程卡住了,但CSS動畫依然可以風騷依舊的原因。
2.7 合成和顯示
在所有的圖塊都被進行柵格化后,合成線程就會生成繪制圖塊的命令——“DrawQuad”,然后將該命令提交給瀏覽器進程。瀏覽器進程里面有一個叫 viz 的組件,用來接收合成線程發過來的 DrawQuad 命令,然后根據 DrawQuad 命令,將其頁面內容繪制到內存中,最后再將內存顯示在屏幕上。
三、瀏覽器渲染網頁的那些事兒
3.1 回流和重繪(reflow和repaint)
我們都知道HTML默認是流式布局的,但CSS和JS會打破這種布局,改變DOM的外觀樣式以及大小和位置。因此我們就需要知道兩個概念:
- reflow(回流):當瀏覽器發現某個部分發生了變化從而影響了布局,這個時候就需要倒回去重新渲染,大家稱這個回退的過程叫 reflow。 常見的reflow是一些會影響頁面布局的操作,諸如Tab,隱藏等。reflow 會從 html 這個 root frame 開始遞歸往下,依次計算所有的結點幾何尺寸和位置,以確認是渲染樹的一部分發生變化還是整個渲染樹。reflow幾乎是無法避免的,因為只要用戶進行交互操作,就勢必會發生頁面的一部分的重新渲染,且通常我們也無法預估瀏覽器到底會reflow哪一部分的代碼,因為他們會相互影響。
- repaint(重繪): repaint則是當我們改變某個元素的背景色、文字顏色、邊框顏色等等不影響它周圍或內部布局的屬性時,屏幕的一部分要重畫,但是元素的幾何尺寸和位置沒有發生改變。
需要注意的是,display:none 會觸發 reflow,而visibility: hidden屬性則并不算是不可見屬性,它的語義是隱藏元素,但元素仍然占據著布局空間,它會被渲染成一個空框,這在我們上面有提到過。所以visibility:hidden 只會觸發 repaint,因為沒有發生位置變化。
我們不能避免reflow,但還是能通過一些操作來減少回流:
- 用transform做形變和位移.
- 通過絕對位移來脫離當前層疊上下文,形成新的Render Layer。
另外有些情況下,比如修改了元素的樣式,瀏覽器并不會立刻reflow 或 repaint 一次,而是會把這樣的操作積攢一批,然后做一次 reflow,這又叫異步 reflow 或增量異步 reflow。但是在有些情況下,比如resize 窗口,改變了頁面默認的字體等。對于這些操作,瀏覽器會馬上進行 reflow。
3.2 幾條關于優化渲染效率的建議
結合上文和我看到的一些文章,有以下幾點可以優化渲染效率
- 合法地去書寫 HTML 和 CSS ,且不要忘了文檔編碼類型。
- 樣式文件應當在 head 標簽中,而腳本文件在 body 結束前,這樣可以防止阻塞的方式。
- 簡化并優化CSS選擇器,盡量將嵌套層減少到最小。
- 盡量減少在 JavaScript 中進行DOM操作。
- 修改元素樣式時,更改其class屬性是性能最高的方法。
- 盡量用 transform 來做形變和位移
參考資料:
https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/