高頻 dom 操作和頁面效能優化探索

發表於2017-09-01

一、DOM操作影響頁面效能的核心問題


通過js操作DOM的代價很高,影響頁面效能的主要問題有如下幾點:

  • 訪問和修改DOM元素
  • 修改DOM元素的樣式,導致重繪重排
  • 通過對DOM元素的事件處理,完成與使用者的互動功能

DOM的修改會導致重繪重排

  • 重繪是指一些樣式的修改,元素的位置和大小都沒有改變;
  • 重排是指元素的位置或尺寸發生了變化,瀏覽器需要重新計算渲染樹,而新的渲染樹建立後,瀏覽器會重新繪製受影響的元素。

頁面重繪的速度要比頁面重排的速度快,在頁面互動中要儘量避免頁面的重排操作。瀏覽器不會在js執行的時候更新DOM,而是會把這些DOM操作存放在一個佇列中,在js執行完之後按順序一次性執行完畢,因此在js執行過程中使用者一直在被阻塞。

1.頁面渲染過程

一個頁面更新時,渲染過程大致如下:

enter image description here

  • JavaScript: 通過js來製作動畫效果或操作DOM實現互動效果
  • Style: 計算樣式,如果元素的樣式有改變,在這一步重新計算樣式,並匹配到對應的DOM上
  • Layout: 根據上一步的DOM樣式規則,重新進行佈局(重排
  • Paint: 在多個渲染層上,對新的佈局重新繪製(重繪
  • Composite: 將繪製好的多個渲染層合併,顯示到螢幕上

在網頁生成的時候,至少會進行一次佈局和渲染,在後面使用者的操作時,不斷的進行重繪或重排,因此如果在js中存在很多DOM操作,就會不斷地出發重繪或重排,影響頁面效能。

2.DOM操作對頁面效能的影響

如前面所說,DOM操作影響頁面效能的核心問題主要在於DOM操作導致了頁面的重繪重排,為了減少由於重繪和重排對網頁效能的影響,我們要知道都有哪些操作會導致頁面的重繪或者重排。

2.1 導致頁面重排的一些操作:

  • 內容改變
    • 文字改變或圖片尺寸改變
  • DOM元素的幾何屬性的變化
    • 例如改變DOM元素的寬高值時,原渲染樹中的相關節點會失效,瀏覽器會根據變化後的DOM重新排建渲染樹中的相關節點。如果父節點的幾何屬性變化時,還會使其子節點及後續兄弟節點重新計算位置等,造成一系列的重排。
  • DOM樹的結構變化
    • 新增DOM節點、修改DOM節點位置及刪除某個節點都是對DOM樹的更改,會造成頁面的重排。瀏覽器佈局是從上到下的過程,修改當前元素不會對其前邊已經遍歷過的元素造成影響,但是如果在所有的節點前新增一個新的元素,則後續的所有元素都要進行重排。
  • 獲取某些屬性
    • 除了渲染樹的直接變化,當獲取一些屬性值時,瀏覽器為取得正確的值也會發生重排,這些屬性包括:offsetTopoffsetLeftoffsetWidthoffsetHeightscrollTopscrollLeftscrollWidthscrollHeightclientTopclientLeftclientWidthclientHeightgetComputedStyle()
  • 瀏覽器視窗尺寸改變
    • 視窗尺寸的改變會影響整個網頁內元素的尺寸的改變,即DOM元素的集合屬性變化,因此會造成重排。

2.2 導致頁面重繪的操作

  • 應用新的樣式或者修改任何影響元素外觀的屬性
    • 只改變了元素的樣式,並未改變元素大小、位置,此時只涉及到重繪操作。
  • 重排一定會導致重繪
    • 一個元素的重排一定會影響到渲染樹的變化,因此也一定會涉及到頁面的重繪。

二、高頻操作DOM會導致的問題

接下來會分享一下在平時專案中由於高頻操作DOM影響網頁效能的問題。

1. 抽獎專案的高頻操作DOM問題

1.1 存在的問題

在最近做的抽獎專案中,就遇到了這樣的由於高頻操作DOM,導致頁面效能變差的問題。在經歷幾輪抽獎後,文字滾動速度越來越慢,肉眼能感受到與第一次抽獎時文字滾動速度的明顯差別,如持續時間過長或輪次過多,還會造成瀏覽器假死現象。

實現demo: https://gxt19940130.github.io/demo/dom.html

1.2 問題分析

下圖為抽獎時文字滾動過程中的timeline記錄。
enter image description here

timeline分析:

  1. FPS:最上面一欄為綠色柱形為幀率(FPS),頂點值為60fps,上方紅色方塊表示長幀,這些長幀被Chrome稱為jank(卡頓)。
  2. CPU:第二欄為CPU,藍色表示loading(網路通訊和HTML解析),黃色表示scripting(js執行時間),紫色表示rendering(樣式計算和佈局,即重排), 綠色為painting(即重繪)。

更多timeline使用方法可參考:如何使用Chrome Timeline 工具(譯)

由上圖可以看出,在文字滾動過程中紅色方塊出現頻繁,頁面中存在的卡頓過多。幀率的值越低,人眼感受到的效果越差。
參考文章:腦洞大開:為啥幀率達到 60 fps 就流暢?


接下來選擇一段長幀區域放大來看
enter image description here

在這段區域內最大一幀達到了49.7ms,幀率只有20fps,接下來看看這一幀裡是什麼因素耗時過長


enter image description here

由上圖可以看出,耗時最大的在scripting,js的執行時間達到了44.9ms,佔總時間的93.2%,因為主要靠js計算控制DOM的顯示內容,所以js執行時間過長。


選取一段FPS值很低的部分檢視造成這段值低的原因
enter image description here

由下圖可看出主要為dom.html中的js執行佔用時間。
enter image description here

點進dom.html檔案,即可定位到該函式
enter image description here

由此可知,主要是rolling這個函式執行時間過長,對該部分失幀影響較大。而這個函式的主要作用就是實現文字的滾動效果,也可以從程式碼中看出,這個函式利用的setTimeout來反覆執行,並且在這個函式中存在著迴圈以及大量的DOM操作,造成了頁面的失幀等問題。

1.3 優化方案

針對該專案中的問題,採取的解決方法是:

  • 一次性生成全部
  • ,並且隱藏這些
  • ,隨機生成一組隨機數陣列,只有index與陣列裡面的隨機數相等時,才顯示該位置的
  • ,雖然也會觸發重排和重繪,但是效能要遠遠高於直接操作DOM的新增和刪除。
  • requestAnimationFrame取代setTimeout不斷生成隨機數。

requestAnimationFrame與setTimeout和setInterval類似,都是通過遞迴呼叫同一個方法不斷更新頁面。

  • setTimeout():在特定的時間後執行函式,而且只執行一次,如果在特定時間前想取消執行函式,可以用clearTimeout立即取消執行。但是並不是每次執行setTimeout都會在特定的時間後執行,頁面載入後js會按照主執行緒中的順序按序執行那個,如果在延遲時間內主執行緒不空閒,setTimeout裡面的函式是不會執行的,它會延遲到主執行緒空閒時才執行。
  • setInterval():在特定的時間間隔內重複執行函式,除非主動清除它,不然會一直執行下去,清除函式可以使用clearInterval。setInterval也會等到主執行緒空閒了再執行,但是setInterval去排隊時,如果發現自己還在佇列中未執行,就會被drop掉,所以可能會造成某段時間的函式未被執行。
  • requestAnimationFrame():它不需要設定時間間隔,它會在瀏覽器每次重新整理之前執行回撥函式的任務。這樣我們動畫的更新就能和瀏覽器的重新整理頻率保持一致。requestAnimationFrame在執行時,瀏覽器會自動優化方法的呼叫,並且如果頁面不是啟用狀態下的話,動畫會自動暫停,有效節省了CPU開銷。

在採用上面的方法進行優化後,在經歷多輪抽獎後,文字滾動速度依舊正常,網頁效能良好,不會出現文字滾動速度越來越慢,最後導致瀏覽器假死的現象。

實現demo: https://gxt19940130.github.io/demo/demo_gxt/dom_by_vue.html

1.4 優化前後FPS對比

優化前文字滾動時的timeline
enter image description here


優化後文字滾動時的timeline
優化後的timeline

優化前的程式碼對DOM操作很頻繁,因此FPS值普遍偏低,而優化後可以看出紅色方塊明顯減少,FPS值一直處於高值。

1.5 優化前後CPU佔用對比

優化前文字滾動時的timeline
enter image description here


優化後文字滾動時的timeline
優化後的timeline


優化前js的CPU佔用率較高,而優化後佔用CPU的主要為渲染時間,因為優化後的程式碼只是控制了節點的顯示和隱藏,所以在js上消耗較少,在渲染上消耗較大。

2.吸頂導航條相關及scroll滾動優化

2.1 存在的問題

吸頂導航條要求當頁面滾動到某個區域時,對應該區域的導航條在設定的顯示範圍內保持吸頂顯示。涉及到的操作:

  • 監聽頁面的scroll事件
  • 在頁面滾動時進行計算和DOM操作
    • 計算:計算當前所在位置是否為對應導航條的顯示範圍
    • DOM操作:顯示在範圍內的導航條並且隱藏其他導航條

由於scroll事件被觸發的頻率高、間隔近,如果此時進行DOM操作或計算並且這些DOM操作和計算無法在下一次scroll事件發生前完成,就會造成掉幀、頁面卡頓,影響使用者體驗。

2.2 優化方案

針對該專案中的問題,採取的解決方法是:

  • 儘量控制DOM的顯示或隱藏,而不是刪除或新增:

    頁面載入時根據當前頁面中吸頂導航的數量複製對應的DOM,並且隱藏這些導航。當頁面滾動到指定區域後,顯示對應的導航。

  • 一次性操作DOM:

    將複製的DOM儲存到陣列中,將該陣列append到對應的父節點下,而不是根據複製得到DOM的數量依次迴圈插入到父節點下。

  • 多做快取:

    如果某個節點將在後續進行多次操作,可以將該節點利用變數儲存起來,而不是每次進行操作時都去查詢一遍該節點。

  • 使用 requestAnimationFrame優化頁面滾動

對於scroll的滾動優化還可以採用防抖(Debouncing)和節流(Throttling)的方式,但是防抖和節流的方式還是要藉助於setTimeout,因此和requestAnimationFrame相比,還是requestAnimationFrame實現效果好一些。
參考文章:高效能滾動 scroll 及頁面渲染優化

三、針對操作DOM的效能優化方法總結

為了減少DOM操作對頁面效能產生的影響,在實現頁面的互動效果時一定要注意一下幾點:


1.減少在迴圈內進行DOM操作,在迴圈外部進行DOM快取


兩個函式的執行時間對比:
enter image description here

優化前的程式碼中,每進行一次迴圈,都會讀取一次divinnerHtml屬性,並且對這個屬性進行了重新賦值,即每迴圈一次就會操作兩次DOM,因此執行時間很長,頁面效能差。

在優化後的程式碼中,將要更新的DOM內容進行快取,在迴圈時只操作字串,迴圈結束後字串的值寫入到div中,只進行了一次查詢innerHtml屬性和一次對該屬性重新賦值的操作,因此同樣的迴圈次數先,優化後的方法執行時間遠遠少於優化前。

2.只控制DOM節點的顯示或隱藏,而不是直接去改變DOM結構

在抽獎專案中頻繁操作DOM來控制文字滾動的方法(demo:https://gxt19940130.github.io/demo/dom.html 導致頁面效能很差,最後修改為如下程式碼。

上面程式碼的優化原理即先生成所有DOM節點,但是所有節點均不顯示出來,利用vue.js中的v-show,根據計算的隨機數來控制顯示某個

,來達到文字滾動效果。

如果採用jquery,則需要將生成的所有

全部存放在下,並且隱藏它們,在根據生成的隨機陣列,利用jquery查詢index與生成的隨機數對應的並顯示,達到文字滾動效果。
優化後demo: https://gxt19940130.github.io/demo/demo_gxt/dom_by_vue.html

對比結果可檢視2.4

3.操作DOM前,先把DOM節點刪除或隱藏

display屬性值為none的元素不在渲染樹中,因此對隱藏的元素操作不會引發其他元素的重排。如果要對一個元素進行多次DOM操作,可以先將其隱藏,操作完成後再顯示。這樣只在隱藏和顯示時觸發2次重排,而不會是在每次進行操作時都出發一次重排。


頁面rendering時間對比:
下圖為同樣的迴圈次數下未隱藏節點直接進行DOM操作的rendering時間(圖一)和隱藏節點再進行DOM操作的rendering時間(圖二)

enter image description here

enter image description here

由對比圖可以看出,總時間、js執行時間以及rendering時間都明顯減少,並且避免了painting以及其他的一些操作。

4. 最小化重繪和重排

在上面的程式碼中,每對element進行一次樣式更改都會影響該元素的集合結構,最糟糕情況下會觸發三次重排。
優化方式:利用js或jquery對該元素的class重新賦值,獲得新的樣式,這樣減少了多次的DOM操作。

到此本文結束,如果對於問題分析存在不正確的地方,還請及時指出,多多交流。

參考文章:

相關文章