Angular 1 深度解析:髒數據檢查與 angular 性能優化

TL;DR

  1. 髒檢查是一種模型到視圖的數據映射機制,由 $apply$digest 觸發。
  2. 髒檢查的範圍是整個頁面,不受區域或組件劃分影響
  3. 使用盡可能簡單的綁定表達式提高髒檢查執行速度
  4. 儘可能減小頁面上綁定表達式的個數(單次綁定和ng-if
  5. ng-repeat 添加 track by 讓 angular 複用已有元素

什麼是髒數據檢查(Dirty checking)

Angular 是一個 MVVM 前端框架,提供了雙向數據綁定。所謂雙向數據綁定(Two-way data binding)就是頁面元素變化會觸發 View-model 中對應數據改變,反過來 View-model 中數據變化也會引起所綁定的 UI 元素數據更新。操做數據就等同於操做 UI。html

看似簡單,其實水很深。UI 元素變化引起 Model 中數據變化這個經過綁定對應 DOM 事件(例如 inputchange)能夠簡單的實現;然而反過來就不是那麼容易。前端

好比有以下代碼:程序員

<p ng-bind="content1"></p>
<p ng-bind="content2"></p>
<button ng-click="onClick()">Click Me</button>

用戶點擊了 button,angular 執行了一個叫 onClick 的方法。這個 onClick 的方法體對於 angular 來講是黑盒,它到底作了什麼不知道。可能改了 $scope.content1 的值,可能改了 $scope.content2 的值,也可能兩個值都改了,也可能都沒改。angularjs

那麼 angular 到底應該怎樣得知 onClick() 這段代碼後是否應該刷新 UI,應該更新哪一個 DOM 元素?算法

angular 必須去挨個檢查這些元素對應綁定表達式的值是否有被改變。這就是髒數據檢查的由來(髒數據檢查如下簡稱髒檢查)。數據庫

髒檢查如何被觸發

angular 會在可能觸發 UI 變動的時候進行髒檢查:這句話並不許確。實際上,髒檢查是 $digest](https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$digest) 執行的,另外一個更經常使用的用於觸發髒檢查的函數 [$apply 其實就是 $digest 的一個簡單封裝(還作了一些抓異常的工做)。express

一般寫代碼時咱們無需主動調用 $apply$digest 是由於 angular 在外部對咱們的回調函數作了包裝。例如經常使用的 ng-click,這是一個指令(Directive),內部實現則 相似json

DOM.addEventListener('click', function ($scope) {
  $scope.$apply(() => userCode());
});

能夠看到:ng-click 幫咱們作了 $apply 這個操做。相似的不僅是這些事件回調函數,還有 $http$timeout 等。我聽不少人抱怨說 angular 這個庫太大了什麼都管,其實你能夠不用它自帶的這些服務(Service),只要你記得手工調用 $scope.$apply後端

髒檢查的範圍

前面說到:angular 會對全部綁定到 UI 上的表達式作髒檢查。其實,在 angular 實現內部,全部綁定表達式都被轉換爲 $scope.$watch()。每一個 $watch 記錄了上一次表達式的值。有 ng-bind="a" 即有 $scope.$watch('a', callback),而 $scope.$watch 可不會管被 watch 的表達式是否跟觸發髒檢查的事件有關。api

例如:

<div ng-show="false">
  <span id="span1" ng-bind="content"></span>
</div>
<span id="span2" ng-bind="content"></span>
<button ng-click="">TEST</button>

問:點擊 TEST 這個按鈕時會觸發髒檢查嗎?觸發幾回?

首先:ng-click="" 什麼都沒有作。angular 會由於這個事件回調函數什麼都沒作就不進行髒檢查嗎?不會。

而後:#span1 被隱藏掉了,會檢查綁定在它上面的表達式嗎?儘管用戶看不到,可是 $scope.$watch('content', callback) 還在。就算你直接把這個 span 元素幹掉,只要 watch 表達式還在,要檢查的還會檢查。

再次:重複的表達式會重複檢查嗎?會。

最後:別忘了 ng-show="false"。多是由於 angular 的開發人員認爲這種綁定常量的狀況並很少見,因此 $watch 並無識別所監視的表達式是不是常量。常量依舊會重複檢查。

因此:

答:觸發三次。一次 false,一次 content,一次 content

因此說一個綁定表達式只要放在當前 DOM 樹裏就會被監視,無論它是否可見,無論它是否被放在另外一個 Tab 裏,更無論它是否與用戶操做相關。

另外,就算在不一樣 Controller 裏構造的 $scope 也會互相影響,別忘了 angular 還有全局的 $rootScope,你還能夠 $scope.$emit。angular 沒法保證你絕對不會在一個 controller 裏更改另外一個 controller 生成的 scope,包括 自定義指令(Directive)生成的 scopeAngular 1.5 裏新引入的組件(Component)。

因此說不要懷疑用戶在輸入表單時 angular 會不會監聽頁面左邊導航欄的變化。

髒檢查與運行效率

髒檢查慢嗎?

說實話髒檢查效率是不高,可是也談不上有多慢。簡單的數字或字符串比較能有多慢呢?十幾個表達式的髒檢查能夠直接忽略不計;上百個也能夠接受;成百上千個就有很大問題了。綁定大量表達式時請注意所綁定的表達式效率。建議注意一下幾點:

  1. 表達式(以及表達式所調用的函數)中少寫太過複雜的邏輯
  2. 不要鏈接太長的 filter(每每 filter 裏都會遍歷而且生成新數組)
  3. 不要訪問 DOM 元素。

使用單次綁定減小綁定表達式數量

單次綁定(One-time binding 是 Angular 1.3 就引入的一種特殊的表達式,它以 :: 開頭,當髒檢查發現這種表達式的值不爲 undefined 時就認爲此表達式已經穩定,並取消對此表達式的監視。這是一種行之有效的減小綁定表達式數量的方法,與 ng-repeat 連用效果更佳(下文會提到),但過分使用也容易引起 bug。

善用 ng-if 減小綁定表達式的數量

若是你認爲 ng-if 就是另外一種用於隱藏、顯示 DOM 元素的方法你就大錯特錯了。

ng-if 不只能夠減小 DOM 樹中元素的數量(而非像 ng-hide 那樣僅僅只是加個 display: none),每個 ng-if 擁有本身的 scopeng-if 下面的 $watch 表達式都是註冊在 ng-if 本身 scope 中。當 ng-if 變爲 falseng-if 下的 scope 被銷燬,註冊在這個 scope 裏的綁定表達式也就隨之銷燬了。

考慮這種 Tab 選項卡實現:

<ul>
  <li ng-class="{ selected: selectedTab === 1 }">Tab 1 title</li>
  <li ng-class="{ selected: selectedTab === 1 }">Tab 2 title</li>
  <li ng-class="{ selected: selectedTab === 1 }">Tab 3 title</li>
  <li ng-class="{ selected: selectedTab === 1 }">Tab 4 title</li>
</ul>
<div ng-show="selectedTab === 1">[[Tab 1 body...]]</div>
<div ng-show="selectedTab === 2">[[Tab 2 body...]]</div>
<div ng-show="selectedTab === 3">[[Tab 3 body...]]</div>
<div ng-show="selectedTab === 4">[[Tab 4 body...]]</div>

對於這種會反覆隱藏、顯示的元素,一般人們第一反應都是使用 ng-showng-hide 簡單的用 display: none 把元素設置爲不可見。

然而入上文所說,肉眼不可見不表明不會跑髒檢查。若是將 ng-show 替換爲 ng-ifng-switch-when

<div ng-if="selectedTab === 1">[[Tab 1 body...]]</div>
<div ng-if="selectedTab === 2">[[Tab 2 body...]]</div>
<div ng-if="selectedTab === 3">[[Tab 3 body...]]</div>
<div ng-if="selectedTab === 4">[[Tab 4 body...]]</div>

有以下優勢:

  1. 首先 DOM 樹中的元素個數顯著減小至四分之一,下降內存佔用
  2. 其次 $watch 表達式也減小至四分之一,提高髒檢查循環的速度
  3. 若是這個 tab 下面有 controller(例如每一個 tab 都被封裝爲一個組件),那麼僅當這個 tab 被選中時該 controller 纔會執行,能夠減小各頁面的互相干擾
  4. 若是 controller 中調用接口獲取數據,那麼僅當對應 tab 被選中時纔會加載,避免網絡擁擠

固然也有缺點:

  1. DOM 重建自己費時間
  2. 若是 tab 下有 controller,那麼每次該 tab 被選中時 controller 都會被執行
  3. 若是在 controller 裏面調接口獲取數據,那麼每次該 tab 被選中時都會從新加載

各位讀者本身取捨。

當髒檢查趕上數組

ng-repeat!這就更有(e)趣(xin)了。一般的綁定只是去監聽一個值的變化(綁定對象也是綁定到對象裏的某個成員),而 ng-repeat 卻要監視一整個數組對象的變化。例若有:

<ul ng-init="array = [
  { value: 1 },
  { value: 2 },
  { value: 3 },
  { value: 4 },
]">
  <li ng-repeat="item in array" ng-bind="item.value"></li>
</ul>

會生成 4 個 li 元素

  • 1
  • 2
  • 3
  • 4

沒有問題。若是我添加一個按鈕以下:

<button ng-click="array.shift()">刪除第一個元素</button>

請考慮:當用戶點擊這個按鈕會發生什麼?

咱們一步一步分析。開始的時候,angular 記錄了 array 的初始狀態爲:

[
  { "value": 1 },
  { "value": 2 },
  { "value": 3 },
  { "value": 4 }
]

當用戶點擊按鈕後,數組的第一個元素被刪除了,array 變爲:

[
  { "value": 2 },
  { "value": 3 },
  { "value": 4 }
]

二者比較:

  1. array.length = 4 => array.length = 3
  2. array[0].value = 1 => array[0].value = 2
  3. array[1].value = 2 => array[1].value = 3
  4. array[2].value = 3 => array[2].value = 4
  5. array[3].value = 4 => array[3].value = undefinedarray[4]undefined,則 undefined.value 爲 undefined,見 Angular 表達式的說明

如同你所見:angular 通過比較,看到的是:

  1. 數組長度減小了 1
  2. 數組第 1 個元素的 value 被改成 2
  3. 數組第 2 個元素的 value 被改成 3
  4. 數組第 3 個元素的 value 被改成 4

反應到 DOM 元素上就是:

  1. 第 1 個 li 內容改成 2
  2. 第 2 個 li 內容改成 3
  3. 第 3 個 li 內容改成 4
  4. 第 4 個 li 刪掉

能夠看到,刪除一個元素致使了整個 ul 序列的刷新。要知道 DOM 操做要比 JS 變量操做要慢得多,相似這樣的無用操做最好能想辦法避免。

那麼問題出在哪裏呢?用戶刪除了數組的第一個元素,致使了整個數組元素前移;然而 angular 無法得知用戶作了這樣一個刪除操做,只能傻傻的按下標一個一個比。

那麼只要引入一種機制來標記數組的每一項就行了吧。因而 angular 引入了 track by

詳解 track by

用來標記數組元素的必定是數組裏相似 ID 的某個值。這個值必定要符合如下這兩個特色。

  1. 不能重複。ID 重複了什麼鬼
  2. 值必定要簡單。ID 是用於比較相等的,有時候因爲算法不一樣可能還要比較大小,處於速度考慮不能太複雜。

基於這兩個特色。若是用戶沒有給 ng-repeat 指定 track by 的表達式,則默認爲內置函數 $id$id 會檢查 item 中有沒有一個名爲 $$hashKey` 的成員。若有,返回其值;如沒有,則生成一個新的惟一值寫入。這就是數組中那個奇怪的 `$$hashKey 成員來歷,默認值是 "object:X"(你問我爲何是個字符串而不是數字?我怎麼知道。。。)

仍是前面的問題,引入 track by 後再來看。由於沒有指定 track by,則默認爲 $id(item),實際爲 $$hashKey

<ul ng-init="array = [
  { value: 1 },
  { value: 2 },
  { value: 3 },
  { value: 4 },
]">
  <li ng-repeat="item in array track by $id(item)" ng-bind="item.value"></li>
