瀏覽器渲染這是一個廣而深的題目,其中的每一個點如果深入,都可以講一整天。本文主要從廣度的層面,梳理了瀏覽器的整體渲染流程,有不對的地方,煩請指正!
ps:本文整體思路主要參考極客時間專欄-瀏覽器工作原理與實踐(推薦,講的不錯),文中部分圖片畫起來比較復雜,也直接采用了文中的圖片,僅供學習。
因為瀏覽器無法直接理解和使用html,所以需要將html轉換為瀏覽器能夠理解的結構——DOM樹。 在渲染引擎內部,有一個叫 HTML 解析器(HTMLParser)的模塊,它的職責就是負責將 HTML 字節流轉換為 DOM 結構。
第3步和第4步其實是同時進行的,需要將 Token 解析為 DOM 節點,并將 DOM 節點添加到 DOM 樹中。此過程HTML 解析器通過維護了一個Token棧結構來完成。
具體實現可以參考Vue.js的HTMLParser實現
與HTML文本一樣,渲染引擎也沒法直接理解CSS文本,因此渲染引擎會將其轉換為其能理解的結構——styleSheets。在控制臺執行document.styleSheets 可以看到:
styleSheets是對頁面樣式的一個總覽,其內部層級如下圖:
關于stylesheets的具體屬性,可參考鏈接
針對styleSheets,結合CSS的繼承、優先級層疊等規則,渲染引擎最終生成如下CSS規則樹:
此時每個元素上的樣式就是最終應用這個元素上的樣式了,通過瀏覽器的Element->Computed可以查看。
頁面結構和頁面樣式都確定了,接下來就需要將兩者結合起來,對頁面進行整體布局。
DOM樹只是描述了源碼中HTML的結構,但其中許多元素并不需要展示在畫面中(比如head、dispaly:none),也有一些不存在DOM樹中但需要顯示在頁面上的元素(比如偽類),因此在顯示之前需要遍歷DOM樹中的所有節點,忽略掉不可見元素,添加不存在DOM樹中但需要顯示的的內容,最終生成一棵只包含可見元素的Render樹。
以上得到了每個DOM元素的文檔結構和樣式,但是還不知道元素的具體絕對幾何位置。 比如一個div元素的樣式如下:
div {
position: absolute;
width:100px;
height:100px;
top:10px;
left:10px;
}
復制代碼
那么我們還需要知道它的具體絕對幾何位置:
div {
x: ?
y: ?
width: 100px
height: 100px
}
復制代碼
而計算元素的具體絕對幾何位置是一項艱巨的任務,因為即使是最簡單的頁面布局(如從上到下的塊流程)也必須考慮字體的大小以及在何處換行,因為這會影響段落的大小和形狀,也會影響下一段的位置。
在Chrome中,有一整個工程師團隊在為布局而工作, few talks from BlinkOn Conference 有提到一些,大家感興趣可以看看。
以上得到了完整的Render樹,也就是知道了頁面的樣式和位置信息,但還沒到繪制的時候。類似于畫一幅畫,我們還需要知道頁面各元素的繪制順序,比如需要先畫藍天再畫白云,否則白云會被藍天覆蓋住。 針對繪制順序,因為頁面中有很多復雜的效果,如一些復雜的 3D 變換、頁面滾動,或者使用 z-index做 z 軸排序等,為了更加方便地實現這些效果,渲染引擎采用了分層機制。
每個DOM元素會有自己的布局信息Layout Object, 根據其布局信息的層級等關系,某些Layout Object會擁有共同的渲染層Paint Layer,某些Paint Layer又會擁有共同的合成層Composite Layer(Graphic Layers)。
分層-渲染層(Paint Layer)
如上圖,DOM 樹中得每個 Node 節點都有一個對應的 LayoutObject;擁有相同的坐標空間的 LayoutObjects,屬于同一個渲染層(PaintLayer)。渲染層產生的最普遍條件是“層疊上下文”。
層疊上下文示意圖:
根據層疊上下文-MDN,層疊上下文由滿足以下任意一個條件的元素形成:
滿足以上任一條件的元素,都會擁有自己的渲染層,其子元素若沒有單獨的渲染層,則隨父級元素同一層。
其他產生渲染層的特殊場景(除“層疊上下文”),可參考鏈接
分層-合成層(Composite Layer)
某些特殊的渲染層會被認為是合成層(Composite Layer),合成層擁有單獨的 GraphicsLayer。 渲染層與合成層的區別,如圖:
產生合成層的具體條件可參考文章,這里列出幾個常見的場景:
以上三種原因生成合成層demo代碼如下
<!DOCTYPE html>
<html lang="en">
<head>
<style type="text/css">
*{
margin:0;
padding:0;
}
div{
width:200px;
height:100px;
}
.default{
background: #ffb284;
}
.composite-translateZ{
transform: translateZ(0);
background: #f5cec7;
}
.composite-tansform-active{
background: #e79796;
transform: translate(0,0);
transition: 3s;
}
.composite-tansform-active:hover{
transform: translate(100px,100px);
}
.composite-will-change{
background: #ffc988;
will-change: transform;
}
</style>
</head>
<body>
<div class='default'>默認層</div>
<div class='composite-translateZ'>合成層-translateZ</div>
<div class='composite-tansform-active'>合成層——active transform(hover一下我)</div>
<div class='composite-will-change'>合成層——will-change</div>
</body>
</html>
復制代碼
在控制臺的Layers下,可以看到合成層。
<!DOCTYPE html>
<html lang="en">
<head>
<style type="text/css">
*{
margin:0;
padding:0;
}
div{
width:200px;
height:200px;
/*background: */
}
.bottom{
background: #f5cec7;
animation: anim-translate 3s ease-in-out alternate infinite both;
}
@keyframes anim-translate {
from { transform: translateX(0); }
to { transform: translateX(50px); }
}
.top{
background: #e79796;
transform: translateY(-50px);
}
</style>
</head>
<body>
<div class='parents'>
<div class="bottom">下層-有動畫</div>
<div class="top">上層-隱式提升為合成層</div>
</div>
</body>
</html>
復制代碼
demo中的上層div,本不具備提升為合成層的因素,但由于其覆蓋在了下層div上,如果上層div不隱式提升為合成層,它就會和和父元素共用一個合成層,此時渲染順序就會出錯。為了保證渲染順序,因此上層被隱式提升為合成層。在控制臺也可以看到原因:might overlap other composited content.
渲染層是為保證頁面元素以正確的順序,合成層是為了減少渲染的開銷。
提升為合成層的好處:
因此,在開發中,建議對于需要頻繁移動的元素,建議將其提升為單獨的合成層,可減少不必要的重繪,同時可以利用硬件加速,提高渲染效率。
分好層后,就需要對每個層進行繪制了。繪制并不是一蹴而就,而是像畫畫一樣,是按順序一筆一筆畫出來的,渲染引擎也是類似。對于每一個合成層,渲染引擎的渲染過程:
常見的指令如下:
各指令的含義可參考鏈接
打開“開發者工具”的“Layers”標簽,任意選擇一層合成層,可查看該層detail下的詳細渲染列表paint profiler。 繪制指令demo代碼如下
<!DOCTYPE html>
<html lang="en">
<meta http-equiv="Content-Type" Content="text/html; Charset=UTF-8">
<head>
<style type="text/css">
*{
margin:0;
padding:0;
}
div{
width:200px;
height:100px;
text-align: center;
line-height:100px;
}
p{
height:40px;
line-height:40px;
font-size:20px;
margin-bottom: 30px;
}
.level-default{
position: absolute;
background: #f5cec7;
top: 60px;
}
.level1{
background: #ffb284;
position: absolute;
z-index:2;
top: 160px;
}
.level2{
background: #e79796;
position: absolute;
z-index:1;
top: 260px;
}
.composite-1, .composite-2{
position: relative;
transform: translateZ(0);
width:300px;
height:400px;
background: #ddd;
margin-bottom:20px;
}
</style>
</head>
<body>
<div class="composite-1">
<p>合成層一</p>
<div class='level-default'>默認層</div>
<div class='level1'>渲染層1:z-index:2</div>
<div class='level2'>渲染層2:z-index:1</div>
</div>
<div class="composite-2">
<p>合成層二</p>
<div class='level-default'>默認層</div>
<div class='level1'>渲染層1:z-index:2</div>
<div class='level2'>渲染層2:z-index:1</div>
</div>
</body>
</html>
復制代碼
比如選擇composite-1的合成層,繪制列表如下:
這里順便也可以看到一點:渲染層2在渲染層1的后面,但由于其z-index較大(說明其渲染層層級較高),因此優先渲染層2。
需要說明一點,繪制列表只是用來記錄繪制順序和繪制指令的列表,并沒有真正的繪制出頁面。
生成了繪制指令,就到了真正繪制頁面的時候了,真正的繪制過程不是在主線程完成的,而是在得到繪制指令后,主線程會將這些信息交給合成線程,由合成線程來完成繪制。
合成線程是如何工作的呢?
頁面可能很大,但用戶只能看到一部分,在這種情況下如果全部繪制,就會產生很大的性能開銷,因此需要優先繪制視口(即用戶看到的區域)區域內的元素。
基于此原因,繪制前,合成線程會對頁面進行分塊,然后將每個圖塊發送給柵格線程,柵格線程將圖塊轉換為位圖。合成器線程可以優先處理不同的柵格線程,這樣就可以首先對視口(或附近)中的事物進行柵格化。
通常,柵格化過程都會使用 GPU 來加速生成,生成的位圖被保存在 GPU 內存中。
柵格化的過程:
柵格化完成后,每一個圖層都對應一張“圖片”,合成線程會將這些圖片合成為一張“圖片”。此時,頁面數據已經完成繪制,現在只需要顯示給用戶即可,此時就需要顯卡和顯示器就上場了。
顯卡分為前緩沖區和后緩沖區,合成線程生成的“圖片”會被發送至后緩沖區。顯卡對圖片進行處理完成后,系統就會讓后緩沖區和前緩沖區互換,這樣顯示器就總能讀到顯卡最新產生的數據了。
通常顯卡和顯示器的刷新頻率是一致的,都會60次/秒,但對于一些復雜的場景,顯卡處理速度比較慢,顯卡的刷新頻率就會低于顯示器,此時頁面就會出現卡頓現象。
因此在開發中,我們需要盡量保證一幀畫面的處理總時長(以上的所有步驟)不超過1/60s = 16.7ms,這樣畫面才不會出現卡頓現象。不過量化地衡量渲染時間比較困難,但基于以上分析的渲染過程,我們就可以從渲染的各個步驟著手優化渲染流程,提高渲染效率。
JavaScript腳本由于可能會修改DOM,因此會阻塞DOM的構建,這一點我們都知道;而CSS并不會操作或者改變DOM,因此通常我們認為CSS不會影響DOM的構建,只會影響后續的布局、繪制等過程,即會影響DOM的渲染。但其實CSS可以通過JavaScript來阻塞DOM的構建。
因為JavaScript是可以改變樣式的,也就是具有修改CSS規則樹的能力,而JavaScript腳本里是否有改變樣式的操作,這一點在執行JavaScript之前是不可知的。因此,為保證JavaScript腳本的正確執行,在執行JavaScript之前,CSS規則樹必須要先準備好(不然萬一有修改CSS的操作呢)。
也就是說,若在構建DOM的中途存在阻塞DOM構建的JavaScript腳本,而此頁面中還包含了外部 CSS 文件的引用,那么此時就需要等目前的CSS規則樹(基于目前生成完的部分DOM樹)構建完畢后,再開始JavaScript腳本的執行,等一切結束了,再繼續DOM的構建。
整個流程如圖:(其中CSSOM表示CSS規則樹)
demo代碼如下:
<!DOCTYPE html>
<html lang="en">
<meta http-equiv="Content-Type" Content="text/html; Charset=UTF-8">
<head>
<style type="text/css">
h4{
font-size:18px;
font-weight:none;
}
</style>
<link rel="stylesheet" type="text/css" href="https://ss1.bdstatic.com/5eN1bjq8AAUYm2zgoY3K/r/www/cache/static/protocol/https/soutu/css/soutu_new2_ae491b7.css">
</head>
<body>
<button id="btn">清空dom</button>
<div>我是div</div>
<!-- !!!script阻塞div的構建 -->
<script>
console.log('遇到內聯script啦')
</script>
<div>我是div</div>
<div>我是div</div>
<div>我是div</div>
<div>我是div</div>
</body>
<script type="text/javascript">
let btn = document.getElementById('btn')
let body = document.body
btn.addEventListener("click", function(e){
body.innerHTML = ''
}, true);
</script>
</html>
復制代碼
將控制臺Network中的網絡調為Slow 3G,點擊按鈕清空dom后,刷新頁面觀察Element中DOM元素出現的時機。
說明CSS可以通過JavaScript來阻塞DOM的構建。
另外在合成小節提到,生成繪制指令之后的分開、柵格化等工作是在合成線程中進行,這也就意味著在執行合成操作時,是不會影響到主線程執行的,這也是合成動畫性能好的原因之一。也就揭示了為什么經常主線程卡住了,但是 CSS 動畫依然能執行的原因。
前面提到overlap會導致生成隱式合成層,極端情況下就可能會產生大量的不在預期內的額外合成層,導致層爆炸。demo
因此,在開發過程中,建議: