天生就慢的 DOM 如何優化?
用腳本進行DOM操作的代價是很昂貴的,它是富web應用中最常見的性能瓶頸。主要有以下三種問題:
-
訪問和修改DOM元素
-
修改DOM元素的樣式導致repaint和reflow
-
通過DOM事件處理與用戶進行交互
瀏覽器中的DOM
DOM是(Document Object Model)一個與語言無關的、用來操作XML和HTML文檔的應用程序接口(Application Program Interface)。盡管DOM與語言無關,但是在瀏覽器中的接口卻是用JavaScript來實現的。
一個前端小知識
瀏覽器通常會把js和DOM分開來分別獨立實現。
舉個
栗子
冷知識,在IE中,js的實現名為JScript,位于jscript.dll文件中;DOM的實現則存在另一個庫中,名為mshtml.dll(Trident)。
Chrome中的DOM實現為webkit中的webCore,但js引擎是Google自己研發的V8。
Firefox中的js引擎是SpiderMonkey,渲染引擎(DOM)則是Gecko。
DOM,天生就慢
前面的小知識中說過,瀏覽器把實現頁面渲染的部分和解析js的部分 分開來實現 ,既然是分開的,一旦兩者需要產生連接,就要付出代價。
兩個例子:
-
小明和小紅是兩個不同學校的學生,兩個人家里經濟條件都不太好,買不起手機(好尷尬的設定Orz...),所以只能通過寫信來互相交流,這樣的過程肯定比他倆面對面交談時所需要花費的代價大(額外的事件、寫信的成本等)。
-
官方例子:把DOM和js(ECMAScript)各自想象為一座島嶼,它們之間用收費橋進行連接。ECMAScript每次訪問DOM,都要途徑這座橋,并交納“過橋費”。訪問DOM的次數越多,費用也就越高。
因此,推薦的做法是: 盡可能的減少過橋的次數,努力待在ECMAScript島上 。
DOM的訪問與修改
前面說到訪問DOM需要交納“過橋費”,而修改DOM元素則代價更為昂貴,因為它會導致瀏覽器重新計算頁面的幾何變化。
來看一段代碼:
function innerHTMLLoop(){
for (var count = 0; count < 15000; count++){
document.getElementById('text').innerHTML += 'dom';
}
}
這段代碼,每次循環會訪問兩次特定的元素:第一次讀取這個元素的innerHTML屬性,第二次重寫它。
看清楚了這一點,不難得到一個效率更高的版本:
function innerHTMLLoop2(){
var content = '';
for (var count = 0; count < 15000; count++){
content += 'dom';
}
document.getElementById('text').innerHTML += content;
}
用一個局部變量包層每次更新后的內容,等待循環結束后,一次性的寫入頁面(盡可能的把更多的工作交給js的部分來做)。
根據統計,在所有的瀏覽器中,修改后的版本都運行的更快(優化幅度最明顯的是IE8,使用后者比使用前者快273倍)。
HTML元素集合
HTML元素集合是包含了DOM節點引用的 類數組 對象。
可以用以下方法或屬性得到一個HTML元素集合:
-
document.getElementsByName()
-
document.getElementsByTagName()
-
document.getElementsByClassName()
-
document.images 頁面中所有img元素
-
document.links 頁面中所有a元素
-
document.forms 頁面中所有表單元素
-
document.forms[0].elements 頁面中第一個表單的所有字段
HTML元素集合處于一種“實時的狀態”,這意味著當底層文檔對象更新時,它也會自動更新,也就是說,HTML元素集合與底層的文檔對象之間保持的連接。正因如此,每當你想從HTML元素集合中獲取一些信息時,都會產生一次查詢操作,這正是低效之源。
昂貴的集合
//這是一個死循環
//不管你信不信,反正我是信了
var alldivs = document.getElementsByTagName('div');
for (var i = 0; i < alldivs.length; i++){
document.body.appendChild(document.createElement('div'));
}
乍一看,這段代碼只是單純的把頁面中的div數量翻倍:遍歷所有的div,每次創建一個新的div并創建到添加到body中。
但事實上,這是一個死循環:因為循環的退出條件alldivs.length在每一次循環結束后都會增加,因為這個HTML元素集合反映的是底層文檔元素的實時狀態。
接下來,我們通過這段代碼,對一個HTML元素集合做一些處理:
function toArray(coll){
for (var i = 0, a = [], len = coll.lengthl i < len; i++){
a[i] = coll[i];
}
return a;
}
//將一個HTML元素集合拷貝到一個數組中
var coll = document.getElementsByTagName('div');
var arr = toArray(coll);
現在比較以下兩個函數:
function loopCollection(){
for (var count = 0; count < coll.length; count++){
//processing...
}
}
function loopCopiedArray(){
for (var count = 0; count < arr.length; count++){
//processing...
}
}
在IE6中,后者比前者快114倍;IE7中119倍;IE8中79倍...
所以,在相同的內容和數量下,遍歷一個數組的速度明顯快于遍歷一個HTML元素集合。
由于在每一次迭代循環中,讀取元素集合的length屬性會引發集合進行更新,這在所有的瀏覽器中都有明顯的性能問題,所以你也可以這么干:
function loopCacheLengthCollection(){
var coll = document.getElementsByTagName('div'),
len = coll.length;
for (var count = 0; count < len; count++){
//processing...
}
}
這個函數和上面的loopCopiedArray()一樣快。
訪問集合元素時使用局部變量
一般來說,對于任何類型的DOM訪問,當同一個DOM屬性或者方法需要被多次訪問時,最好使用一個局部變量緩存此成員。當遍歷一個集合時,首要優化原則是把集合存儲在局部變量中,并把length緩存在循環外部,然后使用局部變量訪問這些需要多次訪問的元素。
一個栗子,在循環之中訪問每個元素的三個屬性。
function collectionGlobal(){
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++){
name = document.getElementsByTagName('div')[count].nodeName;
name = document.getElementsByTagName('div')[count].nodeType;
name = document.getElementsByTagName('div')[count].tagName;
//我的天不會有人真的這么寫吧...
}
return name;
}
上面這段代碼,大家不要當真...正常人肯定是寫不出來的...這里是為了對比一下,所以把這種最慢的情況寫給大家看。
接下來,是一個稍微優化了的版本:
function collectionLocal(){
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < length; count++){
name = coll[count].nodeName;
name = coll[count].nodeType;
name = coll[count].tagName;
}
return name;
}
這次就看起來正常很多了,最后是這次優化之旅的最終版本:
function collectionNodesLocal(){
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '',
ele = null;
for (var count = 0; count < len; count++){
ele = coll[count];
name = ele.nodeName;
name = ele.nodeType;
name = ele.tagName;
}
return name;
}
遍歷DOM
在DOM中爬行
通常你需要從某一個DOM元素開始,操作周圍的元素,或者遞歸查找所有的子節點。
考慮下面兩個等價的栗子:
//1
function testNextSibling(){
var el = document.getElementById('mydiv'),
ch = el.firstChild,
name = '';
do {
name = ch.nodeName;
} while (ch = ch.nextSibling);
return name;
}
//2
function testChildNodes(){
var el = document.getElementById('mydiv'),
ch = el.childNodes,
len = ch.length,
//childNodes是一個元素集合,因此在循環中主席緩存length屬性以避免迭代更新
name = '';
for (var count = 0; count < len; count++){
name = ch[count].nodeName;
}
return name;
}
在不同瀏覽器中,兩種方法的運行時間幾乎相等。但在老版本的IE瀏覽器中,nextSibling的性能比childNodes更好一些。
元素節點
我們知道,DOM節點有以下五種分類:
-
整個文檔是一個 文檔節點
-
每個HTML元素是 元素節點
-
HTML元素內的文本是 文本節點
-
每個HTML屬性是 屬性節點
-
注釋是注釋節點
諸如childNodes、firstChild、nextSibling這些DOM屬性是不區分元素節點和其他類型的節點的,但往往我們只需要訪問元素節點,此時需要做一些過濾的工作。事實上,這些類型檢查的過程都是不必要的DOM操作。
許多現代瀏覽器提供的API只返回元素節點,如果可用的話推薦直接只用這些API,因為它們的執行效率比自己在js中過濾的效率要高。
-
現代瀏覽器提供的API(被替換的API)
-
children(childNodes)
-
childElementCount (childNodes.length)
-
firstElementChild (firstChild)
-
lastElementChild (lastChild)
-
nextElementSibling (nextSibling)
-
previousElementSibling (previousSibling)
使用這些新的API,可以直接獲取到元素節點,也正是因此,其速度也更快。
選擇器API
有時候為了得到需要的元素列表,開發人員不得不組合調用getElementById、getElementsByTagName,并遍歷返回的節點,但這種繁密的過程效率低下。
最新的瀏覽器提供了一個傳遞參數為CSS選擇器的名為querySelectorAll()的原生DOM方法。這種方式自然比使用js和DOM來遍歷查找元素要快的多。
比如,
var elements = document.querySelectorAll('#menu a');
這一段代碼,返回的是一個NodeList————包含著匹配節點的類數組對象。與之前不同的是,這個方法不會返回HTML元素集合,因此返回的節點不會對應實時的文檔結構,也避免了之前由于HTML集合引起的性能(潛在邏輯)問題。
如果不使用querySelectorAll(),我們需要這樣寫:
var elements = document.getElementById('menu').getElementsByTagName('a');
不僅寫起來更麻煩了,更要注意的是,此時的elements是一個HTML元素集合,所以還需要把它copy到數組中,才能得到一個與前者相似的靜態列表。
還有一個querySelector()方法,用來獲取第一個匹配的節點。
重繪與重排(Repaints & Reflows)
瀏覽器用來顯示頁面的所有“組件”,有:HTML標簽、js、css、圖片——之后會解析并生成兩個內部的數據結構:
-
DOM樹(表示頁面結構)
-
渲染樹(表示DOM節點應該如何表示)
DOM樹中的每一個需要顯示的節點在渲染樹中至少存在一個對應的節點。
渲染樹中的節點被稱為“幀(frames)”或“盒(boxes)”,符合css盒模型的定義,理解頁面元素為一個具有padding、margin、borders和position的盒子。
一旦渲染樹構建完成,瀏覽器就開始顯示頁面元素,這個過程稱為 繪制(paint) 。
當DOM的變化影響了元素的幾何屬性(寬、高)——比如改變改變了邊框的寬度或者給一個段落增加一些文字導致其行數的增加——瀏覽器就需要重新計算元素的幾何屬性,同樣,頁面中其他元素的幾何屬性和位置也會因此受到影響。
瀏覽器會使渲染樹中收到影響的部分消失,重新構建渲染樹,這個過程稱為“ 重排(reflow) ”。重排完成之后,瀏覽器會重新將受到影響的部分繪制到瀏覽器中,這個過程稱之為“ 重繪(repaint) ”。
如果改變的不是元素的幾何屬性,如:改變元素的背景顏色,不會發生重排,只會發生一次重繪,因為元素的布局并沒有改變。
不管是重繪還是重排,都是代價昂貴的操作,它們會導致web應用程序的UI反應遲鈍,應當盡可能的減少這類過程的發生。
重排何時發生?
-
添加或刪除可見的DOM元素
-
元素位置的改變
-
元素尺寸的改變(padding、margin、border、height、width)
-
內容改變(文本改變或圖片尺寸改變)
-
頁面渲染器初始化
-
瀏覽器窗口尺寸改變
-
滾動條的出現(會觸發整個頁面的重排)
最小化重繪和重排
改變樣式
一個栗子:
var el = document.getElementById('mydiv');
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';
示例中,元素的三個樣式被改變,而且每一個都會影響元素的幾何結構。在最糟糕的情況下,這段代碼會觸發三次重排(大部分現代瀏覽器為此做了優化,只會觸發一次重排)。從另一個角度看,這段代碼四次訪問DOM,可以被優化。
var el = document.getElementById('mydiv');
//思路:合并所有改變然后一次性處理
//method_1:使用cssText屬性
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px';
//method_2:修改類名
el.className = 'anotherClass';
批量修改DOM
當你需要對DOM元素進行一系列操作的時候,不妨按照如下步驟:
-
使元素脫離文檔流
-
對其應用多重改變
-
把元素帶回文檔中
上面的這一套組合拳中,第一步和第三部分別會觸發一次重排。 但是如果你忽略了這兩個步驟,那么在第二步所產生的任何修改都會觸發一次重排 。
在此安利三種可以使DOM元素脫離文檔流的方法:
-
隱藏元素
-
使用文檔片段(document fragment)在當前DOM之外構建一個子樹,再把它拷貝回文檔
-
將原始元素拷貝到一個脫離文檔的節點中,修改副本,完成后再替換原始元素
讓動畫元素脫離文檔流
一般情況下,重排只影響渲染樹中的一小部分,但也可能影響很大的一部分,甚至是整個渲染樹。
瀏覽器所需的重排次數越少,應用程序的響應速度也就越快。
想象這樣一種情況,頁面的底部有一個動畫,會推移頁面整個余下的部分,這將是一次代價昂貴的大規模重排!用戶也勢必會感覺到頁面一卡一卡的。
因此,使用以下步驟可以避免頁面中的大部分重排:
-
使用 絕對定位 讓頁面上的動畫元素脫離文檔流
-
動畫展示階段
-
動畫結束時,將元素恢復定位。
IE的:hover
從IE7開始,IE允許在任何元素上使用:hover這個css選擇器。
然而,如果你有大量元素使用了:hover,你會發現,賊喇慢!
事件委托(Event Delegation)
這一個優化手段也是在前端求職面試中的高頻題目。
當頁面中有大量的元素,并且這些元素都需要綁定事件處理器。
每綁定一個事件處理器都是有代價的,要么加重了頁面負擔,要么增加了運行期的執行時間。再者,事件綁定會占用處理時間,而且瀏覽器需要跟蹤每個事件處理器,這也會占用更多的內存。還有一種情況就是,當這些工作結束時,這些事件處理器中的絕大多數都是不再需要的(并不是100%的按鈕或鏈接都會被用戶點擊),因此有很多工作是沒有必要的。
事件委托的 原理 很簡單—— 事件逐層冒泡并能被父級元素捕獲 。
使用事件委托,只需要給外層元素綁定一個處理器,就可以處理在其子元素上觸發的所有事件。
有以下幾點需要注意:
-
訪問事件對象,判斷事件源
-
按需取消文檔樹中的冒泡
-
按需阻止默認動作
小結
訪問和操作DOM需要穿越連接ECMAScript和DOM兩個島嶼之間的橋梁,為了盡可能的減少“過橋費”,有以下幾點需要注意:
-
最小化DOM訪問次數
-
對于需要多次訪問的DOM節點,使用局部變量存儲其引用
-
如果要操作一個HTML元素集合,建議把它拷貝到一個數組中
-
使用速度更快的API:比如querySelectorAll
-
留意重排和重繪的次數
-
事件委托
來自:https://segmentfault.com/a/1190000008267184