</ul>

開始的時候,$id(item) 給數組中全部項建立了 $$hashKey

這時 angular 記錄了 array 的初始狀態爲:

[
  { "value": 1, "$$hashKey": "object:1" },
  { "value": 2, "$$hashKey": "object:2" },
  { "value": 3, "$$hashKey": "object:3" },
  { "value": 4, "$$hashKey": "object:4" }
]

當用戶點擊按鈕後,數組的第一個元素被刪除了,array 變爲:

[
  { "value": 2, "$$hashKey": "object:2" },
  { "value": 3, "$$hashKey": "object:3" },
  { "value": 4, "$$hashKey": "object:4" }
]

先比較 track by 的元素,這裏爲 $id(item),即 $$hashKey

  1. "object:1" => "object:2"
  2. "object:2" => "object:3"
  3. "object:3" => "object:4"
  4. "object:4" => undefined

二者對不上,說明數組被作了增刪元素或者移動元素的操做。將其規整

  1. "object:1" => undefined
  2. "object:2" => "object:2"
  3. "object:3" => "object:3"
  4. "object:4" => "object:4"

那麼顯然,第一個元素被刪除了。再比較剩餘的元素

  1. array[0].value = 2 => array[0].value = 2
  2. array[1].value = 3 => array[1].value = 3
  3. array[2].value = 4 => array[2].value = 4

