1. 程式人生 > 實用技巧 >深入瞭解瀏覽器重排和重繪

深入瞭解瀏覽器重排和重繪

瀏覽器的渲染引擎

瀏覽器的主要元件有:使用者介面、瀏覽器引擎、渲染引擎、網路、使用者介面後端、JavaScript直譯器、資料儲存。

瀏覽器的主要功能就是向伺服器發出請求,在瀏覽器視窗中展示您選擇的網路資源。瀏覽器在解析HTML文件,將網頁內容展示到瀏覽器上的流程,其實就是渲染引擎完成的。

瀏覽器的渲染過程

我們在這裡討論Gecko和Webkit這兩種渲染引擎,其中Firefox 使用的是 Gecko,這是 Mozilla 公司“自制”的呈現引擎。而 Safari 和 Chrome 瀏覽器使用的都是 WebKit。

WebKit 渲染引擎的主流程:

Mozilla 的 Gecko渲染引擎的主流程:

  1. HTML被HTML解析器解析成DOM 樹

  2. css則被css解析器解析成CSSOM 樹

  3. 結合DOM樹和CSSOM樹,生成一棵渲染樹(Render Tree)

  4. Layout(迴流):根據生成的渲染樹,進行迴流(Layout),得到節點的幾何資訊(位置,大小)(生成佈局(flow),即將所有渲染樹的所有節點進行平面合成)

  5. Painting(重繪):根據渲染樹以及迴流得到的幾何資訊,得到節點的絕對畫素(將佈局繪製(paint)在螢幕上)

  6. Display:將像素髮送給GPU,展示在頁面上。(這一步其實還有很多內容,比如會在GPU將多個合成層合併為同一個層,並展示在頁面中。而css3硬體加速的原理則是新建合成層)

第四步和第五步是最耗時的部分,這兩步合起來,就是我們通常所說的渲染

渲染樹與渲染物件

生成渲染樹

渲染樹(render tree)是由視覺化元素按照其顯示順序而組成的樹,也是文件的視覺化表示。它的作用是讓您按照正確的順序繪製內容。

為了構建渲染樹,瀏覽器主要完成了以下工作:

  1. 從DOM樹的根節點開始遍歷每個可見節點。

  2. 對於每個可見的節點,找到CSSOM樹中對應的規則,並應用它們。

  3. 根據每個可見節點以及其對應的樣式,組合生成渲染樹。

第一步中,既然說到了要遍歷可見的節點,那麼我們得先知道,什麼節點是不可見的。不可見的節點包括:

  • 一些不會渲染輸出的節點,比如script、meta、link等。

  • 一些通過css進行隱藏的節點。比如display:none。注意,利用visibility和opacity隱藏的節點,還是會顯示在渲染樹上的。只有display:none的節點才不會顯示在渲染樹上。

注意:渲染樹只包含可見的節點

渲染物件

Firefox 將渲染樹中的元素稱為“框架”。WebKit 使用的術語是渲染器(renderer)或渲染物件(render object)。

每一個渲染物件都代表了一個矩形區域,通常對應相關節點的css框,包含寬度、高度和位置等幾何資訊。框的型別受“display”樣式屬性影響,根據不同的display屬性,使用不同的渲染物件(如RenderInlineRenderBlockRenderListItem等物件)。

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}

我們可以看到,每個渲染物件都有layoutpaint方法,分別對應了迴流和重繪的方法。

渲染

網頁生成的時候,至少會渲染一次。

在使用者訪問的過程中,還會不斷重新渲染

重新渲染需要重複之前的第四步(重新生成佈局)+第五步(重新繪製)或者只有第五個步(重新繪製)。

重排比重繪大

大,在這個語境裡的意思是:誰能影響誰?

  • 重繪:某些元素的外觀被改變,例如:元素的填充顏色

  • 重排:重新生成佈局,重新排列元素。

就如上面的概念一樣,單單改變元素的外觀,肯定不會引起網頁重新生成佈局,但當瀏覽器完成重排之後,將會重新繪製受到此次重排影響的部分

比如改變元素高度,這個元素乃至周邊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%。而在迴流這個階段,我們就需要根據視口具體的寬度,將其轉為實際的畫素值。(如下圖)

常見引起重排屬性和方法

任何會改變元素幾何資訊(元素的位置和尺寸大小)的操作,都會觸發重排,下面列一些栗子:

  1. 新增或者刪除可見的DOM元素

  2. 元素的尺寸發生變化(包括外邊距、內邊框、邊框大小、高度和寬度等)

  3. 元素的位置發生變化

  4. 內容變化,比如文字變化或圖片被另一個不同尺寸的圖片所替代

  5. 頁面一開始渲染的時候(這肯定避免不了)
  6. 瀏覽器的視窗尺寸變化(因為迴流是根據視口的大小來計算元素的位置和大小的)

  7. 計算 offsetWidth 和 offsetHeight 屬性

  8. 設定 style 屬性的值

改變這些屬性會觸發迴流:

  • 盒模型相關的屬性:widthheightmargindisplayborder

  • 定位屬性及浮動相關的屬性: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樣式請求

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight

  2. scrollTop, scrollLeft, scrollWidth, scrollHeight

  3. clientTop, clientLeft, clientWidth, clientHeight

  4. 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進行一系列修改的時候,可以通過以下步驟減少迴流重繪次數:

  1. 使元素脫離文件流

  2. 對其進行多次修改

  3. 將元素帶回到文件中。

該過程的第二步和第三步可能會引起迴流,但是經過第一步之後,對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(主要看問題)