瀏覽器渲染原理流程
前言
瀏覽器的內(nèi)核是指支持瀏覽器運行的最核心的程序,分為兩個部分的,一是渲染引擎,另一個是JS引擎。渲染引擎在不同的瀏覽器中也不是都相同的。目前市面上常見的瀏覽器內(nèi)核可以分為這四種:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)。這里面大家最耳熟能詳?shù)目赡芫褪?Webkit 內(nèi)核了,Webkit 內(nèi)核是當(dāng)下瀏覽器世界真正的霸主。
本文我們就以 Webkit 為例,對現(xiàn)代瀏覽器的渲染過程進行一個深度的剖析。
頁面加載過程
在介紹瀏覽器渲染過程之前,我們簡明扼要介紹下頁面的加載過程,有助于更好理解后續(xù)渲染過程。
要點如下:
向瀏覽器輸入網(wǎng)址
瀏覽器根據(jù) DNS 服務(wù)器得到域名的 IP 地址
向這個 IP 的機器發(fā)送 HTTP 請求
服務(wù)器收到、處理并返回 HTTP 請求
瀏覽器接收到服務(wù)器返回的內(nèi)容
例如在瀏覽器輸入https://www.baidu.com,然后經(jīng)過?DNS 解析,www.baidu.com對應(yīng)的?IP 是14.215.177.38(不同時間、地點對應(yīng)的 IP 可能會不同)。然后瀏覽器向該 IP 發(fā)送 HTTP 請求。
服務(wù)端接收到 HTTP 請求,然后經(jīng)過計算(向不同的用戶推送不同的內(nèi)容),返回 HTTP 請求,返回的內(nèi)容如下:
其實就是一堆 HMTL 格式的字符串,因為只有 HTML 格式瀏覽器才能正確解析,這是 W3C 標(biāo)準(zhǔn)的要求。接下來就是瀏覽器的渲染過程。
瀏覽器渲染過程

從上面這個圖上,我們可以看到,瀏覽器渲染過程如下:
解析HTML,生成DOM樹,解析CSS,生成CSSOM樹
將DOM樹和CSSOM樹結(jié)合,生成渲染樹(Render Tree)
Layout(回流):根據(jù)生成的渲染樹,進行回流(Layout),得到節(jié)點的幾何信息(位置,大小)
Painting(重繪):根據(jù)渲染樹以及回流得到的幾何信息,得到節(jié)點的絕對像素
Display:?將像素發(fā)送給GPU,最后通過調(diào)用操作系統(tǒng)Native GUI的API繪制,展示在頁面上。(這一步其實還有很多內(nèi)容,比如會在GPU將多個合成層合并為同一個層,并展示在頁面中。而css3硬件加速的原理則是新建合成層,這里我們不展開,之后有機會再寫一篇博客來介紹)
渲染過程看起來也不復(fù)雜,讓我們來具體了解下每一步具體做了什么。
構(gòu)建DOM詳細流程
瀏覽器會遵守一套步驟將HTML 文件轉(zhuǎn)換為 DOM 樹。宏觀上,可以分為幾個步驟:

瀏覽器從磁盤或網(wǎng)絡(luò)讀取HTML的原始字節(jié)(字節(jié)數(shù)據(jù)),并根據(jù)文件的指定編碼(例如 UTF-8)將它們轉(zhuǎn)換成字符串。
在網(wǎng)絡(luò)中傳輸?shù)膬?nèi)容其實都是 0 和 1 這些字節(jié)數(shù)據(jù)。當(dāng)瀏覽器接收到這些字節(jié)數(shù)據(jù)以后,它會將這些字節(jié)數(shù)據(jù)轉(zhuǎn)換為字符串,也就是我們寫的代碼。
將字符串轉(zhuǎn)換成Token,例如:、等。Token中會標(biāo)識出當(dāng)前Token是“開始標(biāo)簽”或是“結(jié)束標(biāo)簽”或著是“文本”等信息。
這時候你一定會有疑問,節(jié)點與節(jié)點之間的關(guān)系如何維護?
事實上,這就是Token要標(biāo)識“起始標(biāo)簽”和“結(jié)束標(biāo)簽”等標(biāo)識的作用。
例如“title”Token的起始標(biāo)簽和結(jié)束標(biāo)簽之間的節(jié)點肯定是屬于“head”的子節(jié)點。

