一、瀏覽器如何渲染網(wǎng)頁
要了解瀏覽器渲染頁面的過程,首先得知道一個(gè)名詞——關(guān)鍵路徑渲染。關(guān)鍵渲染路徑(Critical Rendering Path)是指與當(dāng)前用戶操作有關(guān)的內(nèi)容。例如用戶在瀏覽器中打開一個(gè)頁面,其中頁面所顯示的東西就是當(dāng)前用戶操作相關(guān)的內(nèi)容,也就是瀏覽器從服務(wù)器那收到的HTML,CSS,JavaScript等相關(guān)資源,然后經(jīng)過一系列處理后渲染出來web頁面。實(shí)際抽象出來理解可以將這些步驟看作一個(gè)函數(shù),就輸入HTML,經(jīng)過一層層的處理,最后輸出像素。
而瀏覽器渲染的過程主要包括以下幾步:
- 瀏覽器將獲取的HTML文檔并解析成DOM樹。
- 將 css 文件處理成 StyleSheet 對象,從而進(jìn)行樣式計(jì)算。
- 根據(jù)dom樹和StyleSheet 生成布局樹。
- 根據(jù)具體的節(jié)點(diǎn)信息對頁面進(jìn)行分層處理,生成圖層樹
- 根據(jù)圖層樹生成繪制列表
- 合成線程通過主線程提交的繪制列表對圖層進(jìn)行分塊,并進(jìn)行柵格化,生成位圖
- 合成位圖,并將其顯示
具體如下圖過程如下圖所示:

需要注意的是,以上幾個(gè)步驟并不一定是一次性順序完成,比如 DOM 被修改時(shí),亦或是哪個(gè)過程會重復(fù)執(zhí)行,這樣才能計(jì)算出哪些像素需要在屏幕上進(jìn)行重新渲染。而在實(shí)際情況中,JavaScript和CSS的某些操作往往會多次修改DOM或者CSSOM。
值得注意的的是,在每個(gè)階段,都會有對應(yīng)的輸入,處理,以及輸出。下面我們就來詳細(xì)的了解一下這幾個(gè)過程及需要注意的事項(xiàng)。
二、瀏覽器渲染網(wǎng)頁的具體流程
2.1 構(gòu)建DOM樹
因?yàn)闉g覽器無法直接使用HTML/SVG/XHTML,因此當(dāng)瀏覽器客戶端從服務(wù)器那接受到HTML文檔后,就會遍歷文檔節(jié)點(diǎn),然后對這些文檔節(jié)點(diǎn)通過HTML解析器進(jìn)行解析,最后生成DOM樹,所生成的 DOM 樹結(jié)構(gòu)和HTML標(biāo)簽一一對應(yīng)。需要注意的是,在這其中HTML解析器會進(jìn)行諸如:標(biāo)記化算法,樹構(gòu)建算法等操作,其中的規(guī)范即遵循了W3C的相應(yīng)規(guī)范,也都有瀏覽器引擎自己的一些特定的操作,詳情可以翻閱這篇非常著名的文章:
在此階段,輸入的即是一個(gè)HTML文件,然后會有瀏覽器的HTML解析器對其進(jìn)行解析,輸出樹形結(jié)構(gòu)的DOM樹。值得注意的是,HTML解析器并不是等整個(gè)文檔全部加載完之后才開始解析的,而是網(wǎng)絡(luò)進(jìn)程加載了多少數(shù)據(jù),HTML解析器就會解析多少數(shù)據(jù)。相當(dāng)與在網(wǎng)絡(luò)進(jìn)程與渲染進(jìn)程之間會在這期間建立一個(gè)數(shù)據(jù)共享的管道,網(wǎng)絡(luò)進(jìn)程每次收到數(shù)據(jù)都會將其轉(zhuǎn)發(fā)到渲染進(jìn)程,從而保證渲染進(jìn)程中的HTML解析器可以源源不斷的獲取到用于渲染的數(shù)據(jù)。這個(gè)過程可以理解為下方這個(gè)過程:
[圖片上傳失敗...(image-79abab-1574600053183)]
- 將字節(jié)流通過分詞器轉(zhuǎn)化為 Token
- 根據(jù) Token 生成節(jié)點(diǎn) node
- 根據(jù)生成的節(jié)點(diǎn),組成 DOM 樹
每個(gè)頁面的DOM樹,我們也可以直接通過在控制臺輸入document 來進(jìn)行訪問:

