開篇我們先對上上節介紹的回流與重繪的基礎知識做個復習(跳讀的同學請自覺回到上上節補齊 →\_→)。
**回流**:當我們對 DOM 的修改引發了 DOM 幾何尺寸的變化(比如修改元素的寬、高或隱藏元素等)時,瀏覽器需要重新計算元素的幾何屬性(其他元素的幾何屬性和位置也會因此受到影響),然后再將計算的結果繪制出來。這個過程就是回流(也叫重排)。
**重繪**:當我們對 DOM 的修改導致了樣式的變化、卻并未影響其幾何屬性(比如修改了顏色或背景色)時,瀏覽器不需重新計算元素的幾何屬性、直接為該元素繪制新的樣式(跳過了上圖所示的回流環節)。這個過程叫做重繪。
由此我們可以看出,**重繪不一定導致回流,回流一定會導致重繪**。硬要比較的話,回流比重繪做的事情更多,帶來的開銷也更大。但這兩個說到底都是吃性能的,所以都不是什么善茬。我們在開發中,要從代碼層面出發,盡可能把回流和重繪的次數最小化。
## 哪些實際操作會導致回流與重繪
要避免回流與重繪的發生,最直接的做法是避免掉可能會引發回流與重繪的 DOM 操作,就好像拆彈專家在解決一顆炸彈時,最重要的是掐滅它的導火索。
觸發重繪的“導火索”比較好識別——只要是不觸發回流,但又觸發了樣式改變的 DOM 操作,都會引起重繪,比如背景色、文字色、可見性(可見性這里特指形如visibility: hidden這樣不改變元素位置和存在性的、單純針對可見性的操作,注意與display:none進行區分)等。為此,我們要著重理解一下那些可能觸發回流的操作。
### 回流的“導火索”
* 最“貴”的操作:改變 DOM 元素的幾何屬性
這個改變幾乎可以說是“牽一發動全身”——當一個DOM元素的幾何屬性發生變化時,所有和它相關的節點(比如父子節點、兄弟節點等)的幾何屬性都需要進行重新計算,它會帶來巨大的計算量。
常見的幾何屬性有 width、height、padding、margin、left、top、border 等等。此處不再給大家一一列舉。有的文章喜歡羅列屬性表格,但我相信我今天列出來大家也不會看、看了也記不住(因為太多了)。我自己也不會去記這些——其實確實沒必要記,?一個屬性是不是幾何屬性、會不會導致空間布局發生變化,大家寫樣式的時候完全可以通過代碼效果看出來。多說無益,還希望大家可以多寫多試,形成自己的“肌肉記憶”。
* “價格適中”的操作:改變 DOM 樹的結構
這里主要指的是節點的增減、移動等操作。瀏覽器引擎布局的過程,順序上可以類比于樹的前序遍歷——它是一個從上到下、從左到右的過程。通常在這個過程中,當前元素不會再影響其前面已經遍歷過的元素。
* 最容易被忽略的操作:獲取一些特定屬性的值
當你要用到像這樣的屬性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 時,你就要注意了!
“像這樣”的屬性,到底是像什么樣?——這些值有一個共性,就是需要通過**即時計算**得到。因此瀏覽器為了獲取這些值,也會進行回流。
除此之外,當我們調用了 getComputedStyle 方法,或者 IE 里的 currentStyle 時,也會觸發回流。原理是一樣的,都為求一個“即時性”和“準確性”。
## 如何規避回流與重繪
了解了回流與重繪的“導火索”,我們就要盡量規避它們。但很多時候,我們不得不使用它們。當避無可避時,我們就要學會更聰明地使用它們。
### 將“導火索”緩存起來,避免頻繁改動
有時我們想要通過多次計算得到一個元素的布局位置,我們可能會這樣做:
```
<!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>Document</title>
<style>
#el {
width: 100px;
height: 100px;
background-color: yellow;
position: absolute;
}
</style>
</head>
<body>
<div id="el"></div>
<script>
// 獲取el元素
const el = document.getElementById('el')
// 這里循環判定比較簡單,實際中或許會拓展出比較復雜的判定需求
for(let i=0;i<10;i++) {
el.style.top = el.offsetTop + 10 + "px";
el.style.left = el.offsetLeft + 10 + "px";
}
</script>
</body>
</html>
```
這樣做,每次循環都需要獲取多次“敏感屬性”,是比較糟糕的。我們可以將其以 JS 變量的形式緩存起來,待計算完畢再提交給瀏覽器發出重計算請求:
```
// 緩存offsetLeft與offsetTop的值
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop
// 在JS層面進行計算
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}
// 一次性將計算結果應用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop + "px"
```
### 避免逐條改變樣式,使用類名去合并樣式
比如我們可以把這段單純的代碼:
```
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
```
優化成一個有 class 加持的樣子:
```
<!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>Document</title>
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container')
container.classList.add('basic_style')
</script>
</body>
</html>
```
前者每次單獨操作,都去觸發一次渲染樹更改,從而導致相應的回流與重繪過程。
合并之后,等于我們將所有的更改一次性發出,用一個 style 請求解決掉了。
### 將 DOM “離線”
我們上文所說的回流和重繪,都是在“該元素位于頁面上”的前提下會發生的。一旦我們給元素設置 display: none,將其從頁面上“拿掉”,那么我們的后續操作,將無法觸發回流與重繪——這個將元素“拿掉”的操作,就叫做 DOM 離線化。
仍以我們上文的代碼片段為例:
```
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了許多類似的后續操作)
```
離線化后就是這樣:
```
let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了許多類似的后續操作)
container.style.display = 'block'
```
有的同學會問,拿掉一個元素再把它放回去,這不也會觸發一次昂貴的回流嗎?這話不假,但我們把它拿下來了,后續不管我操作這個元素多少次,每一步的操作成本都會非常低。當我們只需要進行很少的 DOM 操作時,DOM 離線化的優越性確實不太明顯。一旦操作頻繁起來,這“拿掉”和“放回”的開銷都將會是非常值得的。
## Flush 隊列:瀏覽器并沒有那么簡單
以我們現在的知識基礎,理解上面的優化操作并不難。那么現在我問大家一個問題:
```
let container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
```
這段代碼里,瀏覽器進行了多少次的回流或重繪呢?
“width、height、border是幾何屬性,各觸發一次回流;color只造成外觀的變化,會觸發一次重繪。”——如果你立刻這么想了,說明你是個能力不錯的同學,認真閱讀了前面的內容。那么我們現在立刻跑一跑這段代碼,看看瀏覽器怎么說:

這里為大家截取有“Layout”和“Paint”出鏡的片段(這個圖是通過 Chrome 的 Performance 面板得到的,后面會教大家用這個東西)。我們看到瀏覽器只進行了一次回流和一次重繪——和我們想的不一樣啊,為啥呢?
因為現代瀏覽器是很聰明的。瀏覽器自己也清楚,如果每次 DOM 操作都即時地反饋一次回流或重繪,那么性能上來說是扛不住的。于是它自己緩存了一個 flush 隊列,把我們觸發的回流與重繪任務都塞進去,待到隊列里的任務多起來、或者達到了一定的時間間隔,或者“不得已”的時候,再將這些任務一口氣出隊。因此我們看到,上面就算我們進行了 4 次 DOM 更改,也只觸發了一次 Layout 和一次 Paint。
大家這里尤其小心這個“不得已”的時候。前面我們在介紹回流的“導火索”的時候,提到過有一類屬性很特別,它們有很強的“即時性”。當我們訪問這些屬性時,瀏覽器會為了獲得此時此刻的、最準確的屬性值,而提前將 flush 隊列的任務出隊——這就是所謂的“不得已”時刻。具體是哪些屬性值,我們已經在“最容易被忽略的操作”這個小模塊介紹過了,此處不再贅述。
## 小結
整個一節讀下來,可能會有同學感到疑惑:既然瀏覽器已經為我們做了批處理優化,為什么我們還要自己操心這么多事情呢?今天避免這個明天避免那個,多麻煩!
問題在于,**并不是所有的瀏覽器都是聰明的**。我們剛剛的性能圖表,是 Chrome 的開發者工具呈現給我們的。Chrome 里行得通的東西,到了別處(比如 IE)就不一定行得通了。而我們并不知道用戶會使用什么樣的瀏覽器。如果不手動做優化,那么一個頁面在不同的環境下就會呈現不同的性能效果,這對我們、對用戶都是不利的。因此,養成良好的編碼習慣、從根源上解決問題,仍然是最周全的方法。
- 開篇:知識體系與小冊格局
- 網絡篇 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
- 前方的路:希望成為你的起點