上圖給出了節(jié)點之間的關(guān)系,例如:“Hello”Token位于“title”開始標(biāo)簽與“title”結(jié)束標(biāo)簽之間,表明“Hello”Token是“title”Token的子節(jié)點。同理“title”Token是“head”Token的子節(jié)點。
生成節(jié)點對象并構(gòu)建DOM
事實上,構(gòu)建DOM的過程中,不是等所有Token都轉(zhuǎn)換完成后再去生成節(jié)點對象,而是一邊生成Token一邊消耗Token來生成節(jié)點對象。換句話說,每個Token被生成后,會立刻消耗這個Token創(chuàng)建出節(jié)點對象。注意:帶有結(jié)束標(biāo)簽標(biāo)識的Token不會創(chuàng)建節(jié)點對象。
接下來我們舉個例子,假設(shè)有段HTML文本:
<html>
<head> ?
<title>Web page parsing</title>
</head>
<body>
<div>
<h1>Web page parsing</h1> ? ?
<p>This is an example Web page.</p> ?
</div>
</body>
</html>
上面這段HTML會解析成這樣:

構(gòu)建CSSOM詳細流程
DOM會捕獲頁面的內(nèi)容,但瀏覽器還需要知道頁面如何展示,所以需要構(gòu)建CSSOM。
構(gòu)建CSSOM的過程與構(gòu)建DOM的過程非常相似,當(dāng)瀏覽器接收到一段CSS,瀏覽器首先要做的是識別出Token,然后構(gòu)建節(jié)點并生成CSSOM。

在這一過程中,瀏覽器會確定下每一個節(jié)點的樣式到底是什么,并且這一過程其實是很消耗資源的。因為樣式你可以自行設(shè)置給某個節(jié)點,也可以通過繼承獲得。在這一過程中,瀏覽器得遞歸 CSSOM 樹,然后確定具體的元素到底是什么樣式。
注意:CSS匹配HTML元素是一個相當(dāng)復(fù)雜和有性能問題的事情。所以,DOM樹要小,CSS盡量用id和class,千萬不要過渡層疊下去。
構(gòu)建渲染樹
當(dāng)我們生成 DOM 樹和 CSSOM 樹以后,就需要將這兩棵樹組合為渲染樹。

在這一過程中,不是簡單的將兩者合并就行了。渲染樹只會包括需要顯示的節(jié)點和這些節(jié)點的樣式信息,如果某個節(jié)點是?display: none
?的,那么就不會在渲染樹中顯示。
注意:渲染樹只包含可見的節(jié)點
我們或許有個疑惑:瀏覽器如果渲染過程中遇到JS文件怎么處理?
渲染過程中,如果遇到<script>
就停止渲染,執(zhí)行 JS 代碼。因為瀏覽器有GUI渲染線程與JS引擎線程,為了防止渲染出現(xiàn)不可預(yù)期的結(jié)果,這兩個線程是互斥的關(guān)系。JavaScript的加載、解析與執(zhí)行會阻塞DOM的構(gòu)建
,也就是說,在構(gòu)建DOM時,HTML解析器若遇到了JavaScript,那么它會暫停構(gòu)建DOM,將控制權(quán)移交給JavaScript引擎,等JavaScript引擎運行完畢,瀏覽器再從中斷的地方恢復(fù)DOM構(gòu)建。
也就是說,如果你想首屏渲染的越快,就越不應(yīng)該在首屏就加載 JS 文件,這也是都建議將 script 標(biāo)簽放在 body 標(biāo)簽底部的原因。當(dāng)然在當(dāng)下,并不是說 script 標(biāo)簽必須放在底部,因為你可以給 script 標(biāo)簽添加?defer(延遲)
?或者?async(異步)
?屬性(下文會介紹這兩者的區(qū)別)。
JS文件不只是阻塞DOM的構(gòu)建,它會導(dǎo)致CSSOM也阻塞DOM的構(gòu)建。
原本DOM和CSSOM的構(gòu)建是互不影響,井水不犯河水,但是一旦引入了JavaScript,CSSOM也開始阻塞DOM的構(gòu)建,只有CSSOM構(gòu)建完畢后,DOM再恢復(fù)DOM構(gòu)建。
這是什么情況呢?
這是因為JavaScript不只是可以改DOM,它還可以更改樣式,也就是它可以更改CSSOM。因為不完整的CSSOM是無法使用的,如果JavaScript想訪問CSSOM并更改它,那么在執(zhí)行JavaScript時,必須要能拿到完整的CSSOM。所以就導(dǎo)致了一個現(xiàn)象,如果瀏覽器尚未完成CSSOM的下載和構(gòu)建,而我們卻想在此時運行腳本,那么瀏覽器將延遲腳本執(zhí)行和DOM構(gòu)建,直至其完成CSSOM的下載和構(gòu)建。也就是說,在這種情況下,瀏覽器會先下載和構(gòu)建CSSOM,然后再執(zhí)行JavaScript,最后在繼續(xù)構(gòu)建DOM。

