Faster paging in AngularJS

很多 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 鈕之外,上面的範例還提供了額外的兩種可以操作換頁功能的方法(完全是我個人覺得按下面那排鈕來換頁好不方便):

  1. 鍵盤的 “left” 或 “right”
  2. 按著 “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.pagedDatang-repeat 的目標
  • $scope.item 為各個 ng-repeat 的 child scope 綁定的 object
  • 淡黃色部分表示 paging 時變動的 object 內容

Usual

換頁時會換掉各個位置的 object,因此 $$hashKey 也會隨之改變,DOM 也一並重新產生。

concept-usual.png

Faster paging

採取只取代 object 內容的方式,對 ng-repeat 來說,沒有任何變動($$hashKey 也不會改變)。

concept-faster.png

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 方法喔~

fb-share.png