深入瞭解瀏覽器重排和重繪
瀏覽器的渲染引擎
瀏覽器的主要元件有:使用者介面、瀏覽器引擎、渲染引擎、網路、使用者介面後端、JavaScript直譯器、資料儲存。
瀏覽器的主要功能就是向伺服器發出請求,在瀏覽器視窗中展示您選擇的網路資源。瀏覽器在解析HTML文件,將網頁內容展示到瀏覽器上的流程,其實就是渲染引擎完成的。
瀏覽器的渲染過程
我們在這裡討論Gecko和Webkit這兩種渲染引擎,其中Firefox 使用的是 Gecko,這是 Mozilla 公司“自制”的呈現引擎。而 Safari 和 Chrome 瀏覽器使用的都是 WebKit。
WebKit 渲染引擎的主流程:
Mozilla 的 Gecko渲染引擎的主流程:
-
HTML被HTML解析器解析成DOM 樹
-
css則被css解析器解析成CSSOM 樹
-
結合DOM樹和CSSOM樹,生成一棵渲染樹(Render Tree)
-
Layout(迴流):根據生成的渲染樹,進行迴流(Layout),得到節點的幾何資訊(位置,大小)(生成佈局(flow),即將所有渲染樹的所有節點進行平面合成)
-
Painting(重繪):根據渲染樹以及迴流得到的幾何資訊,得到節點的絕對畫素(將佈局繪製(paint)在螢幕上)
- Display:將像素髮送給GPU,展示在頁面上。(這一步其實還有很多內容,比如會在GPU將多個合成層合併為同一個層,並展示在頁面中。而css3硬體加速的原理則是新建合成層)
第四步和第五步是最耗時的部分,這兩步合起來,就是我們通常所說的渲染。
渲染樹與渲染物件
生成渲染樹
渲染樹(render tree
)是由視覺化元素按照其顯示順序而組成的樹,也是文件的視覺化表示。它的作用是讓您按照正確的順序繪製內容。
為了構建渲染樹,瀏覽器主要完成了以下工作:
-
從DOM樹的根節點開始遍歷每個可見節點。
-
對於每個可見的節點,找到CSSOM樹中對應的規則,並應用它們。
-
根據每個可見節點以及其對應的樣式,組合生成渲染樹。
第一步中,既然說到了要遍歷可見的節點,那麼我們得先知道,什麼節點是不可見的。不可見的節點包括:
-
一些不會渲染輸出的節點,比如script、meta、link等。
-
一些通過css進行隱藏的節點。比如display:none。注意,利用visibility和opacity隱藏的節點,還是會顯示在渲染樹上的。只有display:none的節點才不會顯示在渲染樹上。
注意:渲染樹只包含可見的節點
渲染物件
Firefox 將渲染樹中的元素稱為“框架”。WebKit 使用的術語是渲染器(renderer
)或渲染物件(render object
)。
每一個渲染物件都代表了一個矩形區域,通常對應相關節點的css框,包含寬度、高度和位置等幾何資訊。框的型別受“display
”樣式屬性影響,根據不同的display
屬性,使用不同的渲染物件(如RenderInline
、RenderBlock
、RenderListItem
等物件)。
WebKits RenderObject
類是所有渲染物件的基類,其定義如下:
class RenderObject{
virtual void layout();
virtual void paint(PaintInfo);
virtual void rect repaintRect();
Node* node; //the DOM node
RenderStyle* style; // the computed style
RenderLayer* containgLayer; //the containing z-index layer}
我們可以看到,每個渲染物件都有layout
和paint
方法,分別對應了迴流和重繪的方法。
渲染
網頁生成的時候,至少會渲染一次。
在使用者訪問的過程中,還會不斷重新渲染
重新渲染需要重複之前的第四步(重新生成佈局)+第五步(重新繪製)或者只有第五個步(重新繪製)。
重排比重繪大
大,在這個語境裡的意思是:誰能影響誰?
-
重繪:某些元素的外觀被改變,例如:元素的填充顏色
-
重排:重新生成佈局,重新排列元素。
就如上面的概念一樣,單單改變元素的外觀,肯定不會引起網頁重新生成佈局,但當瀏覽器完成重排之後,將會重新繪製受到此次重排影響的部分。
比如改變元素高度,這個元素乃至周邊dom都需要重新繪製。
也就是說:"重繪"不一定會出現"重排","重排"必然會出現"重繪"
重排(reflow)
概念:
當DOM的變化影響了元素的幾何資訊(DOM物件的位置和尺寸大小),瀏覽器需要重新計算元素的幾何屬性,將其安放在介面中的正確位置,這個過程叫做重排。
重排也叫回流
前面我們通過構造渲染樹,我們將可見DOM節點以及它對應的樣式結合起來,可是我們還需要計算它們在裝置視口(viewport)內的確切位置和大小,這個計算的階段就是迴流。
為了弄清每個物件在網站上的確切大小和位置,瀏覽器從渲染樹的根節點開始遍歷,我們可以以下面這個例項來表示:
<!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將節點的顯示尺寸設定為視口寬度的50%,第二個div將其尺寸設定為父節點的50%。而在迴流這個階段,我們就需要根據視口具體的寬度,將其轉為實際的畫素值。(如下圖)
常見引起重排屬性和方法
任何會改變元素幾何資訊(元素的位置和尺寸大小)的操作,都會觸發重排,下面列一些栗子:
-
新增或者刪除可見的DOM元素;
-
元素的尺寸發生變化(包括外邊距、內邊框、邊框大小、高度和寬度等)
-
元素的位置發生變化
-
內容變化,比如文字變化或圖片被另一個不同尺寸的圖片所替代
- 頁面一開始渲染的時候(這肯定避免不了)
-
瀏覽器的視窗尺寸變化(因為迴流是根據視口的大小來計算元素的位置和大小的)
-
計算 offsetWidth 和 offsetHeight 屬性
-
設定 style 屬性的值
改變這些屬性會觸發迴流:
-
盒模型相關的屬性:
width
,height
,margin
,display
,border
等 -
定位屬性及浮動相關的屬性:
top
,position
,float
等 -
改變節點內部文字結構也會觸發迴流:
text-align
,overflow
,font-size
,line-height
,vertival-align
等
重排影響的範圍:
由於瀏覽器渲染介面是基於流失佈局模型的,所以觸發重排時會對周圍DOM重新排列,影響的範圍有兩種:
-
全域性範圍:從根節點
html
開始對整個渲染樹進行重新佈局。 -
區域性範圍:對渲染樹的某部分或某一個渲染物件進行重新佈局
全域性範圍重排:(比如,滾動條出現的時候或者修改了根節點)
<body>
<div class="hello">
<h4>hello</h4>
<p><strong>Name:</strong>BDing</p>
<h5>male</h5>
<ol>
<li>coding</li>
<li>loving</li>
</ol>
</div>
</body>
當p節點上發生reflow時,hello和body也會重新渲染,甚至h5和ol都會收到影響。
區域性範圍重排:
用區域性佈局來解釋這種現象:把一個dom的寬高之類的幾何資訊定死,然後在dom內部觸發重排,就只會重新渲染該dom內部的元素,而不會影響到外界。
儘可能的減少重排的次數、重排範圍:
重排需要更新渲染樹,效能花銷非常大:
它們的代價是高昂的,會破壞使用者體驗,並且讓UI展示非常遲緩,我們需要儘可能的減少觸發重排的次數。
重排的效能花銷跟渲染樹有多少節點需要重新構建有關係:
所以我們應該儘量以區域性佈局的形式組織html
結構,儘可能小的影響重排的範圍。
而不是像全域性範圍的示例程式碼一樣一溜的堆砌標籤,隨便一個元素觸發重排都會導致全域性範圍的重排。
"迴流"還是"重排"?
本質上它們是同樣的流程,只是在不同瀏覽器引擎下的“說法”有所差異。
- Gecko 將視覺格式化元素組成的樹稱為 "
Frame tree
"框架樹
。每個元素都是一個框架;
對於元素的放置,將其稱為 "Reflow
"迴流
。
- WebKit 使用的術語是 "
Render Tree
"渲染樹
,它由"Render Objects
"組成。對於元素的放置,WebKit 使用的術語是 "Layout
"佈局
(或Relayout重排
)
重繪(repaint):
概念:
當一個元素的外觀發生改變,但沒有改變佈局,重新把元素外觀繪製出來的過程,叫做重繪。
最終,我們通過構造渲染樹和迴流階段,我們知道了哪些節點是可見的,以及可見節點的樣式和具體的幾何資訊(位置、大小),那麼我們就可以將渲染樹的每個節點都轉換為螢幕上的實際畫素,這個階段就叫做重繪節點。
重繪是填充畫素的過程。它涉及繪出文字、顏色、影象、邊框和陰影,基本上包括元素的每個可視部分。在重繪階段,系統會遍歷渲染樹,並呼叫渲染物件的“paint
”方法,將渲染物件的內容顯示在螢幕上。
常見的引起重繪的屬性:
繪製順序
繪製的順序其實就是元素進入堆疊樣式上下文的順序。這些堆疊會從後往前繪製,因此這樣的順序會影響繪製。塊渲染物件的堆疊順序如下:1、背景顏色
2、背景圖片
3、邊框
4、子代
5、輪廓
瀏覽器的渲染佇列:
思考以下程式碼將會觸發幾次渲染?
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
根據我們上文的定義,這段程式碼理論上會觸發4次重排+重繪,因為每一次都改變了元素的幾何屬性,實際上最後只觸發了一次重排,這都得益於瀏覽器的渲染佇列機制:
當我們修改了元素的幾何屬性,導致瀏覽器觸發重排或重繪時。它會把該操作放進渲染佇列,等到佇列中的操作到了一定的數量或者到了一定的時間間隔時,瀏覽器就會批量執行這些操作。
強制重新整理佇列:
div.style.left = '10px';
console.log(div.offsetLeft);
div.style.top = '10px';
console.log(div.offsetTop);
div.style.width = '20px';
console.log(div.offsetWidth);
div.style.height = '20px';
console.log(div.offsetHeight);
這段程式碼會觸發4次重排+重繪,因為在console
中你請求的這幾個樣式資訊,無論何時瀏覽器都會立即執行渲染佇列的任務,即使該值與你操作中修改的值沒關聯。
因為佇列中,可能會有影響到這些值的操作,為了給我們最精確的值,瀏覽器會立即重排+重繪。
強制重新整理佇列的style樣式請求:
-
offsetTop, offsetLeft, offsetWidth, offsetHeight
-
scrollTop, scrollLeft, scrollWidth, scrollHeight
-
clientTop, clientLeft, clientWidth, clientHeight
-
getComputedStyle(), 或者 IE的 currentStyle
我們在開發中,應該謹慎的使用這些style請求,注意上下文關係,避免一行程式碼一個重排,這對效能是個巨大的消耗
重排優化建議
就像上文提到的我們要儘可能的減少重排次數、重排範圍,這樣說很泛,下面是一些行之有效的建議,大家可以參考一下。
1. 分離讀寫操作
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);
還是上面觸發4次重排+重繪的程式碼,這次只觸發了一次重排:
在第一個console
的時候,瀏覽器把之前上面四個寫操作的渲染佇列都給清空了。剩下的console,因為渲染佇列本來就是空的,所以並沒有觸發重排,僅僅拿值而已。
2. 樣式集中改變
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
雖然現在大部分瀏覽器有渲染佇列優化,不排除有些瀏覽器以及老版本的瀏覽器效率仍然低下:
建議通過改變class或者csstext屬性集中改變樣式
// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top = top + "px";
// good
el.className += " theclassname";
// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
3. 快取佈局資訊(避免觸發同步佈局事件)
當我們訪問元素的一些屬性的時候,會導致瀏覽器強制清空佇列,進行強制同步佈局。舉個例子,比如說我們想將一個p標籤陣列的寬度賦值為一個元素的寬度,我們可能寫出這樣的程式碼:
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
這段程式碼看上去是沒有什麼問題,可是其實會造成很大的效能問題。在每次迴圈的時候,都讀取了box的一個offsetWidth屬性值,然後利用它來更新p標籤的width屬性。這就導致了每一次迴圈的時候,瀏覽器都必須先使上一次迴圈中的樣式更新操作生效,才能響應本次迴圈的樣式讀取操作。每一次迴圈都會強制瀏覽器重新整理佇列。我們可以優化為:
const width = box.offsetWidth;
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = width + 'px';
}
}
避免迴圈讀取offsetLeft等屬性,在迴圈之前把它們存起來
另外一個例子:
// bad 強制重新整理 觸發兩次重排
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
// good 快取佈局資訊 相當於讀寫分離
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
避免迴圈讀取offsetLeft等屬性,在迴圈之前把它們存起來
4. 離線改變dom
批量修改DOM
當我們需要對DOM進行一系列修改的時候,可以通過以下步驟減少迴流重繪次數:
-
使元素脫離文件流
-
對其進行多次修改
-
將元素帶回到文件中。
該過程的第二步和第三步可能會引起迴流,但是經過第一步之後,對DOM的所有修改都不會引起迴流,因為它已經不在渲染樹了。
有三種方式可以讓DOM脫離文件流:
-
隱藏要修改的元素,應用修改,重新顯示
在要操作dom之前,通過display隱藏dom,當操作完成之後,才將元素的display屬性為可見,因為不可見的元素不會觸發重排和重繪。
dom.display = 'none'
// 修改dom樣式
dom.display = 'block'
-
通過使用DocumentFragment建立一個
dom
碎片,在它上面批量操作dom,操作完成之後,再新增到文件中,這樣只會觸發一次重排。 -
將原始元素拷貝到一個脫離文件的節點中,修改節點後,再替換原始的元素。
考慮我們要執行一段批量插入節點的程式碼:
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);
如果我們直接這樣執行的話,由於每次迴圈都會插入一個新的節點,會導致瀏覽器迴流一次。
我們可以使用這三種方式進行優化:
隱藏元素,應用修改,重新顯示
這個會在展示和隱藏節點的時候,產生兩次重繪
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)在當前DOM之外構建一個子樹,再把它拷貝迴文檔
const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
ul.appendChild(fragment);
將原始元素拷貝到一個脫離文件的節點中,修改節點後,再替換原始的元素。
const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);
對於上述那種情況,我寫了一個demo來測試修改前和修改後的效能。然而實驗結果不是很理想。
原因:原因其實上面也說過了,瀏覽器會使用佇列來儲存多次修改,進行優化,所以對這個優化方案,我們其實不用優先考慮。
5. position屬性為absolute或fixed
position屬性為absolute或fixed的元素,重排開銷比較小,不用考慮它對其他元素的影響
6. 優化動畫
-
對於複雜動畫效果,使用絕對定位讓其脫離文件流
可以把動畫效果應用到position屬性為absolute或fixed的元素上,這樣對其他元素影響較小
對於複雜動畫效果,使用絕對定位讓其脫離文件流,否則會引起父元素及後續元素大量的迴流
-
動畫效果還應犧牲一些平滑,來換取速度,這中間的度自己衡量:
比如實現一個動畫,以1個畫素為單位移動這樣最平滑,但是reflow就會過於頻繁,大量消耗CPU資源,如果以3個畫素為單位移動則會好很多。
-
css3硬體加速(GPU加速)
比起考慮如何減少迴流重繪,我們更期望的是,根本不要回流重繪。這個時候,css3硬體加速就閃亮登場啦!!(最佳的效能渲染流程,就是直接避開回流和重繪,只執行Composite
合成這一操作。)
劃重點:使用css3硬體加速,可以讓transform、opacity、filters這些動畫不會引起迴流重繪 。但是對於動畫的其它屬性,比如background-color這些,還是會引起迴流重繪的,不過它還是可以提升這些動畫的效能。
GPU(影象加速器):
GPU 硬體加速是指應用 GPU 的圖形效能對瀏覽器中的一些圖形操作交給 GPU 來完成,因為 GPU 是專門為處理圖形而設計,所以它在速度和能耗上更有效率。
GPU 加速通常包括以下幾個部分:Canvas2D,佈局合成, CSS3轉換(transitions),CSS3 3D變換(transforms),WebGL和視訊(video)。
如何使用
常見的觸發硬體加速的css屬性:
transform
opacity
filters
Will-change
css3硬體加速的坑
如果你為太多元素使用css3硬體加速,會導致記憶體佔用較大,會有效能問題。
在GPU渲染字型會導致抗鋸齒無效。這是因為GPU和CPU的演算法不同。因此如果你不在動畫結束的時候關閉硬體加速,會產生字型模糊。
7.避免使用table
佈局
HTML 採用基於流的佈局模型,從根渲染物件(即<html>
)開始,遞迴遍歷部分或所有的框架層次結構,為每一個需要計算的渲染物件計算幾何資訊,大多數情況下只要一次遍歷就能計算出幾何資訊。但是也有例外,比如<table>
的計算就需要不止一次的遍歷。
參考
瀏覽器重繪(repaint)重排(reflow)與優化[瀏覽器機制](優先)
前端迴流/重排reflow與重繪redraw(主要看問題)