布局與繪制
當(dāng)瀏覽器生成渲染樹以后,就會根據(jù)渲染樹來進行布局(也可以叫做回流)。這一階段瀏覽器要做的事情是要弄清楚各個節(jié)點在頁面中的確切位置和大小。通常這一行為也被稱為“自動重排”。
布局流程的輸出是一個“盒模型”,它會精確地捕獲每個元素在視口內(nèi)的確切位置和尺寸,所有相對測量值都將轉(zhuǎn)換為屏幕上的絕對像素。
布局完成后,瀏覽器會立即發(fā)出“Paint Setup”和“Paint”事件,將渲染樹轉(zhuǎn)換成屏幕上的像素。
回流
前面我們通過構(gòu)造渲染樹,我們將可見DOM節(jié)點以及它對應(yīng)的樣式結(jié)合起來,可是我們還需要計算它們在設(shè)備視口(viewport)內(nèi)的確切位置和大小,這個計算的階段就是回流。
為了弄清每個對象在網(wǎng)站上的確切大小和位置,瀏覽器從渲染樹的根節(jié)點開始遍歷,我們可以以下面這個實例來表示:
<!DOCTYPE html>?
<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>
我們可以看到,第一個div將節(jié)點的顯示尺寸設(shè)置為視口寬度的50%,第二個div將其尺寸設(shè)置為父節(jié)點的50%。而在回流這個階段,我們就需要根據(jù)視口具體的寬度,將其轉(zhuǎn)為實際的像素值。(如下圖)
我們可以看到,第一個div將節(jié)點的顯示尺寸設(shè)置為視口寬度的50%,第二個div將其尺寸設(shè)置為父節(jié)點的50%。而在回流這個階段,我們就需要根據(jù)視口具體的寬度,將其轉(zhuǎn)為實際的像素值。(如下圖)

