從本節開始,我們要關心的兩大核心問題就是:“DOM 為什么這么慢”以及“如何使 DOM 變快”。
后者是一個比“生存還是毀滅”更加經典的問題。不僅我們為它“肝腸寸斷”,許多優秀前端框架的作者大大們也曾為其絞盡腦汁。這一點可喜可賀——研究的人越多,產出優秀實踐的概率就越大。因此在本章的方法論環節,我們不僅會根據 DOM 特性及渲染原理為大家講解基本的優化思路,還會涉及到一部分生產實踐。
循著這個思路,我們把 DOM 優化這塊劃分為三個小專題:“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](https://developer.mozilla.org/zh-CN/docs/Web/API/DocumentFragment) 中體現得淋漓盡致。
> 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 命題的博大精深,一個簡單的循環 Demo 顯然不能說明所有問題。不過不用著急,在本節,我只希望大家能牢記原理與宏觀思路。“藥到病除”到這里才剛剛開了個頭,下個小節,我們將深挖事件循環機制,從而深入 JS 層面的生產實踐。
- 開篇:知識體系與小冊格局
- 網絡篇 1:webpack 性能調優與 Gzip 原理
- 網絡篇 2:圖片優化——質量與性能的博弈
- 存儲篇 1:瀏覽器緩存機制介紹與緩存策略剖析
- 存儲篇 2:本地存儲——從 Cookie 到 Web Storage、IndexDB
- 彩蛋篇:CDN 的緩存與回源機制解析
- 渲染篇 1:服務端渲染的探索與實踐
- 渲染篇 2:知己知彼——解鎖瀏覽器背后的運行機制
- 渲染篇 3:對癥下藥——DOM 優化原理與基本實踐
- 渲染篇 4:千方百計——Event Loop 與異步更新策略
- 渲染篇 5:最后一擊——回流(Reflow)與重繪(Repaint)
- 應用篇 1:優化首屏體驗——Lazy-Load 初探
- 應用篇 2:事件的節流(throttle)與防抖(debounce)
- 性能監測篇:Performance、LightHouse 與性能 API
- 前方的路:希望成為你的起點