結論是:

  1. 原數組第一個元素被刪除
  2. 其餘沒變

angular 經過將新舊數組的 track by 元素作 diff 猜想用戶的行爲,最大可能的減小 DOM 樹的操做,這就是 track by 的用處。

默認 track by 的坑

So far so good! 然而需求某天有變,程序員小哥決定用 filter 給數組作 map 後再渲染。

<ul ng-init="array = [
  { value: 1 },
  { value: 2 },
  { value: 3 },
  { value: 4 },
]">
  <li ng-repeat="item in array | myMap" ng-bind="item.value"></li>
</ul>

map 定義以下:

xxModule.filter('map', function () {
  return arr => arr.map(item => ({ value: item.value + 1 }));
});

ng-repeat 執行時先計算表達式 array | myMap 的值:

arrayForNgRepeat = [
  { value: 2 },
  { value: 3 },
  { value: 4 },
  { value: 5 },
]

注意數組 arrayForNgRepeat 和原來的數組 array 不是同一個引用,由於 filter 裏的 map 操做生成了一個新數組,每一項都是新對象,跟原數組無關。

ng-repeat 時,angular 發現用戶沒有指定 track by,按照默認邏輯,使用 $id(item) 做爲 track by,添加 $$hashKey

arrayForNgRepeat = [
  { value: 2, "$$hashKey": "object:1" },
  { value: 3, "$$hashKey": "object:2" },
  { value: 4, "$$hashKey": "object:3" },
  { value: 5, "$$hashKey": "object:4" },
]

