最後一擊——迴流(Reflow)與重繪(Repaint)

最後一擊——迴流(Reflow)與重繪(Repaint)

迴流:當我們對 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)就不一定行得通了。而我們並不知道用戶會使用什麼樣的瀏覽器。如果不手動做優化,那麼一個頁面在不同的環境下就會呈現不同的性能效果,這對我們、對用戶都是不利的。因此,養成良好的編碼習慣、從根源上解決問題,仍然是最周全的方法。

來自掘金小冊 《前端性能優化原理與實踐》

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