ngRepeat最大的功能是將陣列項目依模版轉換產生DOM元素,以清單方式呈現資料。而我們都知道,動態DOM元素操作往往是效能瓶頸所在,想像以下情境:以AJAX方式由伺服器端取回100筆資料的陣列,交由ngRepeat轉化為100列<tr>;隨後資料更新,再次由伺服器取回陣列,同樣為100筆,但其中有5筆順序調換、10筆舊資料被刪除、另外10筆資料是新増的。此時ngRepeat會如何處理?丟棄前次產生的100個<tr>再重來一次?還是能重覆利用已產生的DOM元素提升效率?但新舊100筆有新增有刪除有順序調動,ngRepeat要如何解決新舊資料對應問題?(恭喜可直接回答以上問題的朋友,請關閉瀏覽器略過)

過年前在專案上遇到此一疑惑有些迷惘,代表自己學藝不精,技術問題不好拖過年,決定寫幾個範例做實驗為自己解惑。

首先,要先找出「DOM元素被重覆利用而非重新產生」的偵測方法,我最愛用的做法是透過jQuery.css("color", "…")修改文字顏色,這種事後加工在ngRepeat重新產生DOM元素不會被保存,文字顏色一旦還原便表示DOM元素已被重建。

設計測試網頁如下:

<!DOCTYPE html>
<html ng-app="app">
<head>
 
  <meta charset="utf-8">
  <title>ngRepeat DOM重覆使用測試 1</title>
  <style>
    .list {
       width: 300px;
    }
    .list td {
      border: 1px solid gray;
    }
    input { display: block; margin-top: 6px}
  </style>
</head>
<body ng-controller="ctrl as vm">
  <table class="list">
    <tr ng-repeat="player in vm.players">
      <td ng-bind="player.id"></td>
      <td ng-bind="player.name"></td>
      <td ng-bind="player.score"></td>
    </tr>
  </table>
  <input type="button" value="第二筆染色(by jQuery)" ng-click="vm.color2ndRow()" />
  <input type="button" value="變更第二筆分數" ng-click="vm.changeScore()" /> 
  <input type="button" value="第二筆移至最後" ng-click="vm.move2ndRow()" />
  <input type="button" value="依分數排序" ng-click="vm.sortArray()" />
  <input type="button" value="重新產生陣列" ng-click="vm.updatePlayers()" />
<script src="//code.jquery.com/jquery-2.1.1.min.js"></script>
  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.2/angular.js"></script>
  <script>
    function Player(id, name, score) {
      this.id = id;
      this.name = name;
      this.score = score;
    }
    function getPlayers() {
      return [
        new Player("A01", "Spider-Man", 63),
        new Player("A02", "Iron Man", 127),
        new Player("A03", "Jeffrey", 255),
        new Player("A04", "Darkthread", 32767)
      ];
    }
    function myViewModel($scope) {
      var self = this;
      self.players = getPlayers();
      self.color2ndRow = function() {
        $(".list tr:eq(1)").css("color", "red");
      };
      self.changeScore = function() {
        self.players[1].score = 0; //修改第二筆分數
      };
      self.move2ndRow = function() {
        var removed = self.players.splice(1, 1)[0];
        self.players.push(removed); //移動第二筆至最後
      };
      self.sortArray = function() {
        //依分數排序
        self.players.sort(function(a, b) { return a.score > b.score; })
      };
      self.updatePlayers = function() {
        self.players = getPlayers();
      };
    }
    angular.module("app", [])
    .controller("ctrl", myViewModel);
  </script>
</body>
</html>

一個陣列交給ngRepeat展成<table>,另外再放幾顆觸發測試,操作示範如下: Live Demo

一開始使用jQuery.css()將第二筆資料改為紅色,之後修改score、調動順序、陣列重新排序,該筆資料維持紅色,但是當重新指定給self.players內容完全相同的陣列,A02 Iron Man變回黑色。

看似奇妙的運作,原理在本草綱目ngRepeat官方說明已有記載,ngRepeat為每個player物件偷偷加上唯一的$$hashKey屬性值,以此識別物件與DOM元素的對應。當同一物件屬性被修改、在陣列的順序被調動,其$$hashKey不受影響,故ngRepeat會修改、調動既有DOM元素,而不是重新建立。在self.updatePlayers()裡,雖然player物件陣列內容完全相同,但其中的palyer物件與第一次產生的player物件為不同的執行個體(Instance),無法用$$hashKey對應到原有DOM元素,故ngRepeat選擇重新產生。

在<tr>加上第四欄<td ng-bind="player.$$hashKey"></td> 觀察$$hashKey的變化,可以發現到self.updatePlayers()之後$$hashKey全被換掉,解釋了為何重新指派相同內容陣列卻無法重覆使用DOM元素:Live Demo

面對此一行為,直覺解法是避免用self.players=… 重新指派陣列,改為「比對新舊資料項目,從原陣列中移除該移除的、新増要新増的、改掉待修改的項目」,藉以保留$$hashKey,儘可能沿用既有DOM元素。比對工程說難不難,但要自己寫少不了得費點工。所幸,ngRepeat自angular 1.2起新增了track by 子句,只需寫成"player in vm.players track by player.id",ngRepeat便會以player.id為鍵值進行前述比對,不用$$hashKey,改由id判斷是否資料為同一筆,決定是否沿用原來的DOM元素。

如以下範例,改寫成<tr ng-repeat="player in vm.players track by player.id">,即便重新指派self.players,只要player.id相同,將沿用現成的<tr>不重新建立,因此在按下「重新產生陣列」時,A02仍維持紅色。Live Demo

要注意的事,track by 指定的屬性必須具有唯一性,若同一陣列中出現兩筆相同值,將引發錯誤:Error: [ngRepeat:dupes] Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: player in vm.players track by player.id, Duplicate key: A02, Duplicate value: {"id":"A02","name":"Darkthread","score":32767} Live Demo

了解此一特性後,遇到ngRepeat每筆資料DOM元素結構複雜或產生耗時(例如:內含複雜的Directive)的場合,記得可善用"track by …"提高DOM元素的再利用率,改善效能。

寫完馬年最後一篇文章,順祝大家羊年大吉,新春如意!

[NG系列]

http://www.darkthread.net/kolab/labs/default.aspx?m=post&t=angularjs

Comments

# by Billy

我曾經亦遇到這個問題。 在angularjs 的scope 中,signalr 實時更新訂單的資料。最初我使用的是整個array 重新指派,後來發現效能很差,用了track by 確實有所善。 但又出現另一個問題,就是於同一秒內新增數十筆訂單,經過profiler 後發現$scope.$apply 很慢,需時大約2-3 秒才運行完畢,最後經過一輪搜索,發現使用$scope.$evalAsync()/$scope.$applyAsync() 後,運行速度就變為30-40ms 了。 還有一個陷阱點,因為習慣的原因,我之前一直沿用jQuery tableSorter 來做sorting,但和angularjs 一起使用,效能完全不能接受,後來發覺都是用angularjs 的orderBy 最好。

# by Billy

對於每筆訂單中不會變更的數據,使用bind once 會大大的減少$scope.$watch(),增加效能。可惜的是,不支援IE8 的angularjs 1.3才有內建,但不能支援IE8 是客戶不能接受的硬傷。

# by Jeffrey

to Billy, 謝謝你的經驗分享

Post a comment