瀏覽器中的DOM
DOM,即文檔對象模型,是一個獨立於語言的、用於操作XML和HTML文檔的程序接口。
儘管DOM是一個與語言無關的接口,但它在瀏覽器中的接口是用JavaScript實現的。
瀏覽器中通常會把DOM和JavaScript獨立實現,例如在Chrome瀏覽器中,網頁的渲染是由WebKit來實現而JavaScript的執行則是由Google自行開發的V8引擎來完成。
可以看出DOM的操作和JavaScript的執行通過接口連接。可以將DOM和JavaScript想象成兩個島嶼,這兩個島之間通過一座需要收費的橋樑連接。那麼每次通過JavaScript來訪問DOM都需要途徑這座橋,那麼次數越多則費用越高。所以,推薦的做法就是儘可能的少去DOM島,儘可能留在JavaScript島上。
DOM的訪問與修改
訪問DOM的代價是高昂的,可能比你我想象的還要高昂,爲了對這種代價有個量化的瞭解,來看下面這兩個例子:
function innerHTMLLoop(){
for(let i=0; i<15000; i++){
document.getElementById('div').innerHTML += 'a';
}
}
function innerHTMLLoop2(){
let tmp = '';
for(let i=0; i<15000; i++){
tmp += 'a';
}
document.getElementById('div').innerHTML += tmp;
}
這兩個函數,實現的都是同樣的功能,即往頁面中id
爲div
的元素的innerHTML
屬性後添加15000個字符"a"。
看似都很簡單的兩個函數,它們的實際性能可謂是雲泥之別。
使用console.time
來對兩個函數的運行時間計時。可以發現兩者的性能差距竟有數百倍之多。
原因在於,innerHTMLLoop2
中通過tmp
這個局部變量將要追加的內容先保存下來,然後再一次性將值追加到innerHTML
屬性中。也就是說,只訪問了一次DOM。
而innerHTMLLoop
則是每次循環都訪問了DOM,訪問了15000次,於是就產生了巨大的性能開銷。
結果顯而易見,訪問DOM的次數越多,代碼的運行速度越慢。
innerHTML與DOM方法
修改頁面除開使用createElement
、appendChild
之類的DOM方法,還可以直接修改innerHTML
屬性。
然而這兩種方法在性能上有差異嗎?
在都做了優化的情況下(即儘量少的訪問DOM和儘量少的修改innerHTML),兩者的差別很小,修改innerHTML
的方法略微佔優。但是在基於WebKit內核瀏覽器中,卻恰恰相反,使用DOM方法會略勝一籌。
因此,選擇哪種方法取決於用戶經常使用的瀏覽器。
HTML集合
所謂HTML集合就是一個包含了DOM節點引用的類數組對象。
以下方法的返回值就是一個集合:
document.getElementsByClassName
document.getElementsByTagName
document.getElementByName
還有下面的屬性也同樣返回一個集合:
document.images
document.links
document.forms
以上的方法或屬性返回的都是一個HTML集合對象(HTMLCollection)。這是個類似數組的列表,區別在於它沒有push
和pop
方法,但它提供了length
屬性,並且能以數字爲索引訪問集合中元素。
DOM標準中規定,HTML集合以一種“假定實時態”(asumed to be lived)實時存在。換句話說,當底層文檔對象更新時,集合也會自動更新。
每次需要訪問集合中的信息,都會重複查詢的過程,哪怕是獲取集合的長度。
下列代碼可以很好的體現集合的實時性:
let collections = document.getElementsByTagName('div');
for(let i=0; i<collection.length; i++){
document.body.appendChild(document.createElemen('div'));
}
這段代碼會陷入死循環,原因在於HTML集合是動態的,也就是說循環體中每次添加一個div
元素,在下一次循環開始時集合的長度都會加1。
訪問集合元素時使用局部變量
需要多次訪問同一個DOM屬性或方法需要多次訪問時,最好使用一個局部變量緩存此成員。
當遍歷一個集合時,第一優化原則是把集合存儲在局部變量中,並把length
存儲在循環外部。
// 最慢
function collectionGlobal(){
let coll = document.getElementsByTagName('div');
let lenth = coll.length;
let name = ' ;
for(let i=0;i<length;i++){
name = document.getElementsByTagName('div')[i].nodeName;
name = document.getElementsByTagName('div')[i].nodeType;
name = document.getElementsByTagName('div')[i].tagName;
}
return name;
}
//最快
function collectionNodeLocal(){
let coll = document.getElementsByTagName('div');
let lenth = coll.length;
let name = ' ;
let el = null;
for(let i=0; i<length; i++){
el = coll[i];
name = el.nodeName;
name = el.nodeType;
name = el.tagName;
}
}
重繪與迴流
瀏覽器下載完所有的資源之後–HTML、JavaScript、CSS等,會解析並生成兩個內部數據結構:DOM樹、渲染樹。
DOM樹用以表示頁面結構,渲染樹表示DOM節點該如何顯示。
一旦DOM和渲染樹構建完成,瀏覽器就開始繪製頁面元素。
當DOM的變化影響了元素的集合屬性(如寬和高),比如說改變邊框寬度或給增加文字導致函數增加,那麼瀏覽器都需要重新計算元素的集合屬性,並重新構建渲染樹。這個過程就稱爲迴流。
完成迴流之後,瀏覽器會重新繪製受影響的部分,這個過程稱爲“重繪”。
並不是所有的DOM變化都會引起迴流,改變元素的背景色僅會引起重繪而已。
重繪與迴流都是代價高昂的操作,所以應當儘可能的減少這類過程的發生。
迴流何時發生
- 添加或刪除可見的DOM元素
- 元素位置改變
- 元素尺寸改變(
padding
、margin
、border
等等) - 內容改變
- 頁面渲染器初始化
- 瀏覽器窗口改變
根據改變的範圍和程度,渲染樹中或大或小的對應的部分也需要重新計算。有些改變會觸發整個頁面的迴流,例如滾動條出現時。
渲染樹的排隊和刷新
因爲迴流十分消耗性能,大多數瀏覽器通過隊列化和批量執行來優化迴流過程。即當一個操作會觸發迴流時,瀏覽器並不立即執行而是放進一個隊列,將一系列的迴流整合到一次執行。
然而,有一些操作會要求瀏覽器強制刷新隊列並要求隊列中的任務立即執行。
例如:
offsetTop
、offsetLeft
、offsetHeight
、offsetWidth
scrollTop
、scrollLeft
、scrollHeight
、scrollWidth
clientTop
、clientLeft
、clientHeight
、clientWidth
getComputedStyle()
以上屬性和方法需要返回最新的佈局信息,因此瀏覽器不得不執行渲染隊列中的“待處理變化”,並觸發迴流以返回正確的值。
最小化重繪和迴流
知道了了重繪和迴流代價高昂,接着就是如何減少它們的發生了。爲了減少發生次數,應該合併多次對DOM和樣式的修改,然後一次處理掉。
例如:
let el = document.getElementById('div');
el.style.borderLeft = '1px';
el.style.padding = '5px';
el.style.marginLeft = '10px';
上面代碼中所修改的3個CSS屬性每一個都會影響元素的集合結構,最糟糕的情況下可能會因此瀏覽器迴流3次。
一個能夠達到同樣效果且效率更高的方式是合併所有的改變然後一次處理,可以使用cssText
實現:
let el = document.getElementById('div');
el.style.cssText += 'border-left: 1px; padding: 5px; margin-left: 10px';
批量修改DOM
當需要對DOM元素進行一系列操作是,可以通過以下步驟減少重繪和迴流的次數:
- 使元素脫離文檔流
- 對其應用多重改變
- 把元素帶回文檔中
有3中方法可以使DOM脫離文檔:
- 隱藏元素
- 使用文檔片段(Document fragment),在當前DOM之外構建一個子樹,再把它考本會文檔
- 將原始元素拷貝到一個脫離文檔的節點中,修改副本,完成後再替換原始元素