一、DOM操作影響頁面效能的核心問題
通過js操作DOM的代價很高,影響頁面效能的主要問題有如下幾點:
- 訪問和修改DOM元素
- 修改DOM元素的樣式,導致
重繪
或重排
- 通過對DOM元素的事件處理,完成與使用者的互動功能
DOM的修改會導致
重繪
和重排
。
- 重繪是指一些樣式的修改,元素的位置和大小都沒有改變;
- 重排是指元素的位置或尺寸發生了變化,瀏覽器需要重新計算渲染樹,而新的渲染樹建立後,瀏覽器會重新繪製受影響的元素。
頁面重繪的速度要比頁面重排的速度快,在頁面互動中要儘量避免頁面的重排操作。瀏覽器不會在js執行的時候更新DOM,而是會把這些DOM操作存放在一個佇列中,在js執行完之後按順序一次性執行完畢,因此在js執行過程中使用者一直在被阻塞。
1.頁面渲染過程
一個頁面更新時,渲染過程大致如下:
- JavaScript: 通過js來製作動畫效果或操作DOM實現互動效果
- Style: 計算樣式,如果元素的樣式有改變,在這一步重新計算樣式,並匹配到對應的DOM上
- Layout: 根據上一步的DOM樣式規則,重新進行佈局(
重排
) - Paint: 在多個渲染層上,對新的佈局重新繪製(
重繪
) - Composite: 將繪製好的多個渲染層合併,顯示到螢幕上
在網頁生成的時候,至少會進行一次佈局和渲染,在後面使用者的操作時,不斷的進行重繪或重排,因此如果在js中存在很多DOM操作,就會不斷地出發重繪或重排,影響頁面效能。
2.DOM操作對頁面效能的影響
如前面所說,DOM操作影響頁面效能的核心問題主要在於DOM操作導致了頁面的重繪
或重排
,為了減少由於重繪和重排對網頁效能的影響,我們要知道都有哪些操作會導致頁面的重繪或者重排。
2.1 導致頁面重排的一些操作:
- 內容改變
- 文字改變或圖片尺寸改變
- DOM元素的幾何屬性的變化
- 例如改變DOM元素的寬高值時,原渲染樹中的相關節點會失效,瀏覽器會根據變化後的DOM重新排建渲染樹中的相關節點。如果父節點的幾何屬性變化時,還會使其子節點及後續兄弟節點重新計算位置等,造成一系列的重排。
- DOM樹的結構變化
- 新增DOM節點、修改DOM節點位置及刪除某個節點都是對DOM樹的更改,會造成頁面的重排。瀏覽器佈局是從上到下的過程,修改當前元素不會對其前邊已經遍歷過的元素造成影響,但是如果在所有的節點前新增一個新的元素,則後續的所有元素都要進行重排。
- 獲取某些屬性
- 除了渲染樹的直接變化,當獲取一些屬性值時,瀏覽器為取得正確的值也會發生重排,這些屬性包括:
offsetTop
、offsetLeft
、offsetWidth
、offsetHeight
、scrollTop
、scrollLeft
、scrollWidth
、scrollHeight
、clientTop
、clientLeft
、clientWidth
、clientHeight
、getComputedStyle()
。
- 除了渲染樹的直接變化,當獲取一些屬性值時,瀏覽器為取得正確的值也會發生重排,這些屬性包括:
- 瀏覽器視窗尺寸改變
- 視窗尺寸的改變會影響整個網頁內元素的尺寸的改變,即DOM元素的集合屬性變化,因此會造成重排。
2.2 導致頁面重繪的操作
- 應用新的樣式或者修改任何影響元素外觀的屬性
- 只改變了元素的樣式,並未改變元素大小、位置,此時只涉及到重繪操作。
- 重排一定會導致重繪
- 一個元素的重排一定會影響到渲染樹的變化,因此也一定會涉及到頁面的重繪。
二、高頻操作DOM會導致的問題
接下來會分享一下在平時專案中由於高頻操作DOM影響網頁效能的問題。
1. 抽獎專案的高頻操作DOM問題
1.1 存在的問題
在最近做的抽獎專案中,就遇到了這樣的由於高頻操作DOM,導致頁面效能變差的問題。在經歷幾輪抽獎後,文字滾動速度越來越慢,肉眼能感受到與第一次抽獎時文字滾動速度的明顯差別,如持續時間過長或輪次過多,還會造成瀏覽器假死現象。
1.2 問題分析
下圖為抽獎時文字滾動過程中的timeline記錄。
timeline分析:
- FPS:最上面一欄為綠色柱形為幀率(FPS),頂點值為60fps,上方紅色方塊表示長幀,這些長幀被Chrome稱為jank(卡頓)。
- CPU:第二欄為CPU,藍色表示
loading
(網路通訊和HTML解析),黃色表示scripting
(js執行時間),紫色表示rendering
(樣式計算和佈局,即重排
), 綠色為painting
(即重繪
)。更多timeline使用方法可參考:如何使用Chrome Timeline 工具(譯)
由上圖可以看出,在文字滾動過程中紅色方塊出現頻繁,頁面中存在的卡頓過多。幀率的值越低,人眼感受到的效果越差。
參考文章:腦洞大開:為啥幀率達到 60 fps 就流暢?。
接下來選擇一段長幀區域放大來看
在這段區域內最大一幀達到了49.7ms,幀率只有20fps,接下來看看這一幀裡是什麼因素耗時過長
由上圖可以看出,耗時最大的在scripting,js的執行時間達到了44.9ms,佔總時間的93.2%,因為主要靠js計算控制DOM的顯示內容,所以js執行時間過長。
選取一段FPS值很低的部分檢視造成這段值低的原因
由下圖可看出主要為dom.html中的js執行佔用時間。
點進dom.html檔案,即可定位到該函式
由此可知,主要是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
優化後文字滾動時的timeline
優化前的程式碼對DOM操作很頻繁,因此FPS值普遍偏低,而優化後可以看出紅色方塊明顯減少,FPS值一直處於高值。
1.5 優化前後CPU佔用對比
優化前文字滾動時的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優化頁面滾動
123456789101112// 在頁面滾動時對顯示範圍進行計算// 延遲到整個dom載入完後再呼叫,並且非同步到所有事件後執行$(function(){//animationShow優化滾動效果,scrollShow為實際計算顯示範圍及操作DOM的函式setTimeout( function() {window.Scroller.on('scrollend', animationShow);window.Scroller.on('scrollmove', animationShow);})});function animationShow(){return window.requestAnimationFrame ?window.requestAnimationFrame(scrollShow) : scrollShow();}
對於scroll的滾動優化還可以採用防抖(Debouncing)和節流(Throttling)的方式,但是防抖和節流的方式還是要藉助於setTimeout,因此和requestAnimationFrame相比,還是requestAnimationFrame實現效果好一些。
參考文章:高效能滾動 scroll 及頁面渲染優化
三、針對操作DOM的效能優化方法總結
為了減少DOM操作對頁面效能產生的影響,在實現頁面的互動效果時一定要注意一下幾點:
1.減少在迴圈內進行DOM操作,在迴圈外部進行DOM快取
1 2 3 4 5 6 7 8 |
//優化前程式碼 function Loop() { console.time("loop1"); for (var count = 0; count < 15000; count++) { document.getElementById('text').innerHTML += 'dom'; } console.timeEnd("loop1"); } |
1 2 3 4 5 6 7 8 9 10 |
//優化後程式碼 function Loop2() { console.time("loop2"); var content = ''; for (var count = 0; count < 15000; count++) { content += 'dom'; } document.getElementById('text2').innerHTML += content; console.timeEnd("loop2"); } |
兩個函式的執行時間對比:
優化前的程式碼中,每進行一次迴圈,都會讀取一次
div
的innerHtml
屬性,並且對這個屬性進行了重新賦值,即每迴圈一次就會操作兩次DOM,因此執行時間很長,頁面效能差。在優化後的程式碼中,將要更新的DOM內容進行快取,在迴圈時只操作字串,迴圈結束後字串的值寫入到
div
中,只進行了一次查詢innerHtml
屬性和一次對該屬性重新賦值的操作,因此同樣的迴圈次數先,優化後的方法執行時間遠遠少於優化前。
2.只控制DOM節點的顯示或隱藏,而不是直接去改變DOM結構
在抽獎專案中頻繁操作DOM來控制文字滾動的方法(demo:https://gxt19940130.github.io/demo/dom.html 導致頁面效能很差,最後修改為如下程式碼。
1 2 3 4 5 6 7 8 |
<div class="staff-list" :class="list"> <ul class="staff-list-ul"> <li v-for="item in staffList" v-show="isShow($index)"> <div>{{{item.staff_name | addSpace}}} </div> <div class="staff_phone">{{item.phone_no}} </div> </li> </ul> </div> |
上面程式碼的優化原理即先生成所有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節點刪除或隱藏
1 2 3 4 5 6 7 8 |
var list1 = $(".list1"); list1.hide(); for (var i = 0; i < 15000; i++) { var item = document.createElement("li"); item.append(document.createTextNode('0')); list1.append(item); } list1.show(); |
display屬性值為none的元素不在渲染樹中,因此對隱藏的元素操作不會引發其他元素的重排。如果要對一個元素進行多次DOM操作,可以先將其隱藏,操作完成後再顯示。這樣只在隱藏和顯示時觸發2次重排,而不會是在每次進行操作時都出發一次重排。
頁面rendering時間對比:
下圖為同樣的迴圈次數下未隱藏節點直接進行DOM操作的rendering時間(圖一)和隱藏節點再進行DOM操作的rendering時間(圖二)
由對比圖可以看出,總時間、js執行時間以及rendering時間都明顯減少,並且避免了painting以及其他的一些操作。
4. 最小化重繪和重排
1 2 3 4 5 |
//優化前程式碼 var element = document.getElementById('mydiv'); element.style.height = "100px"; element.style.borderLeft = "1px"; element.style.padding = "20px"; |
在上面的程式碼中,每對element進行一次樣式更改都會影響該元素的集合結構,最糟糕情況下會觸發三次重排。
優化方式:利用js或jquery對該元素的class重新賦值,獲得新的樣式,這樣減少了多次的DOM操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//優化後程式碼 //js操作 .newStyle { height: 100px; border-left: 1px; padding: 20px; } element.className = "newStyle"; //jquery操作 $(element).css({ height: 100px; border-left: 1px; padding: 20px; }) |
到此本文結束,如果對於問題分析存在不正確的地方,還請及時指出,多多交流。
參考文章: