1. 程式人生 > 其它 >重繪與重排

重繪與重排

前言,最近利用碎片時間拜讀了一下尼古拉斯的另一鉅作《高效能JavaScript》,今天寫的文章從“老生常談”的頁面重繪和重排入手,去探究這兩個概念在頁面效能提升上的作用。

一.重排 & 重繪

1.2 有經驗的大佬對這個概念一定不會陌生,“瀏覽器輸入URL發生了什麼”。估計大家已經爛熟於心了,從計算機網路到JS引擎,一路飛奔到瀏覽器渲染引擎。 經驗越多就能理解的越深。感興趣的同學可以看一下這篇文章,深度和廣度俱佳 從輸入 URL 到頁面載入的過程?如何由一道題完善自己的前端知識體系!

切回正題,我們繼續探討何為重排。瀏覽器下載完頁面所有的資源後,就要開始構建DOM樹,於此同時還會構建渲染樹(Render Tree)。(其實在構建渲染樹之前,和DOM樹同期會構建Style Tree。DOM樹與Style Tree合併為渲染樹)

  • DOM樹
    表示頁面的結構
  • 渲染樹
    表示頁面的節點如何顯示

一旦渲染樹構建完成,就要開始繪製(paint)頁面元素了。當DOM的變化引發了元素幾何屬性的變化,比如改變元素的寬高,元素的位置,導致瀏覽器不得不重新計算元素的幾何屬性,並重新構建渲染樹,這個過程稱為“重排”。完成重排後,要將重新構建的渲染樹渲染到螢幕上,這個過程就是“重繪”。簡單的說,重排負責元素的幾何屬性更新,重繪負責元素的樣式更新。而且,重排必然帶來重繪,但是重繪未必帶來重排。比如,改變某個元素的背景,這個就不涉及元素的幾何屬性,所以只發生重繪。

1.3.重排和重繪的代價究竟多大

重排和重繪的代價有多大?我們再回到前文那個過橋的例子上,細心的你可能會發現了,千倍的時間差並不是由於“過橋”一手造成的,每次“過橋”其實都伴隨著重排和重繪,而耗能的絕大部分也正是在這裡!

var times = 15000;
 
// code1 每次過橋+重排+重繪
console.time(1);
for(var i = 0; i < times; i++) {
  document.getElementById('myDiv1').innerHTML += 'a';
}
console.timeEnd(1);
 
// code2 只過橋
console.time(2);
var str = '';
for(var i = 0; i < times; i++) {
  var tmp = document.getElementById('myDiv2').innerHTML;
  str += 'a';
}
document.getElementById('myDiv2').innerHTML = str;
console.timeEnd(2);
 
// code3 
console.time(3);
var _str = '';
for(var i = 0; i < times; i++) {
  _str += 'a';
}
document.getElementById('myDiv3').innerHTML = _str;
console.timeEnd(3);
 
 
// 1: 2874.619ms
// 2: 11.154ms
// 3: 1.282ms

  

資料是不會撒謊的,看到了吧,多次訪問DOM對於重排和重繪來說,耗時簡直不值一提了。

二. 重排觸發機制

上面已經提到了,重排發生的根本原理就是元素的幾何屬性發生了改變,那麼我們就從能夠改變元素幾何屬性的角度入手

  • 新增或刪除可見的DOM元素
  • 元素位置改變
  • 元素本身的尺寸發生改變
  • 內容改變
  • 頁面渲染器初始化
  • 瀏覽器視窗大小發生改變

三. 如何進行效能優化

重繪和重排的開銷是非常昂貴的,如果我們不停的在改變頁面的佈局,就會造成瀏覽器耗費大量的開銷在進行頁面的計算,這樣的話,我們頁面在使用者使用起來,就會出現明顯的卡頓。現在的瀏覽器其實已經對重排進行了優化,比如如下程式碼:

var div = document.querySelector('.div');
div.style.width = '200px';
div.style.background = 'red';
div.style.height = '300px';

  

比較久遠的瀏覽器,這段程式碼會觸發頁面2次重排,在分別設定寬高的時候,觸發2次,當代的瀏覽器對此進行了優化,這種思路類似於現在流行的MVVM框架使用的虛擬DOM,對改變的DOM節點進行依賴收集,確認沒有改變的節點,就進行一次更新。但是瀏覽器針對重排的優化雖然思路和虛擬DOM接近,但是還是有本質的區別。大多數瀏覽器通過佇列化修改並批量執行來優化重排過程。也就是說上面那段程式碼其實在現在的瀏覽器優化下,只構成一次重排。
但是還是有一些特殊的元素幾何屬性會造成這種優化失效。比如:

  • offsetTop, offsetLeft,...
    scrollTop, scrollLeft, ...
    clientTop, clientLeft, ...
    getComputedStyle() (currentStyle in IE)
    

      

為什麼造成優化失效呢?仔細看這些屬性,都是需要實時回饋給使用者的幾何屬性或者是佈局屬性,當然不能再依靠瀏覽器的優化,因此瀏覽器不得不立即執行渲染佇列中的“待處理變化”,並隨之觸發重排返回正確的值。
接下來深入的介紹幾種效能優化的小TIPS

3.1 最小化重繪和重排

既然重排&重繪是會影響頁面的效能,尤其是糟糕的JS程式碼更會將重排帶來的效能問題放大。既然如此,我們首先想到的就是減少重排重繪。

3.1.1. 改變樣式

考慮下面這個例子:

// javascript
var el = document.querySelector('.el');
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';

  

這個例子其實和上面那個例子是一回事兒,在最糟糕的情況下,會觸發瀏覽器三次重排。然鵝更高效的方式就是合併所有的改變一次處理。這樣就只會修改DOM節點一次,比如改為使用cssText屬性實現:

var el = document.querySelector('.el');
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px';

沿著這個思路,聰明的老鐵一定就說了,你直接改個類名不也妥妥的。沒錯,還有一種減少重排的方法就是切換類名,而不是使用內聯樣式的cssText方法。使用切換類名就變成了這樣:

// css 
.active {
    padding: 5px;
    border-left: 1px;
    border-right: 2px;
}
// javascript
var el = document.querySelector('.el');
el.className = 'active';

  

3.1.2 批量修改DOM

如果我們需要對DOM元素進行多次修改,怎麼去減少重排和重繪的次數呢?有的同學又要說了,利用上面修改樣式的方法不就行了嗎。回過頭看一下造成頁面重排的幾個要點裡,可以明確的看到,造成元素幾何屬性發生改變就會觸發重排,現在需要增加10個節點,必然涉及到DOM的修改,這個時候就需要利用批量修改DOM這種優化方式了,這裡也能看到,改變樣式最小化重繪和重排這種優化方式適用於單個存在的節點。
批量修改DOM元素的核心思想是:

  • 讓該元素脫離文件流
  • 對其進行多重改變
  • 將元素帶回文件中

打個比方,我們主機硬碟出現了故障,常見的辦法就是把硬碟卸下來,用專業的工具測試哪裡有問題,待修復後再安裝上去。要是直接在主機板上面用螺絲刀弄來弄去,估計主機板一會兒也要壞了...
這個過程引發倆次重排,第一步和第三步,如果沒有這兩步,可以想象一下,第二步每次對DOM的增刪都會引發一次重排。那麼知道批量修改DOM的核心思想後,我們再瞭解三種可以使元素可以脫離文件流的方法,注意,這裡不使用css中的浮動&絕對定位,這是風馬牛不相及的概念。

  • 隱藏元素,進行修改後,然後再顯示該元素
  • 使用文件片段建立一個子樹,然後再拷貝到文件中
  • 將原始元素拷貝到一個獨立的節點中,操作這個節點,然後覆蓋原始元素

看一下下面這個程式碼示例:

// html
<ul id="mylist">
  <li><a href="https://www.mi.com">xiaomi</a></li>
  <li><a href="https://www.miui.com">miui</a></li>
</ul>

// javascript 現在需要新增帶有如下資訊的li節點
let data = [
  {
    name: 'tom',
    url: 'https://www.baidu.com',
  },
  {
      name: 'ann',
      url: 'https://www.techFE.com'
  }
]

  

首先,我們先寫一個通用的用於將新資料更新到指定節點的方法:

// javascript
function appendNode($node, data) {
  var a, li;
  
  for(let i = 0, max = data.length; i < max; i++) {
    a = document.createElement('a');
    li = document.createElement('li');
    a.href = data[i].url;
    
    a.appendChild(document.createTextNode(data[i].name));
    li.appendChild(a);
    $node.appendChild(li);
  }
}

  