重繪
最終,我們通過構(gòu)造渲染樹和回流階段,我們知道了哪些節(jié)點是可見的,以及可見節(jié)點的樣式和具體的幾何信息(位置、大小),那么我們就可以將渲染樹的每個節(jié)點都轉(zhuǎn)換為屏幕上的實際像素,這個階段就叫做重繪節(jié)點。
既然知道了瀏覽器的渲染過程后,我們就來探討下,何時會發(fā)生回流重繪。
何時發(fā)生回流重繪
我們前面知道了,回流這一階段主要是計算節(jié)點的位置和幾何信息,那么當(dāng)頁面布局和幾何信息發(fā)生變化的時候,就需要回流。
比如以下情況發(fā)生回流:
根據(jù)改變的范圍和程度,渲染樹中或大或小的部分需要重新計算,有些改變會觸發(fā)整個頁面的重排,比如,滾動條出現(xiàn)的時候或者修改了根節(jié)點。
頁面一開始渲染的時候(這肯定避免不了)
瀏覽器的窗口尺寸變化(因為回流是根據(jù)視口的大小來計算元素的位置和大小的)
添加或刪除可見的DOM元素
元素的位置發(fā)生變化
元素的尺寸發(fā)生變化(包括外邊距、內(nèi)邊框、邊框大小、高度和寬度等)
內(nèi)容發(fā)生變化,比如文本變化或圖片被另一個不同尺寸的圖片所替代。
元素字體大小變化
激活CSS偽類(例如::hover)
一些常用且會導(dǎo)致回流的屬性和方法:
clientWidth、clientHeight、clientTop、clientLeft?
offsetWidth、offsetHeight、offsetTop、offsetLeft?
scrollWidth、scrollHeight、scrollTop、scrollLeft?
scrollIntoView()、scrollIntoViewIfNeeded()?
getComputedStyle()
getBoundingClientRect()?
scrollTo()
Copy to clipboardErrorCopied
以下情況發(fā)生重繪而不回流
當(dāng)頁面中元素樣式的改變并不影響它在文檔流中的位置時(例如:color、background-color、visibility等),瀏覽器會將新樣式賦予給元素并重新繪制它,這個過程重繪而不回流。
注意:回流一定會觸發(fā)重繪,而重繪不一定會回流
性能影響
回流比重繪的代價要更高。
有時即使僅僅回流一個單一的元素,它的父元素以及任何跟隨它的元素也會產(chǎn)生回流。
瀏覽器優(yōu)化機制
現(xiàn)代瀏覽器會對頻繁的回流或重繪操作進行優(yōu)化:
瀏覽器會維護一個隊列,把所有引起回流和重繪的操作放入隊列中,如果隊列中的任務(wù)數(shù)量或者時間間隔達到一個閾值的,瀏覽器就會將隊列清空,進行一次批處理,這樣可以把多次回流和重繪變成一次。
當(dāng)你訪問以下屬性或方法時,瀏覽器會立刻清空隊列:
clientWidth、clientHeight、clientTop、clientLeft?
offsetWidth、offsetHeight、offsetTop、offsetLeft
scrollWidth、scrollHeight、scrollTop、scrollLeft?
width、height?
getComputedStyle()
getBoundingClientRect()
因為隊列中可能會有影響到這些屬性或方法返回值的操作,即使你希望獲取的信息與隊列中操作引發(fā)的改變無關(guān),瀏覽器也會強行清空隊列,確保你拿到的值是最精確的。
以上屬性和方法都需要返回最新的布局信息,因此瀏覽器不得不清空隊列,觸發(fā)回流重繪來返回正確的值。因此,我們在修改樣式的時候,最好避免使用上面列出的屬性,他們都會刷新渲染隊列。如果要使用它們,最好將值緩存起來。
減少回流和重繪
使用 transform 替代 top
使用 visibility 替換 display: none ,因為前者只會引起重繪,后者會引發(fā)回流(改變了布局)
不要把節(jié)點的屬性值放在一個循環(huán)里當(dāng)成循環(huán)里的變量。
不要使用 table 布局,可能很小的一個小改動會造成整個 table 的重新布局
動畫實現(xiàn)的速度的選擇,動畫速度越快,回流次數(shù)越多,也可以選擇使用 requestAnimationFrame
CSS 選擇符從右往左匹配查找,避免節(jié)點層級過多
將頻繁重繪或者回流的節(jié)點設(shè)置為圖層,圖層能夠阻止該節(jié)點的渲染行為影響別的節(jié)點。比如對于 video 標(biāo)簽來說,瀏覽器會自動將該節(jié)點變?yōu)閳D層。
最小化回流和重繪
由于回流和重繪可能代價比較昂貴,因此最好就是可以減少它的發(fā)生次數(shù)。為了減少發(fā)生次數(shù),我們可以合并多次對DOM和樣式的修改,然后一次處理掉??紤]這個例子
const el = document.getElementById('test');
el.style.padding = '5px';
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
例子中,有三個樣式屬性被修改了,每一個都會影響元素的幾何結(jié)構(gòu),引起回流。當(dāng)然,大部分現(xiàn)代瀏覽器都對其做了優(yōu)化,因此,只會觸發(fā)一次重排。但是如果在舊版的瀏覽器或者在上面代碼執(zhí)行的時候,有其他代碼訪問了布局信息(上文中的會觸發(fā)回流的布局信息),那么就會導(dǎo)致三次重排。
因此,我們可以合并所有的改變?nèi)缓笠来翁幚?,比如我們可以采取以下的方式?/p>
使用cssText
const el = document.getElementById('test');?
el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;';
? ? 2.使用class, 把css樣式用個class包住,修改CSS的class.
active{ ?
border-left: 1px; ?
border-right: 2px; ?
?padding: 5px;?
}
const el = document.getElementById('test');
el.className += ' active';
批量修改DOM
當(dāng)我們需要對DOM對一系列修改的時候,可以通過以下步驟減少回流重繪次數(shù):
使元素脫離文檔流
對其進行多次修改
將元素帶回到文檔中。
該過程的第一步和第三步可能會引起回流,但是經(jīng)過第一步之后,對DOM的所有修改都不會引起回流,因為它已經(jīng)不在渲染樹了。
有三種方式可以讓DOM脫離文檔流:
隱藏元素,應(yīng)用修改,重新顯示
使用文檔片段(document fragment)在當(dāng)前DOM之外構(gòu)建一個子樹,再把它拷貝回文檔。
將原始元素拷貝到一個脫離文檔的節(jié)點中,修改節(jié)點后,再替換原始的元素。
下面來個例子演示下
我們要執(zhí)行一段批量插入節(jié)點的代碼:
function appendDataToElement(appendToElement, data) {?
? ? let li; ?
? for (let i = 0; i < data.length; i++) { ?
? ? li = document.createElement('li'); ? ??
? ? li.textContent = 'text'; ??
? ? appendToElement.appendChild(li); ?
? ? ?}?
}?
const ul = document.getElementById('list');
appendDataToElement(ul, data);
如果我們直接這樣執(zhí)行的話,由于每次循環(huán)都會插入一個新的節(jié)點,會導(dǎo)致瀏覽器回流一次。
我們可以使用這三種方式進行優(yōu)化:
隱藏元素,應(yīng)用修改,重新顯示
第一種方法:隱藏元素,這個會在展示和隱藏節(jié)點的時候,產(chǎn)生兩次重繪
function appendDataToElement(appendToElement, data) { ?
?let li; ??
?for (let i = 0; i < data.length; i++) { ??
? ? ?li = document.createElement('li'); ? ?
?? ? li.textContent = 'text'; ? ?
? ? appendToElement.appendChild(li); ?
? ?}
}
const ul = document.getElementById('list');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';
第二種:使用文檔片段(document fragment)在當(dāng)前DOM之外構(gòu)建一個子樹,再把它拷貝回文檔
const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();?
appendDataToElement(fragment, data);
ul.appendChild(fragment);
第三種:將原始元素拷貝到一個脫離文檔的節(jié)點中,修改節(jié)點后,再替換原始的元素。
const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);
避免觸發(fā)同步布局事件
上文我們說過,當(dāng)我們訪問元素的一些屬性的時候,會導(dǎo)致瀏覽器強制清空隊列,進行強制同步布局。舉個例子,比如說我們想將一個p標(biāo)簽數(shù)組的寬度賦值為一個元素的寬度,我們可能寫出這樣的代碼:
function initP() { ??
? for (let i = 0; i < paragraphs.length; i++) { ? ?
? ? paragraphs[i].style.width = box.offsetWidth + 'px'; ??
??? }?
}
這段代碼看上去是沒有什么問題,可是其實會造成很大的性能問題。在每次循環(huán)的時候,都讀取了box的一個offsetWidth屬性值,然后利用它來更新p標(biāo)簽的width屬性。這就導(dǎo)致了每一次循環(huán)的時候,瀏覽器都必須先使上一次循環(huán)中的樣式更新操作生效,才能響應(yīng)本次循環(huán)的樣式讀取操作。每一次循環(huán)都會強制瀏覽器刷新隊列。我們可以優(yōu)化為:
const width = box.offsetWidth;?
function initP() {
? ? for (let i = 0; i < paragraphs.length; i++) { ? ? ? ? paragraphs[i].style.width = width + 'px'; ?
? }?
}
對于復(fù)雜動畫效果,使用絕對定位讓其脫離文檔流
對于復(fù)雜動畫效果,由于會經(jīng)常的引起回流重繪,因此,我們可以使用絕對定位,讓它脫離文檔流。否則會引起父元素以及后續(xù)元素頻繁的回流。這個我們就直接上個例子。
打開這個例子后,我們可以打開控制臺,控制臺上會輸出當(dāng)前的幀數(shù)(雖然不準(zhǔn))。
從例子中,我們可以看到,幀數(shù)一直都沒到60。這個時候,只要我們點擊一下那個按鈕,把這個元素設(shè)置為絕對定位,幀數(shù)就可以穩(wěn)定60。
css3硬件加速(GPU加速)
比起考慮如何減少回流重繪,我們更期望的是,根本不要回流重繪。這個時候,css3硬件加速就閃亮登場啦?。?/p>
劃重點:使用css3硬件加速,可以讓transform、opacity、filters這些動畫不會引起回流重繪 。但是對于動畫的其它屬性,比如background-color這些,還是會引起回流重繪的,不過它還是可以提升這些動畫的性能。
如何使用css3硬件加速(GPU加速)
常見的觸發(fā)硬件加速的css3屬性:
transform
opacity
filters
Will-change
css3硬件加速的坑
如果你為太多元素使用css3硬件加速,會導(dǎo)致內(nèi)存占用較大,會有性能問題。
在GPU渲染字體會導(dǎo)致抗鋸齒無效。這是因為GPU和CPU的算法不同。因此如果你不在動畫結(jié)束的時候關(guān)閉硬件加速,會產(chǎn)生字體模糊。