對于DOM樹,我們需要注意以下幾點(diǎn):
- DOM 樹從內(nèi)容上來看和 HTML 幾乎一模一樣,但 DOM 是保存在內(nèi)存中的樹形結(jié)構(gòu),可以通過 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 標(biāo)簽會在 DOM 樹中
- DOM 樹在構(gòu)建的過程中可能會被 CSS 和 JS 的加載而執(zhí)行阻塞。
此外DOM 樹在構(gòu)建的過程中可能會被 CSS 和 JS 的加載而執(zhí)行阻塞,也就是我們常說的阻塞渲染。這是因?yàn)镠TML文件是通過HTML解析器轉(zhuǎn)化成 DOM 樹的,而在HTML解析器中如果遇到了 JavaScript 腳本,HTML 解析器會先執(zhí)行 JavaScript 腳本,待這個(gè)腳本執(zhí)行完成之后,再繼續(xù)往下解析。因此我們常說,將script標(biāo)簽放在body下面,通常就是基于這種考慮的。但為什么CSS也有可能會阻塞DOM樹的構(gòu)建呢,可以看下面一個(gè)栗子:
<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的結(jié)構(gòu),因此HTML每次遇到script都會停止解析,等待JavaScript腳本被執(zhí)行完成之后,再進(jìn)行接下來的解析,而當(dāng)我們通過 JavaScript 去進(jìn)行樣式操作的時(shí)候,這個(gè) JavaScript 腳本執(zhí)行完成的前提條件就成了需要現(xiàn)將樣式信息確定下來。因此在這種情況下,HTML解析器能否繼續(xù)執(zhí)行下去,以及繼續(xù)執(zhí)行的時(shí)間,也需要取決與這個(gè)CSS文件給不給面子了。這也是我們常說的,別在 JavaScript 中操作樣式的原因。
為了優(yōu)化這種情況,現(xiàn)代瀏覽器也做了一些優(yōu)化,比如預(yù)解析操作。當(dāng)渲染引擎接收到字節(jié)流后,會開啟一個(gè)預(yù)解析線程,用來分析 HTML文件的代碼中的JS,CSS文件,解析到相關(guān)文件的時(shí)候,預(yù)解析進(jìn)行會提前下載這些資源。
對于處理這種事情,避免阻塞的產(chǎn)生,我們也有以下幾點(diǎn)可以注意的:
- 在引入順序上,CSS 資源先于 JavaScript 資源。
- JavaScript 應(yīng)盡量少的去影響 DOM 的構(gòu)建。
- 可以將 JavaScript 腳本設(shè)置為異步加載,通過 async 或 defer 來標(biāo)記代碼
2.2 計(jì)算樣式
在構(gòu)建渲染樹時(shí),需要計(jì)算每一個(gè)呈現(xiàn)對象的可視化的屬性值。而這個(gè)過程就被稱為樣式計(jì)算或者計(jì)算樣式。這個(gè)過程主要是為了 DOM 樹中每個(gè)節(jié)點(diǎn)的具體樣式,大致可分為三大步驟:
- 將 CSS 解析為瀏覽器能理解的 StyleSheet
- 轉(zhuǎn)換樣式表中的屬性值,使其標(biāo)準(zhǔn)化
- 計(jì)算出 DOM 樹中每個(gè)節(jié)點(diǎn)的具體樣式
2.2.1 將 CSS 解析為瀏覽器能理解的 styleSheet
和html一個(gè)道理,瀏覽器也無法直接去理解我們所寫的那些CSS樣式,因此瀏覽器在接收到CSS文件后,會將CSS文件轉(zhuǎn)換為瀏覽器所能理解的 StyleSheet。轉(zhuǎn)化了的 StyleSheet 我們同樣也可以通過控制臺來訪問:

