很多 AngularJS performance 相關的文章都是以 long lists 為主題來做提升,這篇的訴求比較不一樣 - 是 paging speed。
本文所謂 paging speed ,指的是「由一頁換到另一頁所需要的速度」。 首先先來看看一個範例 AngularJS app 是如何切分資料以及換頁。
The usual way
這個範例用上了 UI Bootstrap 來做換頁輔助,它會幫你處理好總頁數跟一些換頁 UI (配合 Bootstrap) 的問題;基本上套用之後只要顧好利用 $scope.itemsPerPage
以及 $scope.currentPage
來將資料切片再餵給 ng-repeat
就可以輕鬆做出換頁的功能。
核心程式碼大概也就只有這樣:
// 目前頁面的變數有更動就更新 $scope.pagedData 的內容
$scope.$watch('currentPage', function (cp) {
if (cp) {
var slice = $scope.data.slice((cp - 1) * $scope.itemsPerPage, cp * $scope.itemsPerPage);
// 為了保持使用原本的陣列,這邊用 angular.extend
angular.extend($scope.pagedData, slice);
$scope.pagedData.length = slice.length;
}
});
<div class="row" ng-repeat="item in pagedData">
<a href="" class="card" ng-controller="CardCtrl" ng-click="card.toggle()" ng-cloak>
<img class="card__picture" ng-src="{{ item.picture }}" alt="{{ item.name }}" class="card__avatar">
<div class="card__name">{{ item.name }}</div>
<div class="card__phone">{{ item.phone }}</div>
<!-- Prepare for what is going to happen -->
<div ng-repeat="n in repeatList">
<div class="card__about" ng-show="card.opened && $index == 0">{{ item.about }}</div>
<div class="card__tags" ng-show="card.opened && $index == 0">
<span class="card__tags__tag label label-info" ng-repeat="tag in item.tags track by $index">{{ tag }}</span>
</div>
</div>
</a>
</div>
換頁速度的需求
除了去按下面那排 pagination 鈕之外,上面的範例還提供了額外的兩種可以操作換頁功能的方法(完全是我個人覺得按下面那排鈕來換頁好不方便):
- 鍵盤的 “left” 或 “right”
- 按著 “shift” 再滾動滑鼠滾輪
加上這兩個操作方法後,給 paging speed 增加了需多壓力,主因在於鍵盤(長按)與滑鼠滾輪的觸發頻率可以非常高,遠超過慢慢按那排 pagination 鈕。
你說用起來還是很順? 對,畢竟上面的範例給的壓力實在太小了。在真正會使用的具有較多功能的頁面裡,才能夠顯現出 paging speed 在這兩個特殊操作下的不足。
現在我們將每個 ng-repeat
裡面的 DOM 壓力增加:
// 這控制了會在每張 card 裡加上多少無用的 DOM element,用來模擬一些比較複雜的 app 情境
$scope.repeatList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
再試著用上述的兩個高觸發頻率來換頁看看。
Faster paging
修改後版本的 paging speed 低落的原因在於 ng-repeat
中每項的 DOM 結構過於龐大,照著 angular 原本 “magcially works” 的模式來做 paging 的結果就是:每次換頁都要刪去龐大的 DOM,再塞進龐大的 DOM
從 Rendering DOM Elements With ngRepeat In AngularJS 裡面可以瞭解一些 ng-repeat
的運作方法(雖然裡面的 angular 版本蠻舊的,但基本上運作方法沒有變),接著我們就利用一些相關的概念來提升 paging speed。
How it works
下面擺上一般作法跟這次要採用的作法的示意圖:
$scope.pagedData
即ng-repeat
的目標$scope.item
為各個ng-repeat
的 child scope 綁定的 object- 淡黃色部分表示 paging 時變動的 object 內容
Usual
換頁時會換掉各個位置的 object,因此 $$hashKey
也會隨之改變,DOM 也一並重新產生。
Faster paging
採取只取代 object 內容的方式,對 ng-repeat
來說,沒有任何變動($$hashKey
也不會改變)。
Implementation
Paging
$scope.pagedData = [];
$scope.$watch('currentPage', function (cp) {
if (cp) {
var slice = $scope.data.slice((cp - 1) * $scope.itemsPerPage, cp * $scope.itemsPerPage);
angular.forEach(slice, function (v, i) {
if (!$scope.pagedData[i]) {
// 不存在就建立新的 object
$scope.pagedData.push({});
}
// 使用 angular.extend() 來保留 object
angular.extend($scope.pagedData[i], v);
});
// 考慮到最後一頁有可能會顯示數量會減少,透過設定 length 來清掉不需要的 object
// 因此,在倒數兩頁間切換會有 DOM 的增刪
$scope.pagedData.length = slice.length;
}
});
Child scope state reset
其實範例中每一個項目在點擊之後都可以做「展開」或「收起」的動作,這是靠著 child scope 上的 CardCtrl
在進行的。
改成新的 paging 方法之後,本來應該隨著 DOM 增刪而重新建立的 CardCtrl
變得都無法重置,因此我們需要搭配一些改變來讓新的 paging 置換 child scope object 內容之後還是能夠自我重置。
建立一個新的 service cardService
.factory('cardService', function () {
function Card(data) {
this.init(data);
}
var p = Card.prototype;
p.opened = false; // 開/關 狀態
p.init = function (data) {
this.opened = false;
this.data = data;
};
// 開/關
p.toggle = function () {
this.opened = !this.opened;
};
return {
create: function (data) {
// 每次 create 會丟回一個新的 Card instance
return new Card(data);
}
};
})
將 CardCtrl
搭配 cardService
以自動重置的方式改寫
.controller('CardCtrl', function CardCtrl($scope, cardService) {
// 利用 $scope.$watchCollection 來檢查 object 的淺層變動
$scope.$watchCollection('item', function (item) {
if (!$scope.card) {
$scope.card = cardService.create(item);
}
else {
$scope.card.init(item);
}
});
})
改造完成!趕快用上面所說的特別操作來試一下超快的換頁速度吧~
Baseline
當然也不是所有的換頁情況都適用本文的方法:
- 講求 web app 的優雅度勝於使用效率
- 講求換頁的轉場效果(會使用 transition 或 animation)
- 不趕時間(?)
後記
這篇文章為了表達概念,特別用 Sketch 精心製作了示意圖,還真的是特別累人 XD (Sketch 沒有那麼熟 lol)
另外順便工商服務一下,本公司的最新力作 ParrotTalks抄筆記 已經正式上線啦! 「ParrotTalks抄筆記」的筆記本就是用了本文的 paging 方法喔~