2013年9月16日 星期一

AngularJS 與肥 Lists 的愛恨糾葛 - 看技術文章學英文第一彈

原文在此:AngularJS Performance Tuning for Long Lists

警告:此篇為翻譯文章,所有內容皆為原作者所有

此外歡迎指正破英文,如果哪邊有嚴重謬誤哪肯定是我把靈魂出賣給了 google 小姐





AngularJS Lists 效能調教

AnglarJS 棒棒!但是當他在處裡包含複雜 data 結構的肥 Lists的時候,會像駭客任務一樣產生子彈時間般的世界都靜止了,我們在將核心移植到AnglarJS的時候遭遇這個鳥問題。照理來說應該要很滑溜的顯示500 rows,但是第一次親密接觸的時候花了七秒鐘才顯示出來,這下悲劇惹!
我們發現實作上兩項主要的效能問題,一個是關於ng-repeat directive,另一個是關於filtering
接下來的文章記錄了我們使用各種伎倆,來解決或減輕親密接觸時感受不好的經驗談,這會給你一些點子跟提示,讓你知道什麼東東你可以自個兒玩玩,什麼東西最好不要亂玩。

為神馬ng-repeat 碰上肥lists會這麼慢?

AngularJS ng-repeat directive 大約在 2500 two-way data binding 的時候就快升天了。你可以看看Misko Hevery 的文章(See further reading no 2)。這是因為 AngularJS 使用髒檢查(dirty checking)來監聽所有改變,每次的監聽都要消耗時間,所以包含複雜 data 結構的肥 Lists 將會搞爛你的 APP

先來瞧瞧效能發生啥事

Time logging directive:

我們寫了個簡單的directive來測量list 渲染出來需要花多少時間,他會記錄ng-repeat生出$last浪費多少光陰,資料的參考會存在TimeTracker-service裡面,所以他跟從server讀取data的時間是獨立開來紀錄的。

// Post repeat directive for logging the rendering time
angular.module('siApp.services').directive('postRepeatDirective',
    ['$timeout', '$log', 'TimeTracker',
    function ($timeout, $log, TimeTracker) {
        return function (scope, element, attrs) {
            if (scope.$last) {
                $timeout(function () {
                    var timeFinishedLoadingList = TimeTracker.reviewListLoaded();
                    var ref = new Date(timeFinishedLoadingList);
                    var end = new Date();
                    $log.debug("## DOM rendering list took: " + (end - ref) + " ms");
                });
            }
        };
    }
]);

//Use in HTML:
//<tr ng-repeat="item in items" post-repeat-directive>...</tr>


Chrome 的時間軸開發者工具

Chrome 開發者工具 timeline tab,你可以看到事件和瀏覽器每秒記憶體的分配。記憶體工具對於偵查記憶體消耗和一頁需要多少記憶體十分有效。每秒幀數低於30是頁面閃爍是大部分的問題點,幀數工具提供了渲染效能的觀察,此外他還顯示 JavaScript 消耗了多少 CPU 的時間。

最基本的調教伎倆 - 限制 List Size

限制顯示時 List Size 是舒緩問題的最佳的方式,你可以玩玩分頁,或者無限捲捲。

分頁

我們使用 AngularJS 玩分頁是靠”limitTo filter (1.1.4才有 :P) 搭配自製”startFromfilter。它可以讓我們限制List Size 來減少渲染時間,他是最有效減少渲染時間的方法。
如果你不能或者不想這麼幹,但是你有filtering慢的症狀,看看步驟五,然後使用ng-show 來隱藏要過濾掉的元素。

//Pagination in controller
$scope.currentPage = 0;
$scope.pageSize = 75;
$scope.numberOfPages = function() {
    return Math.ceil($scope.displayedItemsList.length/ $scope.pageSize);
};