在這個(gè)過程中需要注意的是:
- CSS解析可以與DOM解析同時(shí)進(jìn)行。
- CSS解析與 script 的執(zhí)行互斥 。
- 在Webkit內(nèi)核中進(jìn)行了script執(zhí)行優(yōu)化,只有在JS訪問CSS時(shí)才會發(fā)生互斥。
- CSS樣式不管是來自于 link 的外部引用,還是style標(biāo)記內(nèi)的CSS,亦或是元素的style屬性內(nèi)嵌的CSS,都會被解析成styleSheets。
2.2.2 轉(zhuǎn)換樣式表中的屬性值,使其標(biāo)準(zhǔn)化
在將CSS文轉(zhuǎn)化為瀏覽器能夠理解的 styleSheet 后,就需要對期進(jìn)行進(jìn)行屬性值的標(biāo)準(zhǔn)化操作了。這里的標(biāo)準(zhǔn)化的意思就是,我們在寫css文件的時(shí)候,會寫一些語義化的屬性比如:red/bold等等。但其實(shí)這些詞對于渲染引擎來說,卻不是那么好理解的。因此在進(jìn)行計(jì)算樣式之前,瀏覽器還會這對這些不怎么好計(jì)算的值進(jìn)行標(biāo)準(zhǔn)化,將其轉(zhuǎn)化為渲染引擎容易理解的詞,比如將red轉(zhuǎn)化成為 rgb(255, 0, 0)等等。
2.2.3 計(jì)算出 DOM 樹中每個(gè)節(jié)點(diǎn)的具體樣式
計(jì)算出 DOM 樹中每個(gè)節(jié)點(diǎn)的具體樣式主要涉及的就是CSS繼承規(guī)則和層疊規(guī)則了,對于繼承規(guī)則其實(shí)比較好理解,就是,每個(gè)DOM節(jié)點(diǎn)都包含的父節(jié)點(diǎn)的樣式。
而層疊規(guī)則也就是樣式層疊就有點(diǎn)麻煩了,MDN是這么描述層疊的:
層疊是CSS的一個(gè)基本特征,它是一個(gè)定義了如何合并來自多個(gè)源的屬性值的算法。它在CSS處于核心地位,CSS的全稱層疊樣式表正是強(qiáng)調(diào)了這一點(diǎn)。
層疊的具體細(xì)節(jié)在這里也不展開講了(我自己現(xiàn)在還沒搞清楚。。。),大家可以去CSS層疊看看其內(nèi)部的一些規(guī)則。
在有了css繼承規(guī)則和層疊規(guī)則后,樣式計(jì)算的這個(gè)階段就會在這兩個(gè)規(guī)則的基礎(chǔ)上對 DOM 節(jié)點(diǎn)中的每個(gè)元素計(jì)算處具體的樣式,這個(gè)階段中最終輸出的結(jié)果會保存在 ComputedStyle 中,這個(gè)同樣可以通過控制臺進(jìn)行查看:

