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元素已被重建。
設計測試網頁如下:
<!DOCTYPEhtml>
<htmlng-app="app">
<head>
<metacharset="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>
<bodyng-controller="ctrl as vm">
<tableclass="list">
<trng-repeat="player in vm.players">
<tdng-bind="player.id"></td>
<tdng-bind="player.name"></td>
<tdng-bind="player.score"></td>
</tr>
</table>
<inputtype="button"value="第二筆染色(by jQuery)"ng-click="vm.color2ndRow()"/>
<inputtype="button"value="變更第二筆分數"ng-click="vm.changeScore()"/>
<inputtype="button"value="第二筆移至最後"ng-click="vm.move2ndRow()"/>
<inputtype="button"value="依分數排序"ng-click="vm.sortArray()"/>
<inputtype="button"value="重新產生陣列"ng-click="vm.updatePlayers()"/>
<scriptsrc="//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系列]