生成 DOM:

  • 2
  • 3
  • 4
  • 5

這裏請再次注意:數組 arrayForNgRepeat 與原始數組 array 沒有任何關係,數組自己是不一樣的引用,數組裏的每一項也是不一樣引用。修改新數組的成員不會影響到原來的數組。

這時 array 的值:

array = [
  { value: 1 },
  { value: 2 },
  { value: 3 },
  { value: 4 },
]

這時用戶的某個無關操做觸發了髒檢查。針對 ng-repeat 表達式,首先計算 array | myMap 的值:

newArrayForNgRepeat = [
  { value: 2 },
  { value: 3 },
  { value: 4 },
  { value: 5 },
]

先比較 track by 的元素。用戶沒有指定,默認爲 $id(item)

$id 發現數組中有一些元素沒有 $$hashKey`,則給它們填充新 `$$hashKey,結果爲

newArrayForNgRepeat = [
  { value: 2, "$$hashKey": "object:5" },
  { value: 3, "$$hashKey": "object:6" },
  { value: 4, "$$hashKey": "object:7" },
  { value: 5, "$$hashKey": "object:8" },
]

這時兩邊的 track by 的實際結果爲

  1. "object:1" => "object:5"
  2. "object:2" => "object:6"
  3. "object:3" => "object:7"
  4. "object:4" => "object:8"

二者對不上,說明數組被作了增刪元素或者移動元素的操做。將其規整

  1. "object:1" => undefined
  2. "object:2" => undefined
  3. "object:3" => undefined
  4. "object:4" => undefined
  5. undefined => "object:5"
  6. undefined => "object:6"
  7. undefined => "object:7"
  8. undefined => "object:8"

結論是:

  1. 原數組所有 4 個元素被刪除
  2. 新添加了 4 個元素

因而 angular 把原來全部 li 刪除,再建立 4 個新的 li 元素,填充它們的 textContent,放到 ul

若是懷疑我說的話,請本身在瀏覽器裏測試。你能夠清楚的看到調試工具裏 DOM 樹的閃爍

track by 與性能