2.3 布局階段
通過前面兩個(gè)階段,我們已經(jīng)得到了DOM樹以及DOM樹中具體每個(gè)元素的樣式了,但對于每個(gè)元素所處的幾何位置我們現(xiàn)在還是不知道的,因此接下來要做的就是計(jì)算出DOM樹中可見元素的幾何位置。這個(gè)過程可以分為兩個(gè)階段:
- 創(chuàng)建布局樹
- 布局計(jì)算
2.3.1 創(chuàng)建布局樹
由于DOM樹還包含很多不可見的元素,比如head標(biāo)簽,script標(biāo)簽,以及設(shè)置為display:none的屬性,因?yàn)闉g覽器勢必不能將所有的dom樹的元素都全部拿來進(jìn)行布局計(jì)算,因此在這個(gè)階段,瀏覽器會額外構(gòu)建一顆只包含可見元素的布局樹。在構(gòu)建布局樹期間,瀏覽器大體會進(jìn)行以下一些工作:
- 遍歷DOM樹中的所有可見節(jié)點(diǎn),并將這些節(jié)點(diǎn)加到布局中。
- 將所有不可見節(jié)點(diǎn)忽略掉
下面兩個(gè)需要注意:
- display: none的元素不在Render Tree中
- visibility: hidden的元素在Render Tree中
2.3.2 布局計(jì)算
在已經(jīng)獲取了所有可見元素的樹之后,就可以計(jì)算布局樹節(jié)點(diǎn)的幾何位置了。HTML是基于流的布局方式,因此大多數(shù)情況下,只需要進(jìn)行一次遍歷即刻計(jì)算出頁面的幾何信息。通常來說,處于流靠后的元素不會影響到靠前位置元素的幾何特征,因此在進(jìn)行布局計(jì)算的時(shí)候,通常是按從左至右,從上至下的順序遍歷文檔(只是通常而言,比如表格啥的就不是這樣)。
布局計(jì)算是一個(gè)遞歸的過程,它從根節(jié)點(diǎn)出發(fā),然后遞歸遍歷部分或所有的節(jié)點(diǎn),為每一個(gè)需要計(jì)算的呈現(xiàn)器計(jì)算幾何信息。這個(gè)計(jì)算量無疑是龐大的,因此為了避免一些較小的更改也會觸發(fā)頁面的整體布局計(jì)算,瀏覽器將布局方式分為了全局布局和增量布局。
- 全局布局:全局布局是指觸發(fā)了整個(gè)布局樹的布局計(jì)算的布局,包括:屏幕大小改動,字體大小改動等
- 增量布局:增量布局是指當(dāng)某個(gè)呈現(xiàn)器發(fā)生改變了,只對相應(yīng)的呈現(xiàn)器進(jìn)行布局計(jì)算。
在執(zhí)行完布局計(jì)算后,會將布局計(jì)算的結(jié)果寫入布局樹中,因此這個(gè)過程可以理解為一種裝飾者模式,輸入輸出都是一個(gè)布局樹,只是在這個(gè)過程中會將布局計(jì)算的結(jié)果給加進(jìn)去。
2.4 分層
在有了布局樹之后,瀏覽器的還是不能直接根據(jù)布局樹來將頁面給畫出來,因?yàn)轫撁嬷羞€存在中一些特殊的效果,比如頁面滾動,z-index等。為了能夠方便的實(shí)現(xiàn)這些花里胡哨的功能,渲染引擎還需要進(jìn)行一個(gè)分層處理,將特定節(jié)點(diǎn)生成轉(zhuǎn)筒的圖層,并生成一個(gè)圖層樹(LayerTree),這個(gè)我們也能通過瀏覽器的面板看到:
如上圖所示,瀏覽器的頁面實(shí)際上被分成了多個(gè)圖層,這些圖層疊加在一起就形成了我們最終所看到的頁面。需要注意的是,并不是布局樹中的每一個(gè)節(jié)點(diǎn)都會包含一個(gè)圖層,因此如果一個(gè)節(jié)點(diǎn)沒有所對應(yīng)的圖層,那么它就會從屬于父節(jié)點(diǎn)的圖層。如果一個(gè)節(jié)點(diǎn)需要有自己的圖層,通常需要滿足以下聯(lián)合條件
- 擁有層疊上下文屬性的元素
- 需要剪裁(clip)
2.5 圖層繪制
在確定好圖層之后,瀏覽器的渲染引擎會對圖層樹中的每個(gè)圖層進(jìn)行繪制,渲染引擎會將一個(gè)圖層的繪制拆封成很多個(gè)小的繪制指令,然后會將這些繪制指令按照一定順序組成一個(gè)待繪制列表。和布局相同,繪制也分為全局和增量兩種,也是為了避免部分圖層的改變而需要對整個(gè)圖層樹進(jìn)行繪制。此外,CSS也對繪制順序做了規(guī)定:
- 背景顏色
- 背景圖片
- 邊框
- 子代
- 輪廓
2.6 柵格化(raster)操作
這里的柵格化是指將圖轉(zhuǎn)化為位圖。繪制列表只是用來記錄繪制順序和繪制指令的列表,而實(shí)際繪制操作是由渲染引擎中的合成線程來完成的。實(shí)際過程是當(dāng)圖層對應(yīng)的繪制列表準(zhǔn)備好之后,主線程會將繪制列表提交給合成線程。 合成線程會根據(jù)用戶所能見的窗口范圍對一些劃分,將一些大的圖層化分為圖塊。然后合成線程會根據(jù)用戶所見范圍附近的圖塊來優(yōu)先生成位圖,實(shí)際生成位圖的操作是由柵格化來執(zhí)行的。圖塊是柵格化執(zhí)行的最小單元,渲染進(jìn)程維護(hù)了一個(gè)柵格化的線程池,所有的圖塊柵格化操作都會在這個(gè)線程池里進(jìn)行。
通常,柵格化會使用GPU進(jìn)程中的GPU來進(jìn)行加速,使用GPU進(jìn)程生成位圖的過程叫快速柵格化,通過這個(gè)方式生成的位圖會被保存在GPU內(nèi)存中。這樣做的好處就在于,當(dāng)渲染進(jìn)程的主線程發(fā)生阻塞的時(shí)候,合成線程以及GPU進(jìn)程不會受其影響,可以正常運(yùn)行。這也是為啥有時(shí)候主線程卡住了,但CSS動畫依然可以風(fēng)騷依舊的原因。
2.7 合成和顯示
在所有的圖塊都被進(jìn)行柵格化后,合成線程就會生成繪制圖塊的命令——“DrawQuad”,然后將該命令提交給瀏覽器進(jìn)程。瀏覽器進(jìn)程里面有一個(gè)叫 viz 的組件,用來接收合成線程發(fā)過來的 DrawQuad 命令,然后根據(jù) DrawQuad 命令,將其頁面內(nèi)容繪制到內(nèi)存中,最后再將內(nèi)存顯示在屏幕上。
三、瀏覽器渲染網(wǎng)頁的那些事兒
3.1 回流和重繪(reflow和repaint)
我們都知道HTML默認(rèn)是流式布局的,但CSS和JS會打破這種布局,改變DOM的外觀樣式以及大小和位置。因此我們就需要知道兩個(gè)概念:
- reflow(回流):當(dāng)瀏覽器發(fā)現(xiàn)某個(gè)部分發(fā)生了變化從而影響了布局,這個(gè)時(shí)候就需要倒回去重新渲染,大家稱這個(gè)回退的過程叫 reflow。 常見的reflow是一些會影響頁面布局的操作,諸如Tab,隱藏等。reflow 會從 html 這個(gè) root frame 開始遞歸往下,依次計(jì)算所有的結(jié)點(diǎn)幾何尺寸和位置,以確認(rèn)是渲染樹的一部分發(fā)生變化還是整個(gè)渲染樹。reflow幾乎是無法避免的,因?yàn)橹灰脩暨M(jìn)行交互操作,就勢必會發(fā)生頁面的一部分的重新渲染,且通常我們也無法預(yù)估瀏覽器到底會reflow哪一部分的代碼,因?yàn)樗麄儠嗷ビ绊憽?/li>
- repaint(重繪): repaint則是當(dāng)我們改變某個(gè)元素的背景色、文字顏色、邊框顏色等等不影響它周圍或內(nèi)部布局的屬性時(shí),屏幕的一部分要重畫,但是元素的幾何尺寸和位置沒有發(fā)生改變。
需要注意的是,display:none 會觸發(fā) reflow,而visibility: hidden屬性則并不算是不可見屬性,它的語義是隱藏元素,但元素仍然占據(jù)著布局空間,它會被渲染成一個(gè)空框,這在我們上面有提到過。所以visibility:hidden 只會觸發(fā) repaint,因?yàn)闆]有發(fā)生位置變化。
我們不能避免reflow,但還是能通過一些操作來減少回流:
- 用transform做形變和位移.
- 通過絕對位移來脫離當(dāng)前層疊上下文,形成新的Render Layer。
另外有些情況下,比如修改了元素的樣式,瀏覽器并不會立刻reflow 或 repaint 一次,而是會把這樣的操作積攢一批,然后做一次 reflow,這又叫異步 reflow 或增量異步 reflow。但是在有些情況下,比如resize 窗口,改變了頁面默認(rèn)的字體等。對于這些操作,瀏覽器會馬上進(jìn)行 reflow。
3.2 幾條關(guān)于優(yōu)化渲染效率的建議
結(jié)合上文和我看到的一些文章,有以下幾點(diǎn)可以優(yōu)化渲染效率
- 合法地去書寫 HTML 和 CSS ,且不要忘了文檔編碼類型。
- 樣式文件應(yīng)當(dāng)在 head 標(biāo)簽中,而腳本文件在 body 結(jié)束前,這樣可以防止阻塞的方式。
- 簡化并優(yōu)化CSS選擇器,盡量將嵌套層減少到最小。
- 盡量減少在 JavaScript 中進(jìn)行DOM操作。
- 修改元素樣式時(shí),更改其class屬性是性能最高的方法。
- 盡量用 transform 來做形變和位移
參考資料:
https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/