//Start from filter
angular.module('app').filter('startFrom', function() {
    return function(input, start) {        
        return input.slice(start);
    };

//Use in HTML
//Pagination buttons
//<button ng-repeat="i in getNumber(numberOfPages()) track by $index" ng-click="setCurrentPage($index)">{{$index + 1}}</button

//Displayed list
//<tr ng-repeat="item in displayedItemsList | startFrom: currentPage * pageSize  | limitTo:pageSize" /tr>


無限捲捲

使用無限捲捲不是我們的使用情境的選項,如果你想深入的了解一下無限捲捲,下面這個連結將帶領你前往AngularJS infinite scrolling 專案:

調教方針

1.渲染List的時候不要使用data binding

這個是最顯而易見的解法,data-binding最有可能是效能問題的起點。拋棄data binding是絕對沒問題的,如果你只是要一次性的顯示這些資料,而且不需要處理更新或變化。悲劇的是你的List將失去控制,所以這個親密接觸的方法也不是我們的選項。
有興趣點這個:

2. 不要使用行內方法呼叫計算data

為了在controller裡面直接過濾List,不要使用方法來取得過濾後的集合,ng-repeat會在每次$diges時計算每個expression,這將會十分頻繁。在我們的範例裡filteredItems() 返回濾後的集合,如果這個evaluation 很慢,這將會立馬拖慢APP速度。

<li ng-repeat="item in filteredItems()"> <!--Bad idea, since very often evaluated.-->
<li ng-repeat="item in items"> <!--Way to go! -->


3. 用兩個List (一個給view 顯示,一個給data)

區分開顯示用的List跟資料用的List是實用的模式,這讓你可以偷偷的玩一些filters 和快取住集合再交給 view。下面是一個非常基本的範例,filteredLists 變數用來接住集合,applyFilter 方法負責處理mapping.

/* Controller */
// Basic list
var items = [{name:"John", active:true }, {name:"Adam"}, {name:"Chris"}, {name:"Heather"}];

// Init displayedList
$scope.displayedItems = items;

// Filter Cache
var filteredLists['active'] = $filter('filter')(items, {"active" : true});

// Apply the filter
$scope.applyFilter = function(type) {
    if (filteredLists.hasOwnProperty(type)){ // Check if filter is cached
        $scope.displayedItems = filteredLists[type];
    } else {
        /* Non cached filtering */
    }
}

// Reset filter
$scope.resetFilter = function() {
    $scope.displayedItems = items;
}

/* View */
//<button ng-click="applyFilter('active')">Select active</button>
//<ul><li ng-repeat="item in displayedItems">{{item.name}}<li></ul>


4.使用ng-if 代替 ng-show顯示額外的樣板

如果你的directives templates 需要秀一些額外的資訊,例如點list item 的後顯示,你最好使用 ng-if(1.1.5才有:P)ng-if 不給渲染(ng-show比較起來)。因此額外的DOM elements data-bindings 將會在需要的時候才計算。

<li ng-repeat="item in items">
    <p>{{ item.title }} </p>
    <button ng-click="item.showDetails = !item.showDetails">Show details</buttons>
    <div ng-if="item.showDetails">
        {{item.details}}
    </div>
</li>


5. 不要使用ng-mouseenter, ng-mouseleave, 之類的東東

內建的 ng-mouseenter 會造成閃爍,主要是因為瀏覽器低於每秒30幀,使用正港的jQuery來造動畫和滑過效果來解決這個問題吧。如果要注意最後的DOM改變狀態,記得要使用jQuery live() function包裝mouse 事件。

6. 調教filtering的小提示: 使用ng-show隱藏要排除的元素

對肥List 使用filters 同樣令人髮指。因為每個 filter 會對原本的 List 產生一個子集合,在許多情況下,當 data 沒有改變的情況下 filter 返回相同的東西,因此預先過濾 data 再套用到view 能省不少時間。
ng-repeat中套用filters的時候,每個filter回傳原集合的子集,AngularJS會在$destroy的時候移除排除掉的DOM元素,也會移除他們的 $scope。當filter 輸入改變,子集會跟著改變,然後元素必須被重新連結或者再次$destroyed。在大多數情況下,這是絕對沒問題的,但是當使用者頻繁的使用filters,或者List 非常肥,這會持續連結和銷毀DOM元素而影響效能。要加快過濾的話你可以使用ng-show ng-hide directives。在controller裡面計算和賦予旗標給每個需要過濾的項目,根據旗標觸發ng-show,因此他只會增加class elements 代替移除子集、$scope DOM
1.           一個觸發 ng-show 的方法是使用表達式語法,ng-show 的值是通過filter 語法計算出來。
可以參閱這兒 plunkr example

<input ng-model="query"></input>
<li ng-repeat="item in items" ng-show="([item.name] | filter:query).length">{{item.name}}</li>

2.           另一個方法是使用attribute 傳遞ng-show 然後用另一個子controller來計算,這是種比較複雜的方式,然而Ben Nadel有更妙的的建議在他的 blog post

7. 調教filtering的小提示: Debounce input

延續第六點,另一個解決連續filtering的方法是防止input抖動。舉例來說,如果使用者KEY了一些關鍵字,filter只需要在停止輸入之後再開始作用即可。
一個防抖動的好解法 - debounce service
套用在你的view controller 像這樣:

/* Controller */
// Watch the queryInput and debounce the filtering by 350 ms.
$scope.$watch('queryInput', function (newValue, oldValue) {
    if (newValue === oldValue) { return; }
    $debounce(applyQuery, 350);
});
var applyQuery = function () {
    $scope.filter.query = $scope.query;
};

/* View */
//<input ng-model="queryInput"/>
//<li ng-repeat= item in items | filter:filter.query>{{ item.title }} </li>

Further reading

§  1. Project organization with huge apps: http://briantford.com/blog/huuuuuge-angular-apps.html
§  2. Stackoverflow answer of Misko Hevery concerning angular data-binding performance: http://stackoverflow.com/questions/9682092/databinding-in-angularjs/9693933#9693933
§  3. Short article with different approaches increase ng-repeat performance:http://www.williambrownstreet.net/blog/?p=437
§  5. Good article on using the scope: http://thenittygritty.co/angularjs-pitfalls-using-scopes
§  6. AngularJS project for dynamic templates http://onehungrymind.com/angularjs-dynamic-templates
§  Rendering without data-binding: https://github.com/Pasvaz/bindonce