優化Angular應用的效能
MVVM框架的效能,其實就取決於幾個因素:
- 監控的個數
- 資料變更檢測與繫結的方式
- 索引的效能
- 資料的大小
- 資料的結構
我們要優化Angular專案的效能,也需要從這幾個方面入手。
1. 減少監控值的個數
監控值的個數怎麼減少呢?
考慮極端情況,在不引入Angular的時候,監控的個數是為0的,每當我們有需要繫結的資料項,就產生了監控值。
我們注意到,Angular裡面使用了一種HTML模板語法來做繫結,開發業務專案非常方便,但考慮一下,這種所謂的“模板”,其實與我們常見的那種模板是不同的。
傳統的模板,是靜態模板,將資料代入模板之後生成介面,之後資料再有變化,介面也不會變。但Angular的這種“模板”是動態的,當介面生成完畢,資料產生變更的時候,介面還是會更新。
這是Angular的優勢,但我們有時候也會因為使用不當,反而增加困擾。因為Angular採用了變動檢測的方式來跟蹤資料的變化,這些事情都是有負擔的,很多時候,有些資料在初始化之後就不再會變化,但因為我們沒有把它們區分出來,Angular還是要生成一個監聽器來跟蹤這部分資料的變化,效能也就受到牽累。
在這種情況下,可以採用單次繫結,僅在初始化的時候把這些資料繫結,語法如下:
1 |
<div>{{::item}}</div> |
1 2 3 |
<ul> <li ng-repeat="item in ::items">{{item}}</li> </ul> |
這樣的資料就不會被持續觀測,也就有效減少了監控值的數目,提高了效能。
2. 降低資料比對的開銷
這一個環節是從資料變更檢測與繫結的方式入手。細節不說太多了,之前都說過。從資料到介面的更新,一般就兩種方式:推、拉。
所謂推,就是在set的時候,主動把與之相關的資料更新,大部分框架是這種方式,低版本瀏覽器用defineSetter之類。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function Employee() { this._firstName = ""; this._lastName = ""; this.fullName = ""; } Employee.prototype = { get firstName(){ return this._firstName; }, set firstName(val){ this._firstName = val; this.fullName = val + " " + this.lastName; }, get lastName(){ return this._lastName; }, set lastName(val){ this._lastName = val; this.fullName = this.lastName + " " + val; } }; |
所謂拉,就是set的時候只改變自己,關聯資料等到用的時候自己去取。比如:
1 2 3 4 5 6 7 8 9 10 |
function Employee() { this.firstName = ""; this.lastName = ""; } Employee.prototype = { get fullName() { return this.firstName + " " + this.lastName; } }; |
有些框架中,兩種方式都可以用。這時候可以自己考慮下適合用哪種方式,比如說,可能有些框架是合併變更,批量更新的,可能就用拉的方式效率高;有些框架是實時變動,差異更新的,那可能就是用推的效率高些。
上面的程式碼能看出來,從程式碼編寫的簡潔性來說,拉模式要比推模式簡單很多,如果能預知資料量較小,可以這樣用。
在實際開發過程中,這兩種方式是需要權衡的。我們舉的這個例子比較簡單,如果說某個屬性依賴於很多東西,例如,一個很大的購物列表,有個總價,它是由每個商品的單價乘以購買個數,再累加起來的。
在這種情況下,如果使用拉模式,也就是在總價的get上做這個變動,它需要遍歷整個陣列,重新作計算。但是如果使用推模式,每次有商品價格或者商品購買個數發生變更的時候,都只要在原先的總價上,減去兩次變動的差價即可。
此外,不同的框架用不同方式來檢測資料的變動,比如Angular,如果有一個陣列中的元素髮生變化了,它是怎樣知道這個陣列變了呢?
它需要保持變動之前的資料,然後作比對:
- 首先比對陣列的引用是否相等,這一步是為了檢測陣列的整體賦值,比如this.arr = [1, 2, 3]; 直接把原來的替換掉了,如果出現這種情況,就認為它肯定變化了。(其實,如果內容與原先相同,是可以認為沒有變的,但因為這些框架的內部實現,往往都需要更新資料與DOM元素的索引關係,所以不能這樣)
- 其次,比較陣列的長度,如果長度跟原先不相等了,那肯定也產生變化了
- 然後只能挨個去比對裡面元素的變化了
所以,會有人考慮在Angular中結合immutable這樣的東西,加速變更的判定過程,因為immutable的資料只要發生任何變化,其引用都一定會變,所以只要第一步判定引用就足以知道資料是否改變了。
有人說,你這個判定降低的開銷並不大啊,因為引入immutable要增加複製的開銷,跟這裡的新舊資料比對開銷相比,也低不到哪裡去。但這個地方要注意,Angular在有事件產生的時候,會把所有監控資料都重新比對,也就是說,如果你在介面上有個大陣列,你從未對它重新賦值,而是經常在另外一個很小的表單項繫結的資料上進行更新,這個陣列也是要被比對的,這就比較坑了,所以如果引入immutable,可以大幅降低平時這種不受影響時候的比對成本。
但是引入immutable也會對整個應用造成影響,需要在每個賦值取值的地方都使用immutable的封裝方式,而且還要在繫結的時候,對資料作解包,因為Angular繫結的資料是pojo。
所以,用這種方式還是要慎重,除非框架自身就構建在immutable的基礎上。或許,我們可以期望有一套與ng-model平行的機制,ng-immutable之類,實現的難度也還是挺大的。
在使用ES5的場景下,可以利用一些方法加速判斷,比如陣列的:
- filter
- map
- reduce
它們能夠返回一個全新的陣列,與原先的引用不等,所以在第一步判斷就可以得出結果,不必繼續後面幾步的比較。
不過,這個環節的優化其實很不明顯,最關鍵的優化在於與之配套的索引優化,參見下一節。
3. 提升索引的效能
在Angular中,可以通過ng-repeat來實現對陣列或者物件的遍歷,但這個遍歷的機制,其實有很多技巧。
在使用簡單型別陣列的時候,我們很可能會碰到這麼一個問題:陣列中存在相同的值,比如:
1 |
this.arr = [1, 3, 5, 3]; |
1 2 3 |
<ul> <li ng-repeat="num in arr">{{num}}</li> </ul> |
這時候會報錯,然後如果去搜尋一下,會發現一個解決方式:
1 2 3 |
<ul> <li ng-repeat="num in arr track by $index">{{num}}</li> </ul> |
為什麼這就能解決呢?
我們先思考一下,如果自己實現類似Angular這樣的功能,因為要在DOM和資料之間建立關聯,這樣,當改變資料的時候,才能重新整理到對應的介面,所以,必然有個對映關係。
對映關係需要唯一的索引,在剛才那個例子中,Angular預設對簡單型別使用自身當索引,當出現重複的時候,就會出錯了。如果指定$index,也就是元素在陣列中的下標為索引,就可以避免這個問題。
那麼,對於物件陣列,又是怎樣呢?
比如說這麼一個陣列,我們用不同的兩個方式來繫結:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function ListCtrl() { this.arr = []; for (var i=0; i10000; i++) { this.arr.push({ id: i, label: "Item " + i }); } var time = new Date(); $timeout(function() { alert(new Date() - time); console.log(this.arr[0]); }.bind(this), 0); } |
1 2 3 |
<ul ng-controller="ListCtrl as listCtrl"> <li ng-repeat="item in listCtrl.arr">{{item}}</li> </ul> |
1 2 3 |
<ul ng-controller="ListCtrl as listCtrl"> <li ng-repeat="item in listCtrl.arr track by item.id">{{item}}</li> </ul> |
看示例地址,多點選幾下:
我們驚奇地發現,這兩個時間有不小差別。
關注一下在繫結之後,arr裡面的資料,發現在沒有加track by $index的時候,原始資料被改變了,新增了一些索引資訊,這些索引是當資料產生變更時,Angular能夠找到關聯介面的重要線索。
1 |
Object {id: 0, label: "Item 0", $$hashKey: "object:4"} |
如果我們知道資料的唯一性由什麼保證,並且手動指定其為索引,可以減少不必要的新增索引的過程。
4. 降低資料的大小
看到這個標題,可能有人會感到奇怪。業務資料的大小並不是由程式設計師控制的,怎麼降低呢?這裡的降低,指的是降低那些被用於繫結到介面的資料大小。
資料的大小也會影響繫結效率,我們考慮一個螢幕能展示的資料有限,並不需要把所有東西都立即展示出來,可以從資料中擷取一段進行展示,比如大家都熟悉的資料分頁就是這麼一種方式。
很傳統的那種資料分頁,是會有一個分頁條,上面寫著總共多少資料,然後上一頁,下一頁,這樣切換。後來出現了一些變種,比如滾動載入,當滾動條滾到底部的時候,再去載入或生成新的介面。
如果說,我們有上萬條資料形成的一個列表,但是又不打算用那麼老圡的方式放個分頁條在下面,如何在效能與體驗中取得一個平衡呢?
接觸過Adobe Flex的人,可能會對其中的列表控制元件印象深刻,因為就算你給它上百萬資料,它也不會因此而慢下來,為什麼呢?因為它的滾動條是假的。
同理,我們也可能在瀏覽器中使用DOM來模擬一個滾動條,然後利用這個滾動條的位置,從全量資料中獲取對應的那一段資料,並且繫結渲染到介面上。
這種技術一般稱為Virtual List,在很多框架中都有第三方實現,可以參見這篇文章:AngularJS virtual list directive tutorial
上面這篇文章做到的,只是初步的優化,並不精細,因為它假定列表中所有項的大小是一致的,而且要在建立階段即已預知,這樣就很不靈活了。如果需要做更精細的優化,需要做實時的度量,對每個已建立並渲染的子項作度量,然後以此來更新滾動區的位置。
參見demo:http://codepen.io/xufei/pen/avRjqV
5. 將資料的結構扁平化
那麼,資料的結構又是怎樣影響到執行效率的呢?我舉一個常見的例子就是樹形結構,這個結構一般人會使用ul和li之類的結構做,然後不可避免地要用遞迴的方式來使用MVVM框架。
我們考慮一下,為什麼非要使用這種方式呢?其原因有二:
- 給定的資料結構就是樹形的
- 我們習慣於使用樹形DOM結構來表達樹形資料
這個樹形資料對我們來說,是什麼?是資料模型。但是我們知道,比對兩個樹形結構是很麻煩的,它的層級使得監控變得複雜,無論是資料的逐一比對,還是存取器、或者剛被取消的observe提案,都會比單層資料麻煩很多。
如果我們想要用一種更加扁平的DOM結構來展示它,而不是層級結構,怎麼辦呢?所謂的樹形DOM結構,能展現給我們的無非是位置的偏移,比如所有下級節點比上級更靠右,這些東西其實可以很輕易使用定位來模擬,這麼一來,就有可能適用平級DOM結構來表達樹的形狀了。
回憶一下,MVVM,這幾個字母什麼意思?
Model View ViewModel
我們看了前兩者了,但從未關注過檢視模型。在很多人眼裡,檢視模型只是模型的一個簡單封裝,其實那只是特例,Angular官方的demo形成了這種誤導。檢視模型的真正作用應當包括:把模型轉化為適合檢視展示的格式。
如果說我們需要在檢視層有比較扁平的資料結構,就必須在這一層把原始資料拍扁,舉個栗子,我們要做一個動態的組織架構圖,這個展開會像一個樹,內部肯定也會有樹形的資料結構,但我們可以同時維護樹形和扁平的兩種結構,並且隨時保持同步:
原始資料如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var source = [ {id: "0", name: "a"}, {id: "1", name: "b"}, {id: "013", name: "abd", parent: "01"}, {id: "2", name: "c"}, {id: "3", name: "d"}, {id: "00", name: "aa", parent: "0"}, {id: "01", name: "ab", parent: "0"}, {id: "02", name: "ac", parent: "0"}, {id: "010", name: "aba", parent: "01"}, {id: "011", name: "abb", parent: "01"}, {id: "012", name: "abc", parent: "01"} ]; |
轉換程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var map = {}; var dest = []; source.forEach(function(it) { map[it.id] = it; }); source.forEach(function(it) { if (!it.parent) { //根節點 dest.push(it); } else { //葉子節點 map[it.parent].children = map[it.parent].children || []; map[it.parent].children.push(it); } }); |
轉換之後的dest變成了這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
[ { "id": "0", "name": "a", "children": [ { "id": "00", "name": "aa", "parent": "0" }, { "id": "01", "name": "ab", "parent": "0", "children": [ { "id": "013", "name": "abd", "parent": "01" }, { "id": "010", "name": "aba", "parent": "01" }, { "id": "011", "name": "abb", "parent": "01" }, { "id": "012", "name": "abc", "parent": "01" } ] }, { "id": "02", "name": "ac", "parent": "0" } ] }, { "id": "1", "name": "b" }, { "id": "2", "name": "c" }, { "id": "3", "name": "d" } ] |
我們在介面繫結的時候仍然使用source,而在操作的時候使用dest。因為,繫結的時候,不必去經過深層檢測,而操作的時候,需要有父子關係來使得操作便利。
比如說,我們要做一個樹狀拓撲圖,或者是MindMap這類產品,如果不作這樣的考慮,很可能會直接把介面結構繫結到樹狀資料上,這時候效率相對會比較低些。
但我們也可以作這種優化:
- 同時儲存扁平化的原始資料,也生成樹狀資料
- 把展示結構繫結到扁平化的資料上
- 每當結構變更的時候,在樹狀資料上更新,並且在資料模型內部計算出介面座標
- 展示結構的扁平資料因為跟樹狀資料是相同引用,也被更新了,也就引發介面重新整理
- 這時候,介面是單層重新整理,無需跟蹤層級資料,效率可以提高不少,尤其在層次較深的時候
6. 小結
MVVM存在的意義就是儘可能提高開發效率,只有很極端情況下值得去優化效能。如果你的場景中出現非常多的效能問題,很可能是不適合用這類框架的業務形態。
總結一下我們的幾種優化方式,他們的機制分別是:
- 減少監控項
- 加快變更檢測速度
- 主動設定索引
- 縮小渲染的資料量
- 資料的扁平化
可以看到,我們所有的優化都是在資料層面,不必刻意去優化介面。如果你用了一個MVVM框架,卻為它作了各種各樣相當多的優化,那還不如不要用它,全手工寫。
針對其他MVVM框架,也大致可以用類似的幾種方式,只是部分細節有差異,可以觸類旁通。