2014年4月1日 星期二

AngularJS 和卡卡女神說再見!

  不知道大家會不會有這個問題,AngularJS 在開發上雖然很方便,可是當資料量大到一個程度,網頁就會整個大卡。為了不要因為 ngRepeat 變成卡卡網站,最近研究了如何優化 angularJS ngRepeat 的效能。以下來跟大家稍稍分享小小心得。使用 ng-repeat 會導致網頁反應遲緩的原因有兩個,下面整理給大家:


不必要的 2-way binding:

  在用 AngularJS 的人都一定知道, AngularJS 的一大特色就是 2-way binding,這個特色讓我們在開發時可以透過 $scope 讓 template 和 javascript 內的值同步。所以我們會很常撰寫這樣的 template:

  上面這段 code 一切正常,但是請注意, AngularJS 為了要達到同步變更值,ng-* 的 directive 裡面幾乎都呼叫了 scope.$watch 去監測送進去的值,而我們都知道 scope.$watch 是一個 dirty work,每次 $digest 時,所有的 $watch 就會被執行。這保證 AngularJS 的 2-way binding。但是其實上面的 code 我們只是單純的想要印出所有 person 的資訊。可是一個 user 就註冊了 6 個 watcher,外加 ng-repeat,總共是 7 個,而如果 persons 不只 1 個的話 watcher 會更多。但是這段 code 的重點只是要顯示資訊,之後不會再被改變阿!註冊這麼多 $watch 根本就是浪費時間。而 bindonce 解決了這個問題。
  bindonce 顧名思義就是:只 bind 一次,這個方便的 module 解決了上面的問題,如果你想看看到底有什麼差別,他提供了兩個連結,透過以下的連結,去 load 不同數目的 person,再去 input 打字,可以明顯感受到沒有用 bindonce 的頁面,當 load 越多 person 時,畫面越卡;而使用 bindonce,由於不管你 load 幾個 person 都只有註冊 5 個 $watcher,所以不會有跟卡卡見面的問題!

without bindonce: http://plnkr.co/edit/jwrHVb?p=preview
with bindonce: http://plnkr.co/edit/0DGOrk?p=preview

  至於如何使用,我想大家都已經要優化效能了,去看 bindonce 的文件應該不是問題才是,這邊就不多說啦。*1

ngRepeat 的小秘密:

1. track by (AngularJS > v.1.2)

  在 AngularJS 1.2 之前,假設你寫這一段 code:

var getFriends = function(){
   return ([
    {
      id:1,
      name: Tom
    },
    {
      id:2,
      name: Lisa
    }
      
   ]);
}
$scope.friends = getFriends();
$scope.reloadFriends = function(){
    $scope.friends = getFriends();
}

當你每次呼叫 $scope.reloadFriends(),把 getFriends() 中的 friends list 指定給 $scope.friends 時,其實 ngRepeat 每次都產生新的 DOM 給每個 friend。可是其實你只是要 update 你的 friends 的內容。在 AngularJS 1.2 之後,透過 track by,AngularJS 不會重新產生 DOM,而會尋找指定的 identifier 來update DOM。以剛才的例子來說:

  


  • {{friend.name}}
  • {{friend.name}}

  我們用 friend.id 當做 unique identifier,每次 reload 時,因為找到一樣的 id ,ngRepeat 就不會產生新的 DOM,而是用找到的 DOM 做內容的修改。因此在 ngRepeat 中利用 track by 可以有效節省不必要的 DOM 產生。也因此速度上得以提昇。

 2. $$hashkey (AngularJS < v1.2 或 其他需求)
  在 AngularJS v1.2 之前並沒有 track by 的功能,因此像以上的問題,只能透過 $$hashkey 來解決。ngRepeat 決定產生新的 DOM 是透過觀察該 repeat object 的 $$hashkey 值。$$hashkey 是 AngularJS 產生的 expando property,每當進入 $digest 循環時,AngularJS 會去檢查 $$hashkey 值,一旦發現有第一次出現的物件(沒有 $$hashkey),就會建立一個新 DOM。
  如果你用的是 AngularJS 1.2 之前的版本,又有像以上的需求,Ben 大提供了一個 module,透過該 module 可以實現和 track by 一樣的目的。*3

  但是如果你單純的是把某個 array 當做展示資料來用,array 裡面沒有 unique identifier,那麼上面的作法就沒辦法實現不適用了。舉例來說,現在要做 pagination,從 server side 拿了 200 筆的 log 回來,分成 10 頁,每頁 20 筆。但你只想用一個 size 為 20 的 array 來 show log。也就是說,每次換頁時,我們是去 200 筆的 array 切割出對的 20 筆,把它指定給 showLog。

var logs = [ // 200 logs
{key1: value, key2: value2},
{key3: value4, key4: value4},
 ...
];
var getLogs = function(page){
  return logs.slice(page*20, (page+1)*20);
}

$scope.setPage = function(page){
  $scope.showLogs = getLogs(page);
};


  上面這段程式碼在換頁時,同樣會讓 ngRepeat 產生新的 DOM,當資料量很大時,每次換頁就會變得非常緩慢。這邊提供一個解法:

var logs = [ // 200 logs
{key1: value, key2: value2},
{key3: value4, key4: value4},
 ...
];
var getLogs = function(page){
  tmpLogs = logs.slice(page*20, (page+1)*20);
  for(var i=0; i<$scope.showLogs.length; i++){
    for(var key in $scope.showLogs[i]){
       if(key === '$$hashkey'){
          tmpLogs[i]['$$hashkey'] = $scope.showLogs[i]['$$hashkey'];
       }
    }
  }
  return tmpLogs;
}

$scope.setPage = function(page){
  $scope.showLogs = getLogs(page);
};

  上面的 getLogs 透過把 $scope.showLogs 的 $$hashkey 值複製給要換的 Log 裡,再傳送回去,如此可以騙到 ngRepeat 使之不會產生新的 DOM,又可以達到更新內容的目的。

  以上提供一些小方法,希望可以幫助大家在使用 AngularJS 時能不再跟卡卡女神見面。

References:

2. Using Track-By with ngRepeat in AngularJS 1.2: http://www.bennadel.com/blog/2556-Using-Track-By-With-ngRepeat-In-AngularJS-1-2.htm
3. HashKeyCopier - An AngularJS Utility Class For Merging Cached And Live Data: http://www.bennadel.com/blog/2472-HashKeyCopier-An-AngularJS-Utility-Class-For-Merging-Cached-And-Live-Data.htm
4. Rendering DOM elements with ngRepeat in AngularJS: http://www.bennadel.com/blog/2443-Rendering-DOM-Elements-With-ngRepeat-In-AngularJS.htm