首先我們忽視所有的重排因素,大家肯定會這麼寫:

let ul = document.querySelector('#mylist');
appendNode(ul, data);

  

使用這種方法,在沒有任何優化的情況下,每次插入新的節點都會造成一次重排(這幾部分我們都先討論重排,因為重排是效能優化的第一步)。考慮這個場景,如果我們新增的節點數量眾多,而且佈局複雜,樣式複雜,那麼能想到的是你的頁面一定非常卡頓。我們利用批量修改DOM的優化手段來進行重構
1)隱藏元素,進行修改後,然後再顯示該元素

let ul = document.querySelector('#mylist');
ul.style.display = 'none';
appendNode(ul, data);
ul.style.display = 'block';

  

這種方法造成倆次重排,分別是控制元素的顯示與隱藏。對於複雜的,數量巨大的節點段落可以考慮這種方法。為啥使用display屬性呢,因為display為none的時候,元素就不在文件流了,還不熟悉的老鐵,手動Google一下,display:none, opacity: 0, visibility: hidden的區別

2)使用文件片段建立一個子樹,然後再拷貝到文件中

let fragment = document.createDocumentFragment();
appendNode(fragment, data);
ul.appendChild(fragment);

  

我是比較喜歡這種方法的,文件片段是一個輕量級的document物件,它設計的目的就是用於更新,移動節點之類的任務,而且文件片段還有一個好處就是,當向一個節點新增文件片段時,新增的是文件片段的子節點群,自身不會被新增進去。不同於第一種方法,這個方法並不會使元素短暫消失造成邏輯問題。上面這個例子,只在新增文件片段的時候涉及到了一次重排。

3)將原始元素拷貝到一個獨立的節點中,操作這個節點,然後覆蓋原始元素

let old = document.querySelector('#mylist');
let clone = old.cloneNode(true);
appendNode(clone, data);
old.parentNode.replaceChild(clone, old);

  

可以看到這種方法也是隻有一次重排。總的來說,使用文件片段,可以操作更少的DOM(對比使用克隆節點),最小化重排重繪次數。

3.1.3 快取佈局資訊

快取佈局資訊這個概念,在《高效能JavaScript》DOM效能優化中,多次提到類似的思想,比如我現在要得到頁面ul節點下面的100個li節點,最好的辦法就是第一次獲取後就儲存起來,減少DOM的訪問以提升效能,快取佈局資訊也是同樣的概念。前面有講到,當訪問諸如offsetLeft,clientTop這種屬性時,會衝破瀏覽器自有的優化————通過佇列化修改和批量執行的方法,減少重排/重繪版次。所以我們應該儘量減少對佈局資訊的查詢次數,查詢時,將其賦值給區域性變數,使用區域性變數參與計算。
看以下樣例:
將元素div向右下方平移,每次移動1px,起始位置100px, 100px。效能糟糕的程式碼:

div.style.left = 1 + div.offsetLeft + 'px';
div.style.top = 1 + div.offsetTop + 'px';

  

這樣造成的問題就是,每次都會訪問div的offsetLeft,造成瀏覽器強制重新整理渲染佇列以獲取最新的offsetLeft值。更好的辦法就是,將這個值儲存下來,避免重複取值

 
current = div.offsetLeft;
div.style.left = 1 + ++current + 'px';
div.style.top = 1 + ++current + 'px';

  

四、總結

重排和重繪是DOM程式設計中耗能的主要原因之一,平時涉及DOM程式設計時可以參考以下幾點:

  1. 儘量不要在佈局資訊改變時做查詢(會導致渲染佇列強制重新整理)
  2. 同一個DOM的多個屬性改變可以寫在一起(減少DOM訪問,同時把強制渲染佇列重新整理的風險降為0)
  3. 如果要批量新增DOM,可以先讓元素脫離文件流,操作完後再帶入文件流,這樣只會觸發一次重排(fragment元素的應用)
  4. 將需要多次重排的元素,position屬性設為absolute或fixed,這樣此元素就脫離了文件流,它的變化不會影響到其他元素。例如有動畫效果的元素就最好設定為絕對定位。