DOM 優化原理

DOM 爲什麼這麼慢

因爲收了“過路費”

把 DOM 和 JavaScript 各自想象成一個島嶼,它們之間用收費橋樑連接。——《高性能 JavaScript》

JS 是很快的,在 JS 中修改 DOM 對象也是很快的。在JS的世界裏,一切是簡單的、迅速的。但 DOM 操作並非 JS 一個人的獨舞,而是兩個模塊之間的協作。

JS 引擎和渲染引擎(瀏覽器內核)是獨立實現的。當我們用 JS 去操作 DOM 時,本質上是 JS 引擎和渲染引擎之間進行了“跨界交流”。這個“跨界交流”的實現並不簡單,它依賴了橋接接口作爲“橋樑”(如下圖)。

過“橋”要收費——這個開銷本身就是不可忽略的。我們每操作一次 DOM(不管是爲了修改還是僅僅爲了訪問其值),都要過一次“橋”。過“橋”的次數一多,就會產生比較明顯的性能問題。因此“減少 DOM 操作”的建議,並非空穴來風。

對 DOM 的修改引發樣式的更迭

過橋很慢,到了橋對岸,我們的更改操作帶來的結果也很慢。

很多時候,我們對 DOM 的操作都不會侷限於訪問,而是爲了修改它。當我們對 DOM 的修改會引發它外觀(樣式)上的改變時,就會觸發迴流重繪

這個過程本質上還是因爲我們對 DOM 的修改觸發了渲染樹(Render Tree)的變化所導致的:

  • 迴流:當我們對 DOM 的修改引發了 DOM 幾何尺寸的變化(比如修改元素的寬、高或隱藏元素等)時,瀏覽器需要重新計算元素的幾何屬性(其他元素的幾何屬性和位置也會因此受到影響),然後再將計算的結果繪製出來。這個過程就是迴流(也叫重排)。

  • 重繪:當我們對 DOM 的修改導致了樣式的變化、卻並未影響其幾何屬性(比如修改了顏色或背景色)時,瀏覽器不需重新計算元素的幾何屬性、直接爲該元素繪製新的樣式(跳過了上圖所示的迴流環節)。這個過程叫做重繪。

由此我們可以看出,重繪不一定導致迴流,迴流一定會導致重繪。硬要比較的話,迴流比重繪做的事情更多,帶來的開銷也更大。但這兩個說到底都是吃性能的,所以都不是什麼善茬。我們在開發中,要從代碼層面出發,儘可能把迴流和重繪的次數最小化。

給你的 DOM “提提速”

知道了 DOM 慢的原因,我們就可以對症下藥了。

減少 DOM 操作:少交“過路費”、避免過度渲染

我們來看這樣一個例子,HTML 內容如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>DOM操作測試</title>
</head>
<body>
  <div id="container"></div>
</body>
</html>

此時我有一個假需求——我想往 container 元素裏寫 10000 句一樣的話。如果我這麼做:

for(var count=0;count<10000;count++){ 
  document.getElementById('container').innerHTML+='<span>我是一個小測試</span>'
} 

這段代碼有兩個明顯的可優化點。

第一點,過路費交太多了。我們每一次循環都調用 DOM 接口重新獲取了一次 container 元素,相當於每次循環都交了一次過路費。前後交了 10000 次過路費,但其中 9999 次過路費都可以用緩存變量的方式節省下來:

// 只獲取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){ 
  container.innerHTML += '<span>我是一個小測試</span>'
} 

第二點,不必要的 DOM 更改太多了。我們的 10000 次循環裏,修改了 10000 次 DOM 樹。我們前面說過,對 DOM 的修改會引發渲染樹的改變、進而去走一個(可能的)迴流或重繪的過程,而這個過程的開銷是很“貴”的。這麼貴的操作,我們竟然重複執行了 N 多次!其實我們可以通過就事論事的方式節省下來不必要的渲染:

let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){ 
  // 先對內容進行操作
  content += '<span>我是一個小測試</span>'
} 
// 內容處理好了,最後再觸發DOM的更改
container.innerHTML = content

所謂“就事論事”,就像大家所看到的:JS 層面的事情,JS 自己去處理,處理好了,再來找 DOM 打報告。

事實上,考慮JS 的運行速度,比 DOM 快得多這個特性。我們減少 DOM 操作的核心思路,就是讓 JS 去給 DOM 分壓

這個思路,在 DOM Fragment 中體現得淋漓盡致。

DocumentFragment 接口表示一個沒有父級文件的最小文檔對象。它被當做一個輕量版的 Document 使用,用於存儲已排好版的或尚未打理好格式的XML片段。因爲 DocumentFragment 不是真實 DOM 樹的一部分,它的變化不會引起 DOM 樹的重新渲染的操作(reflow),且不會導致性能等問題。

在我們上面的例子裏,字符串變量 content 就扮演着一個 DOM Fragment 的角色。其實無論字符串變量也好,DOM Fragment 也罷,它們本質上都作爲脫離了真實 DOM 樹的容器出現,用於緩存批量化的 DOM 操作。

前面我們直接用 innerHTML 去拼接目標內容,這樣做固然有用,但卻不夠優雅。相比之下,DOM Fragment 可以幫助我們用更加結構化的方式去達成同樣的目的,從而在維持性能的同時,保住我們代碼的可拓展和可維護性。我們現在用 DOM Fragment 來改寫上面的例子:

let container = document.getElementById('container')
// 創建一個DOM Fragment對象作爲容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
  // span此時可以通過DOM API去創建
  let oSpan = document.createElement("span")
  oSpan.innerHTML = '我是一個小測試'
  // 像操作真實DOM一樣操作DOM Fragment對象
  content.appendChild(oSpan)
}
// 內容處理好了,最後再觸發真實DOM的更改
container.appendChild(content)

我們運行這段代碼,可以得到與前面兩種寫法相同的運行結果。
可以看出,DOM Fragment 對象允許我們像操作真實 DOM 一樣去調用各種各樣的 DOM API,我們的代碼質量因此得到了保證。並且它的身份也非常純粹:當我們試圖將其 append 進真實 DOM 時,它會在乖乖交出自身緩存的所有後代節點後全身而退,完美地完成一個容器的使命,而不會出現在真實的 DOM 結構中。這種結構化、乾淨利落的特性,使得 DOM Fragment 作爲經典的性能優化手段大受歡迎,這一點在 jQuery、Vue 等優秀前端框架的源碼中均有體現。

相比 DOM 命題的

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章