不恰當的 ng-repeat 會形成 DOM 樹反覆從新構造,拖慢瀏覽器響應速度,形成頁面閃爍。除了上面這種比較極端的狀況,若是一個列表頻繁拉取 Server 端數據自刷新的話也必定要手工添加 track by,由於接口給前端的數據是不可能包含 $$hashKey 這種東西的,因而結果就形成列表頻繁的重建。

其實沒必要考慮那麼多,總之加上沒壞處,至少能夠避免 angular 生成 $$hashKey 這種奇奇怪怪的東西。因此

請給 ng-repeat 手工添加 track by

重要的事情再說一遍

請給 ng-repeat 手工添加 track by

一般列表都是請求接口從數據庫中讀取返回的。一般數據庫中的記錄都有一個 id 字段作主鍵,那麼這時使用 id 做爲 track by 的字段是最佳選擇。若是沒有,能夠選擇一些業務字段可是確保不會重複的。例如一個連表頭都是動態生成的表格,表頭就可使用其字段名做爲 track by 的字段(對象的 key 是不會重複的)。

若是真的找不到用於 track by 的字段,讓 angular 自動生成 $$hashKey 也不是不能夠,可是切記檢查有沒有出現 DOM 元素不斷重刷的現象,除了仔細看調試工具的 DOM 樹是否閃爍以外,給列表中的元素添加一個特別的標記(好比 style="background: red"),也是一個行之有效的方法(若是這個標記被意外清除,說明原來的 DOM 元素被刪除了)。

除非真的沒辦法,不推薦使用 $index 做爲 track by 的字段。

track by單次綁定 連用

track by 只是讓 angular 複用已有 DOM 元素。數組每一個子元素內部綁定表達式的髒檢查仍是免不了的。然而對於實際應用場景,每每是數組總體改變(例如分頁),數組每一項一般卻不會單獨變化。這時就能夠經過使用單次綁定大量減小 $watch 表達式的數量。例如

<li ng-repeat="item in array track by item.id">
  <div>a: <span ng-bind="::item.a"></span></div>
  <div>b: <span ng-bind="::item.b"></span></div>
  <div>c: <span ng-bind="::item.c"></span></div>
  <div>d: <span ng-bind="::item.d"></span></div>
  <div>e: <span ng-bind="::item.e"></span></div>
</li>

除非 track by 字段改變形成的 DOM 樹重建,item.a 等一旦顯示在頁面上後就不會再被監視。

若是每行有 5 個綁定表達式,每頁顯示 20 條記錄,經過這種方法每頁就能夠減小 5 * 20 = 100 個綁定表達式的監視。

注意:若是在 ng-repeat 內部使用的單次綁定,就必定不要用 track by $index。不然用戶切換下一頁頁面也不會更新。

使用分頁減小綁定個數

這個就很少說了。能後端分頁的就後端分頁;接口不支持分頁的也要前端分頁;前端分頁時能夠簡單的寫個 filterArray.prototype.slice 實現。

能直接減小數組中項的個數就不要在 ng-repeat 中每項上寫 ng-showng-if

寫在最後的話

髒檢查這個東西,其實在三大主流前端框架中或多或少都有涉及。React 每次生成新的 Virtual DOM,與舊 Virtual DOM 的 diff 操做原本就能夠看作一次髒檢查。Vue 從相對完全的拋棄了髒檢查機制,使用 Property 主動觸發 UI 更新,可是 Vue 仍然不能拋棄 track by 這個東西。

既然髒檢查在三大主流框架裏或多或少都有所保留,爲何惟獨 Angular 的性能被廣爲詬病呢?其實仍是說在 Angular 1 的機制下,髒檢查的執行範圍過大以及頻率太過頻繁了。Angular 1.5 從 Angular 2+ 引入了組件(Component)的概念,然而形似而神非,其實只是一個特殊的 Directive 馬甲而已,並不能將髒檢查的執行範圍限制在各個組件以內,因此並不能本質的改變 Angular 1 髒檢查機制效率低下的現狀。

也許 Angular 1 終將被淘汰。但 Angular 做爲前端第一個 MVVM 框架,着實引起了前端框架更新換代的熱潮。百足之蟲死而不僵,無論怎麼樣我還得繼續維護停留在電腦裏的 Angular 1 項目。不過也許老闆哪天大發慈悲給咱們用 Vue 重構整個項目的時間,未來的事情誰知道呢?

相關文章
相關標籤/搜索