1 介紹
無論你是為一個擁有大量使用者的舊應用編寫一個Angalar前端,或已有的Angular應用正在迅速擴張,效能都是一個重要方面。理解什麼會導致AngularJS應用程式響應變慢,並且知道在開發過程中對此做出一些權衡是非常重要的。本文將講述一些AngularJS可能導致的常見效能問題,以及給出在未來如何修復和避免他們的建議。
1.1 需求,假設
本文假設對JavaScript程式語言和AngularJS比較熟悉。當使用特定於版本的特性,他們會被標註。如果你已經花了一些時間在玩Angular,但還沒有認真地處理效能問題,那麼你最能吸收這篇文章的要義。
2 行業工具
2.1 基準分析
最出色的程式碼基準測試工具就是 jsPerf。為了增強可讀性,我將在後面相關的部分連結到特定的test runs(測試例子)。
2.2 效能分析
Chrome開發工具有一個很棒的Javascript分析器。我強烈推薦閱讀本系列文章。
2.3 Angular Batarang
由Angular 核心團隊維護的一個專用的Angular 偵錯程式, 在GitHub上可以獲取。
3 軟體效能
有兩個導致軟體效能差的根本原因。
第一個是演算法的時間複雜度。解決這個問題很大程度上超出了本文的範圍,一般可以這樣說,時間複雜度是衡量一個程式需要做多少次的比較來實現一個結果。比較數量越多,程式越慢。一個簡單的例子是線性查詢與二分查詢。線性查詢對於同一組資料需要進行更多的比較,因此會慢。時間複雜度的詳細討論,請參考 維基百科文章。
第二個原因是空間複雜度。這是一臺電腦執行你的解決方案需要多少“空間”或記憶體的測量。需要的記憶體越多,解決方案就越慢。本文將討論的大多數問題,圍繞空間複雜度。詳細討論,請參閱這裡.。
4 Javascript效能
有些要說的是關於Javascript效能,這裡並不侷限於Angular。
4.1 迴圈
避免在一個迴圈中呼叫外部函式。一旦任何呼叫可以在迴圈外部的完成,它將大大加速你的系統。例如:
1 2 3 4 5 |
var sum = 0; for(var x = 0; x < 100; x++){ var keys = Object.keys(obj); sum = sum + keys[x]; } |
上面將大大慢於下面:
1 2 3 4 5 |
var sum = 0; var keys = Object.keys(obj); for(var x = 0; x < 100; x++){ sum = sum + keys[x]; } |
http://jsperf.com/for-loop-perf-demo-basic
4.2 Dom訪問
需要著重注意的是訪問DOM是昂貴的。
1 |
angular.element('div.elementClass') |
雖然這在AngularJS應該不是一個問題,意識到這一點仍然是有用的。這裡說的第二件事是,DOM樹應該保持儘可能小。
最後,如果可能的話,避免修改DOM,不設定內聯樣式。這是由於JavaScript重排。重排的深度的討論超出了本文的範圍,但是這裡可以找到一個不錯的參考。
4.3 變數作用域和垃圾收集
所有變數作用域儘可能緊密,會讓JavaScript垃圾收集器儘早地釋放你的記憶體。這是通常JavaScript,特別是Angular緩慢,延遲,不響應的一個極其常見原因。請注意以下問題:
1 2 3 4 5 |
function demo(){ var b = {childFunction: function(){console.log('hi this is the child function')}; b.childFunction(); return b; } |
函式終止時,將沒有必要進一步引用b,垃圾收集器將釋放記憶體。然而,如果在其他地方有這樣一行程式碼:
1 |
var cFunc = demo(); |
我們現在將物件繫結到一個變數同時保持引用,阻止垃圾收集器回收它。雖然這可能是必要的,重要的是你要知道物件引用有什麼影響。
4.4 陣列和物件
有許多事情要談。首先且最簡單的是,陣列總是比物件更快,數字訪問好於非數字訪問。
1 2 3 |
for (var x=0; x<arr.length; x++) { i = arr[x].index; } |
上面快於下面
1 2 3 |
(var x=0; x<100; x++) { i = obj[x].index; } |
還快於
1 2 3 4 |
var keys = Object.keys(obj); for (var x = 0; x < keys.length; x++){ i = obj[keys[x]].index; } |
http://jsperf.com/array-vs-object-perf-demo
此外,請注意,基於V8的現代瀏覽器,有較少屬性的物件會使用一種特殊的表現形式,來提高他們的訪問速度,所以試著保持屬性的數量最小化。也請注意,儘管JavaScript陣列中可以使用混合型別,並不意味著這是一個好主意:
1 2 |
var oneType=[1,2,3,4,5,6] var multiType=["string", 1,2,3, {a: 'x'}] |
http://jsperf.com/array-types-compare-perf
避免使用delete。例如,給定:
1 2 3 |
var arr = [1,2,3,4,5,6]; var arrDelete = [1,2,3,4,5,6]; delete arrDelete[3]; |
任何對arrDelete的迭代將慢於對arr的相同迭代。
http://jsperf.com/delet-is-slow
這將會在陣列中建立一個坑,大大降低操作的效能。
5 重要概念
既然我們已經討論了JavaScript效能,它對理解一些Angular背後的關鍵概念很重要。
5.1 Scopes(作用域) 和Digest週期
Angular Scopes本質上是JavaScript物件。他們遵循一個預定義的原型繼承規則,深入討論超出了本文的範圍。與本文有關係的,如前所述,使用小Scopes比大Scopes更快。
另一個可以得出的結論是,任何時間,一個新的Scopes被建立,就會為垃圾收集器增加更多需要收集的值。
編寫普通的且效能特別的Angular JS應用程式時,digest週期特別重要。實際上,每一個scope中都儲存了一個$$watchers函式陣列。
每次對scope中的值呼叫$watch函式,或者一個值被插入或繫結到DOM上,如使用ng-repeat,ng-switch,ng-if,或者其他的DOM屬性或元素,都會在最裡層的scope的$$watchers陣列中新增一個函式。
當scope裡的任何值發生變化時, $$watchers中的所有watcher將被觸發
,如果其中任何一個修改了一個被檢測的值, 他們將再次被觸發。這將繼續下去,直到$$watchers陣列不在有任何變化,或AngularJS丟擲一個異常。
另外,如果非Angular程式碼通過 $scope.$apply()
執行。這將立即啟動digest週期。
最後注意的是,$scope.evalAsync()將在一個非同步迴圈中執行,它不會觸發一個新的Digest週期, 它將執行在當前或下一個digest週期的末尾。
6 常見問題:用心設計Angular
6.1 大型物件和伺服器呼叫。
所以這一切教會了我們什麼?首先,我們應該思考我們的資料模型,努力限制物件的複雜性。這對從伺服器返回的物件特別重要。
簡單地將整個資料庫行強制.toJson()是非常誘人。這裡必須要強調:請不要這樣做。
使用一個自定義的序列化器,返回Angular應用程式必要的屬性的子集。
6.2 監視函式
另一個常見的問題是在watcher或繫結中使用函式。不要將任何指令(ng-show ng-repeat,等等)直接繫結函式。不要直接監測函式的結果。該函式將在每個digest週期執行,這極有可能降緩你的程式。
6.3 監視物件
類似的,Angular 能夠通過將scope.$watch第三個可選的引數設定為true 來監視
整個物件。說句不好聽,這是一個非常糟糕的想法。一個更好的解決方案是依靠服務和物件引用,在scope之間傳播物件的變化。
7 列表問題
7.1 大型列表
如果可能的話,避免大型列表。ng-repeat會做一些相當沉重的DOM操作(更不用說汙染$$watchers
),所以無論是通過分頁或無限滾動,試著保持任何列表的渲染使用小型資料。
7.2 過濾器
如果可能的話,避免使用過濾器。他們每個digest迴圈執行兩次,一次是當發生任何變化,另一次是收集進一步的改變,實際上,不從記憶體刪除任何子集,而是簡單地用css來過濾。
$index沒有什麼價值,因為它不再對應於實際的陣列索引,而是排序後陣列的索引。它還會阻止你釋放所有列表的scope。
7.3 更新ng-repeat
同樣重要的是避免使用ng-repeat時進行全域性列表重新整理。在內部,ng-repeat將產生一個$$ hashKey屬性,用它來作為集合中的識別項。這意味著做一些像scope.listBoundToNgRepeat = serverFetch()
的操作將導致對整個列表進行一個完整的重新計算,導致對每個個體元素的transcludes執行以及watchers 觸發。這是一個非常昂貴的做法。
有兩種方法可以解決這個問題。一是維護兩個集合和在過濾後的集合上使用ng-repeat(更通用的,需要定製同步邏輯,因此演算法更復雜和難以的維護),另一種是使用track by來指定自己的key(需要Angular 1.2+,通用性略低於前者,不需要自定義同步邏輯)。
簡而言之
1 |
scope.arr = mockServerFetch(); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var a = mockServerFetch(); for(var i = scope.arr.length - 1; i >=0; i--){ var result = _.find(a, function(r){ return (r && r.trackingKey == scope.arr[i].trackingKey); }); if (!result){ scope.arr.splice(i, 1); } else { a.splice(a.indexOf(scope.arr[i]), 1); } } _.map(a, function(newItem){ scope.arr.push(newItem); }); |
將比簡單新增慢:
1 |
<div ng-repeat="a in arr track by a.trackingKey"> |
換成:
1 |
<div ng-repeat="a in arr">; |
所有的三種方法的一個完整的功能的演示可以在這裡找到.
點選這三種方法,可以很明顯地重新演示這個問題。一方面要注意的,track方法只有在迭代物件的一個欄位能在集合中保證唯一時才能被使用。對於伺服器資料,id屬性可以作為天生的tracker。如果沒有這個條件,不幸的是,只有自定義同步邏輯是唯一的出路。
8. 渲染問題
Angular 應用緩慢的常見來源是在ng-if或ng-switch上不正確使用ng-hide和ng-show 。區別是重要的,重要性不能在效能的上下文中被誇大。
ng-hide和ng-show簡單地切換CSS display屬性。在實踐中這意味著任何顯示或隱藏仍將在頁面上,儘管看不見。任何scope將存在,所有$$watchers將觸發。
ng-if和ng-switch實際上完全刪除或新增DOM。使用用ng-if刪除的東西不在scope中。效能優勢現在應該很明顯,但也是有考究的。具體地說,切換show/hide相對便宜,但切換if/switch相對昂貴。不幸的是這就導致了需要根據不同情況判斷使用不同的呼叫。做出這個決定需要回答的問題是:
- How frequently will this change? (the more frequent, the worse fit
ng-if
is). - 這經常會如何變化?(越頻繁,使用ng-if越糟糕)。
- How heavy is the scope? (the heavyer, the better fit
ng-if
is). - scope有多大?(越大,越是使用ng-if)。
9. Digest週期問題
9.1 繫結
試著減少你的繫結。Angular 1.3中,有一個新的只進行一次的繫結,語法形狀為{ {::scopeValue } }。這將從scope中拿取一次,而不會新增一個watcher到watchers陣列。
9.2 $digest() and $apply()
scope.$apply是一個強大的工具,它允許你將值從Angular外部引入到你的應用程式。本質上它會Angular的所有事件(ng-click等)上被觸發。問題出現在,scope.$apply始於$rootScope,同時貫穿整個scope鏈,將導致每個scope觸發每個watcher。
另一方面scope.$digest只會在指定的scope內觸發,只往下傳遞。效能優勢應該相當不證自明的。折中的方案,當然是,任何父scope將不會收到這個更新,直到下一個迴圈週期。
9.3 $watch()
scope.$watch()現在已經討論了好幾次。一般來說,scope.$watch()表明糟糕的體系結構。大部分情況下,較低開銷的服務和引用的一些組合繫結就能達到相同的結果。如果您必須建立一個watcher,永遠記住儘可能地早點解除繫結。你可以通過呼叫由$watch返回的解綁函式來解除一個watcher,。
1 2 |
var unbinder = scope.$watch('scopeValueToBeWatcher', function(newVal, oldVal){}); unbinder(); //this line removes the watch from $$watchers. |
如果你不能過早地解除繫結,記得在$on(‘$destroy’)解除繫結
9.4 $on, $broadcast , and $emit
像$watch,這些都是慢的,因為事件(可能)遍歷你的整個scope的層次結構。除此之外,它們會像GOTO一樣,讓您的應用程式很難除錯。幸運的是,像$watch,如果有必要他們可以通過返回函式解除繫結(記得在$on('$destroy')解除繫結,同時可以通過正確地使用服務和scope繼承來完全避免)。
9.5 $destroy
如前所述,你應該總是顯式呼叫$on(‘$destroy’),解綁你所有的watchers和事件監聽器,並取消任何$timeout例項,或其他正在進行的非同步互動。這不僅是確保安全良好的實踐,同時標記你的scope讓垃圾收集更迅速。不這樣做會讓他們在後臺執行,浪費CPU和RAM。
尤其重要的是要記住的在$destroy
呼叫中解綁任何在指令元素上定義的DOM事件監聽器。不這樣做,將會導致在舊瀏覽器發生記憶體洩漏和在現代瀏覽器發生垃圾收集緩慢。一個非常重要的結論是,你需要記住在你移除DOM前呼叫scope.$destroy。
9.6 $evalAsync
scope.$evalAsync是一個強大的工具,它讓你把要執行的操作在當前digest週期的末尾進行排隊,不會導致在scope修改後的另一個digest週期。這需要基於具體案例思考,但預期的效果,evalAsync可以大大提高頁面的效能。
10 指令問題
10.1 隔離的Scope和Transclusion
隔離Scope 和Transclusion是Angular最令人興奮的一些事情。他們允許構建可重用、封裝的元件,它們在語法上和概念上優雅,讓Angular出彩的一個核心部分。
然而,他們有一個權衡。預設情況下,指令不建立一個scope,而是擁有和他們的父元素相同的範圍。通過建立一個新的隔離scope或Transclusion,來建立一個新的物件去跟蹤和新增新的watch,因此會減慢我們的應用程式。總是在你使用它前停下來並思考有沒有必要。
10.2 編譯週期
指令編譯功能在附加scope之前執行,這是執行任何DOM操作(例如繫結事件)的最佳的地方。從效能的角度來看,需要重要認識的,是元素和屬性傳遞到編譯函式使用了原始html模板,它在任何Angular變化前。在實踐中這意味著,DOM操作完成,將執行一次,直接使用。另一個重要的點事prelink和postlink的區別。簡而言之,prelinks執行由外而內,而postlinks執行由內而外。因此,prelinks提供輕微的效能提升,因為他們阻止內部指令執行第二次digest週期,當父節點在prelink修改scope。然而,子DOM可能不可用。
11 DOM事件問題
Angular提供了許多預先編譯好的DOM事件指令. ng-click,ng-mouseenter,ng-mouseleave等等。每次這些事件觸發時都會呼叫scope.$apply()。一個更有效的方法是直接使用addEventListener繫結,然後必要時使用scope.$digest。
12 總結
12.1 AngularJS:不好的部分
- ng-click和其他DOM事件
- scope.$watch
- scope.$on
- 指令postLink
- ng-repeat
- ng-show and ng-hide
12.2 AngularJS:好的(效能)部分
- track by
- 使用::只繫結一次
- compile和preLink
- $evalAsync
- 服務,作用域繼承,通過引用傳遞物件
- $destroy
- 解綁watches和事件監聽器
- ng-if和ng-switch
瞭解更多,https://www.airpair.com/angularjs/posts/angularjs-performance-large-applications#WLXbzSUvg6aWzlUP.99