加速 JavaScript 執行效率的 DOM 操作技巧(降低瀏覽器回流)

在開發 RIA 的過程中,會常常使用到 JavaScript 來變更網頁元素,甚至增加新的網頁元素,而不同的操作方式也會對執行效能有所影響。


瀏覽器在顯示網頁時,會需要計算每一個網頁元素應該放置在哪個位置,這個計算過程就稱為瀏覽器回流(browser reflow)。當我們對 DOM 進行操作(例如更改元素的 CSS 樣式、大小等)或是改變視窗大小時,也會造成瀏覽器的回流,由於瀏覽器的流回需要耗費時間,所以如果可以盡量減少回流,就可以增加整個網頁應用程式的效率。

在操作 DOM 的時候,只會有兩種情況,一種是更動記有的元素,另外一種則是加入新的,以下有四種設計模式(patterns),提供了在這兩種狀況下,減低瀏覽器回流的方式。

CSS 的 Class 變更

這種方式可以讓瀏覽器一次變更多個 CSS 樣式屬性,套用至一個網頁元素以及其所有的子元素(descendants)上,而且只需要耗費一次的瀏覽器回流。

問題

假設我們寫一個 JavaScript 函數,用來更改網頁元素的 CSS 屬性:
function selectAnchor(element) {
  element.style.fontWeight = 'bold';
  element.style.textDecoration = 'none';
  element.style.color = '#000';
}
這樣的方式會造成瀏覽器每次更動 DOM 時,都進行一次回流。

解決方案

建立一個包含所有的屬性的 CSS class:
.selectedAnchor {
  font-weight: bold;
  text-decoration: none;
  color: #000;
}
然後將這個 CSS class 套用至網頁元素上,這樣就可以讓瀏覽器只耗費一次的回流:
function selectAnchor(element) {
  element.className = 'selectedAnchor';
}

DocumentFragment 處理元素變更

這種方式可以讓我們一次變更多個網頁元素,但是只耗費一次的瀏覽器回流。實作的方式是在 DOM 之外建立一個 DocumentFragment,將所有要變更的元素都先放在這個 DocumentFragment 之下,處理完之後,再一次將所有的元素移回 DOM 之中,如此一來不管變更多少元素,都只會耗費一次的瀏覽器回流。

問題

下面這個 JavaScript 函數會變更指定元素中所有超連結的 className 屬性,一般我們都會使用迴圈的方式對每一個超連結做變更,不過這樣的方式會造成大量的瀏覽器回流。
function updateAllAnchors(element, anchorClass) {
  var anchors = element.getElementsByTagName('a');
  for (var i = 0, length = anchors.length; i < length; i ++) {
    anchors[i].className = anchorClass;
  }
}

解決方案

如果要避免產生大量的瀏覽器回流,可以將包含超連結的網頁元素先從 DOM 中移除,等到更改完 className 屬性之後,再將其放回原先的位置。

為了可以達到這樣的目的,我們必須些寫一個小函數,它不只會把元素從 DOM 中移除,還會傳回一個將元素放回 DOM 用的函數。
/**
 * 從 DOM 移除指定的元素,並且傳回將該元素放回 DOM 用的函數
 * @param element {Element} 要暫時移除的元素
 * @return {Function} 用來將元素放回原來位置的函數
 **/
function removeToInsertLater(element) {
  var parentNode = element.parentNode;
  var nextSibling = element.nextSibling;
  parentNode.removeChild(element);
  return function() {
    if (nextSibling) {
      parentNode.insertBefore(element, nextSibling);
    } else {
      parentNode.appendChild(element);
    }
  };
}
使用方式就是在變更 className 屬性之前,先以 removeToInsertLater() 將該元素暫時移除,等到變更完成後再將其放回。
function updateAllAnchors(element, anchorClass) {
  var insertFunction = removeToInsertLater(element);
  var anchors = element.getElementsByTagName('a');
  for (var i = 0, length = anchors.length; i < length; i ++) {
    anchors[i].className = anchorClass;
  }
  insertFunction();
}

建立單一元素

這個設計模式可以讓我們在建立單一網頁元素時,不管建立過程多繁複,都只需要產生一次的瀏覽器回流。

問題

下面這個函數會建立一個新的網頁元素,將該元素加入至 DOM,並設定一些屬性。這樣的狀況會產生三次的瀏覽器回流。
<script>
function addAnchor(parentElement, anchorText, anchorClass) {
  var element = document.createElement('a');
  parentElement.appendChild(element);
  element.innerHTML = anchorText;
  element.className = anchorClass;
}

解決方案

解決方法很簡單,只要將插入 DOM 的動作放在最後,即可將回流次數降低為一次。
function addAnchor(parentElement, anchorText, anchorClass) {
  var element = document.createElement('a');
  element.innerHTML = anchorText;
  element.className = anchorClass;
  parentElement.appendChild(element);
}

DocumentFragment 建立多個元素

這個做法跟上面使用 DocumentFragment 變更元素的方式類似,只不過是用於新增多個元素而已。

問題

這個函數會新增多個超連結元素,並且會產生十次的瀏覽器回流。
function addAnchors(element) {
  var anchor;
  for (var i = 0; i < 10; i ++) {
    anchor = document.createElement('a');
    anchor.innerHTML = 'test';
    element.appendChild(anchor);
  }
}

解決方案

將所有新增的元素先放在 DocumentFragment 中,最後再一次放進 DOM,這樣就可以只耗費一次瀏覽器回流。
function addAnchors(element) {
  var anchor, fragment = document.createDocumentFragment();
  for (var i = 0; i < 10; i ++) {
    anchor = document.createElement('a');
    anchor.innerHTML = 'test';
    fragment.appendChild(anchor);
  }
  element.appendChild(fragment);
}

參考資料:Google Developers
本站已經搬家了,欲查看最新的文章,請至 G. T. Wang 新網站