DOM優化方案

windlany發表於2019-02-28

1、重繪和重排

1.1 重繪和重排是什麼

重繪是指一些樣式的修改,元素的位置和大小都沒有改變;
重排是指元素的位置或尺寸發生了變化,瀏覽器需要重新計算渲染樹,而新的渲染樹建立後,瀏覽器會重新繪製受影響的元素。

1.2 瀏覽器渲染頁面

去參加面試總會被問到一個問題,那就是“向瀏覽器輸入一行url會發生什麼?”,這個問題的答案除了要回答網路方面的知識還牽扯到瀏覽器渲染頁面問題。當我們的瀏覽器接收到從伺服器響應的頁面之後便開始逐行渲染,遇到css的時候會非同步的去計算屬性值,再繼續向下解析dom解析完畢之後形成一顆DOM樹,將非同步計算好的樣式(樣式盒子)與DOM樹相結合便成為了一個Render樹,再由瀏覽器繪製在頁面上。DOM樹與Render樹的區別在於:樣式為display:none;的節點會在DOM樹中而不在渲染樹中。瀏覽器繪製了之後便開始解析js檔案,根據js來確定是否重繪和重排。

1.3 引起重繪和重排的原因

產生重繪的因素:

  • 改變visibility、outline、背景色等樣式屬性,並沒有改變元素大小、位置等。瀏覽器會根據元素的新屬性重新繪製。

產生重排的因素:

  • 內容改變
  • 文字改變或圖片尺寸改變
  • DOM元素的幾何屬性的變化
    • 例如改變DOM元素的寬高值時,原渲染樹中的相關節點會失效,瀏覽器會根據變化後的DOM重新排建渲染樹中的相關節點。如果父節點的幾何屬性變化時,還會使其子節點及後續兄弟節點重新計算位置等,造成一系列的重排。
  • DOM樹的結構變化
    • 新增DOM節點、修改DOM節點位置及刪除某個節點都是對DOM樹的更改,會造成頁面的重排。瀏覽器佈局是從上到下的過程,修改當前元素不會對其前邊已經遍歷過的元素造成影響,但是如果在所有的節點前新增一個新的元素,則後續的所有元素都要進行重排。
  • 獲取某些屬性
    • 除了渲染樹的直接變化,當獲取一些屬性值時,瀏覽器為取得正確的值也會發生重排,這些屬性包括:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、 clientTop、clientLeft、clientWidth、clientHeight、getComputedStyle()。
  • 瀏覽器視窗尺寸改變
    • 視窗尺寸的改變會影響整個網頁內元素的尺寸的改變,即DOM元素的集合屬性變化,因此會造成重排。
  • 滾動條的出現(會觸發整個頁面的重排)

總之你要知道,js是單執行緒的,重繪和重排會阻塞使用者的操作以及影響網頁的效能,當一個頁面發生了多次重繪和重排比如寫一個定時器每500ms改變頁面元素的寬高,那麼這個頁面可能會變得越來越卡頓,我們要儘可能的減少重繪和重排。那麼我們對於DOM的優化也是基於這個開始。

2、優化

2.1 減少訪問

減少訪問次數自然是想到快取元素,但是要注意

var ele = document.getElementById(`ele`);
複製程式碼

這樣並不是對ele進行快取,每一次呼叫ele還是相當於訪問了一次id為ele的節點。

2.1.1 快取NodeList

var foods = document.getElementsByClassName(`food`);
複製程式碼

我們可以用foods[i]來訪問第i個class為food的元素,不過這裡的foods並不是一個陣列,而是一個NodeList。NodeList是一個類陣列,儲存了一些有序的節點並可以通過位置來訪問這些節點。NodeList物件是動態的,每一次訪問都會執行一次基於文件的查詢。所以我們要儘量減少訪問NodeList的次數,可以考慮將NodeList的值快取起來。

// 優化前
var lis = document.getElementsByTagName(`li`);

for(var i = 0; i < lis.length; i++) {
     // do something...  
}

// 優化後,將length的值快取起來就不會每次都去查詢length的值
var lis = document.getElementsByTagName(`li`);

for(var i = 0, len = lis.length; i < len; i++) {
     // do something...  
}
複製程式碼

而且由於NodeList是動態變化的,所以如果不快取可能會引起死迴圈,比如一邊新增元素,一邊獲取NodeList的length。

2.1.2 改變選擇器

獲取元素最常見的有兩種方法,getElementsByXXX()和queryselectorAll(),這兩種選擇器區別是很大的,前者是獲取動態集合,後者是獲取靜態集合,舉個例子。

// 假設一開始有2個li
var lis = document.getElementsByTagName(`li`);  // 動態集合
var ul = document.getElementsByTagName(`ul`)[0];
 
for(var i = 0; i < 3; i++) {
    console.log(lis.length);
    var newLi = document.createElement(`li`); 
    ul.appendChild(newLi);
}
// 輸出結果:2, 3, 4
// 優化後
var lis = document.querySelectorAll(`li`);  // 靜態集合 
var ul = document.getElementsByTagName(`ul`)[0];
 
for(var i = 0; i < 3; i++) {
    console.log(lis.length);
    var newLi = document.createElement(`li`); 
    ul.appendChild(newLi);
}
// 輸出結果:2, 2, 2
複製程式碼

對靜態集合的操作不會引起對文件的重新查詢,相比於動態集合更加優化。

2.1.3 避免不必要的迴圈

// 優化前
for(var i = 0; i < 10; i++) {
    document.getElementById(`ele`).innerHTML += `a`; 
} 
// 優化後 
var str = ``; 
for(var i = 0; i < 10; i++) {
    str += `a`; 
}
document.getElementById(`ele`).innerHTML = str;
複製程式碼

優化前的程式碼訪問了10次ele元素,而優化後的程式碼只訪問了一次,大大的提高了效率。

2.1.4 事件委託

js中的事件函式都是物件,如果事件函式過多會佔用大量記憶體,而且繫結事件的DOM元素越多會增加訪問dom的次數,對頁面的互動就緒時間也會有延遲。所以誕生了事件委託,事件委託是利用了事件冒泡,只指定一個事件處理程式就可以管理某一型別的所有事件。

// 事件委託前
var lis = document.getElementsByTagName(`li`);
for(var i = 0; i < lis.length; i++) {
   lis[i].onclick = function() {
      console.log(this.innerHTML);
   };  
}    

// 事件委託後
var ul = document.getElementsByTagName(`ul`)[0];
ul.onclick = function(event) {
   console.log(event.target.innerHTML);
};
複製程式碼

事件委託前我們訪問了lis.length次li,而採用事件委託之後我們只訪問了一次ul。

2.2 減少重繪重排

2.2.1 改變一個dom節點的多個樣式

我們想改變一個div元素的寬度和高度,通常做法可以是這樣

var div = document.getElementById(`div1`);
div.style.width = `220px`;
div.style.height = `300px`;
複製程式碼

以上操作改變了元素的兩個屬性,訪問了三次dom,觸發兩次重排與兩次重繪。我們說過優化是減少訪問次數以及減少重繪重排次數,從這個出發點可不可以只訪問一次元素以及重排次數降低到1呢?顯然是可以的,我們可以在css裡寫一個class

/* css
.change {
    width: 220px;
    height: 300px;
}
*/
document.getElementById(`div`).className = `change`;
複製程式碼

這樣就達到了一次操作多個樣式

2.2.2 批量修改dom節點樣式

上面程式碼的情況是針對於一個dom節點的,如果我們要改變一個dom集合的樣式呢?
第一時間想到的方法是遍歷集合,給每個節點加一個className。再想想這樣豈不是訪問了多次dom節點?想想文章開頭說的dom樹和渲染樹的區別,如果一個節點的display屬性為none那麼這個節點不會存在於render樹中,意味著對這個節點的操作也不會影響render樹進而不會引起重繪和重排,基於這個思路我們可以實現優化:

  • 將待修改的集合的父元素display: none;
  • 之後遍歷修改集合節點
  • 將集合父元素display: block;
// 假設增加的class為.change
var lis = document.getElementsByTagName(`li`);  
var ul = document.getElementsByTagName(`ul`)[0];

ul.style.display = `none`;

for(var i = 0; i < lis.length; i++) {
    lis[i].className = `change`;  
}

ul.style.display = `block`;
複製程式碼

2.2.3 DocumentFragment

createDocumentFragment()方是用了建立一個虛擬的節點物件,或者說,是用來建立文件碎片節點。它可以包含各種型別的節點,在建立之初是空的。

DocumentFragment節點不屬於文件樹,繼承的parentNode屬性總是null。它有一個很實用的特點,當請求把一個DocumentFragment節點插入文件樹時,插入的不是DocumentFragment自身,而是它的所有子孫節點。 這個特性使得DocumentFragment成了佔位符,暫時存放那些一次插入文件的節點

另外,當需要新增多個dom元素時,如果先將這些元素新增到DocumentFragment中,再統一將DocumentFragment新增到頁面,會減少頁面渲染dom的次數,效率會明顯提升。

使用方法:

var frag = document.createDocumentFragment(); //建立一個DOM片段		
for(var i=0;i<10000;i++) {				
    var li = document.createElement("li");			
    li.innerHTML = i;		
    frag.appendChild(li);  //將li元素加到文件碎片上	
} 			
ul.appendChild(frag);  //將文件碎片加到ul上
複製程式碼

3、總結

  • 減少訪問dom的次數
    • 快取節點屬性值
    • 選擇器的使用
    • 避免不必要的迴圈
    • 事件委託
  • 減少重繪與重排
    • 使用className改變多個樣式
    • 使父元素脫離文件流再恢復
    • DocumentFragment

如果以後看到其他優化方案我會更新,歡迎大家與我交流。

參考文件:

相關文章