深刻 Angular 的 Change Detection 機制

在瞭解 AngularJS 的 Digest Cycle 機制後,對 Angular 的 Change Detection 機制也產生了好奇。看了許多相關的文章和視頻,有了些本身的理解,在此作個總結、記錄,並分享一些本身的想法。博文原地址javascript

什麼是 Change Detection ?

在應用的開發過程當中,state 表明須要顯示在應用上的數據。當 state 發生變化時,每每須要一種機制來檢測變化的 state 並隨之更新對應的界面。這個機制就叫作 Change Detection 機制。html

在 WEB 開發中,更新應用界面其實就是對 DOM 樹進行修改。因爲 DOM 操做是昂貴的,因此一個效率低下的 Change Detection 會讓應用的性能變得不好。所以,框架在實現 Change Detection 機制上的高效與否,很大程度上決定了其性能的好壞。前端

AngularJS 的 Digest Cycle 爲何老是被人詬病?

在談 Angular 的 Change Detection 機制以前,咱們先來看一下他的「前輩」(AngularJS)的 Digest Cycle 老是被人詬病的緣由。java

Digest Cycle 是 AngularJS Change Detection 機制的核心。AngularJS 會爲每個綁定在 HTML 上的變量建立一個 watcher,這種形式建立的 watcher 我稱其爲 bind_watcher 。另外,開發者也能夠經過 scope.watch( ) 方法手工的爲指定的變量建立一個 watcher,我稱其爲 manul_watcher 。同一個 state 會有多個 bind_watcher 但只會有一個 manul_watcher 。這兩種 watcher 都會被存放在 $scope.$$watchers 這個數組中。至於髒查詢(Dirty Checking)的實質其實就是遍歷某個 scope 及其全部 child_scope 下的 $$watchers,並校驗每個 watcher 是否髒(dirty)了。若髒了,調用對應的監聽器(listener),bind_watcher 中的 listener 會修改對應的 DOM 節點,而 manul_watcher 的 listener 則是開發者自定義的一個函數。因爲 listener 中可能會修改已經被檢測過的 state (即這個 state 對應的 watcher 已經被檢測過了),爲了儘量的保證在 Digest Cycle 後全部的 watcher 都是出於「乾淨」的狀態,AngularJS 就不得不進行屢次(缺省上限爲10次)的 Dirty Checking 。能夠結合下圖進行理解。node

上述即是 Digest Cycle 的基本原理。一個 AngularJS 應用頗有可能產生成百上千的 watcher ,這種須要進行屢次 Dirty Checking 的機制極其低效,因此 AngularJS 應用的性能老是被人詬病。git

題外話:網上常說 AngularJS 的髒查詢(Dirty Checking)怎麼怎麼很差,其實 Dirty Checking 自己並無什麼問題,問題在於 AngularJS 的 Change Detection 是在其特有的 watcher 機制的基礎上來實現的,再加上其混亂的數據流,纔不得不進行效率極低的 Digest Cycle 。這也是爲何 AngularJS 團隊明知 Digest Cycle 是如此的低效,卻又沒法進行完全重寫的緣由,由於不是簡單的重寫 Digest Cycle 自己就夠了,還須要從新考慮 watcher 機制、數據流等一些方面的問題,這也是 Angular 不基於 AngularJS 來重寫緣由之一。github

Angular 是如何實現 Change Detection 機制?

在瞭解完 AngularJS 的 Digest Cycle 以後,咱們根據如下幾個問題,逐步的瞭解 Angular 的 Change Detection 機制。web

Q1:Angular 是如何觸發 Change Detection 機制的?

在使用 AngularJS 開發應用時,若是咱們須要使用定時器,有兩種方法:編程

  • 使用瀏覽器原生的 setTimeout( ) 方法,但須要注意:若是在定時器中有修改 state ,那麼須要調用 $scope.$digest( ) 方法來確保修改後的 state 可以反應在 UI 界面上。bootstrap

  • 使用 AngularJS 內置的 $timeout 服務,使用它的好處是不須要手工的調用 $scope.$digest( ) 方法來刷新頁面。

其實這兩種方法本質都是同樣的,就是:AngularJS 須要手動調用 $scope.$digest( ) 方法來觸發框架的 Change Detection

而對於 Angular ,我先拋出結論:經過 Zone , Angular 可以實現自動的觸發 Change Detection 機制。接下來咱們就來看看 Angular 是如何實現的。

在全部的 web 應用中,有如下三種場景須要觸發 Change Detection:

  • Event - 瀏覽器的一系列原生事件
  • XHR - XMLHttpRequest
  • Timers - setTimeout( ) 、setInterval( )

它們共同的特色就是:都是異步。而 Zone 是什麼呢?簡而言之,Zone 是一個執行上下文(execution context),能夠理解爲一個執行環境。與常見的瀏覽器執行環境不一樣,在這個環節中執行的全部異步任務都被稱爲 Task ,Zone 爲這些 Task 提供了一堆的鉤子(hook),使得開發者能夠很輕鬆的「監控」環境中全部的異步任務。

在 Angular 中,框架會生成一個 zone ,大部分的代碼都在這個 zone 中執行,如此一來,Angualr 就能夠監控全部的異步任務。舉個例子:假如組件 A 中有一個 setTimeout( ) 方法,一旦其回調函數被執行,就會觸發 zone 的 onInvoke 鉤子,而後在這個鉤子中去觸發 Change Detection 機制。這也就實現了「自動」觸發 Change Detection 的效果。

注:ZoneJS 做爲一個獨立的庫,在其餘方面還有許多的用途,我後續會寫一篇單獨講 ZoneJS 的問題。

題外話:因爲 Angular 極力的推崇使用可觀察對象(Observable),若是徹底的基於 Observable 來開發應用,能夠代替 Zone 來實現追蹤調用棧的功能,且性能還比使用 Zone 會稍好一些。Angular 在 v5.0.0-beta.8 起能夠經過配置不使用 Zone ,配置以下:

import { platformBrowser } from '@angular/platform-browser'
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory, { ngZone: 'noop' })
複製代碼

Q2:Angular 中還須要進行屢次的髒查詢嗎?他是怎麼解決的?

組件化是如今前端發展的主要趨勢之一,每個頁面都是由一個個組件組成的,這些組件構成一顆組件樹。在常規狀況下,Angular 依舊使用的 Dirty Checking,也就是被你們說厭了的髒查詢。Angular 會從根組件開始,逐一檢測每個組件。與 AngularJS 複雜的數據流不一樣,Angular Change Detection 是基於組件樹的單向數據流來實現的。組件樹+單項數據流使得 Angular 在檢測每個組件時不須要考慮當前組件會修改父組件的數據而不得不像 AngularJS 那樣進行屢次的 Dirty Checking 。

結論1:Angular 的組件樹+單向數據流解決了 AngularJS 中須要屢次 Dirty Checking 的問題,即只需一次 Dirty Checking 就可以完成 Change Detection 。

值的一提的是,在開發模式下,Angular 會有意的進行兩次 Dirty Chacking(因此在開發的應用的時候感受應用的性能不是很好)。其目的是提示開發者:你開發的代碼違背了單向數據流的策略。舉個例子:當檢測某個組件時,會觸發組件的 DoCheck 鉤子,若是開發者在這個鉤子中經過某種方式(好比 Observable)修改了父組件的狀態,這種作法是不符合單向數據流的(由於父組件已經被檢測過),雖然在開發模式下對應的 UI 界面也會正常的被修改,可是 Angualr 會打印出錯誤,提示開發者這種行爲是不被容許的。具體能夠參考示例代碼:angular-unidirection-error-demo,能夠打開控制看看對應的錯誤。

Q3:Angular Change Detection 快在哪裏?

Victor Savkin(Angular 的核心開發成員之一)在 2015 的演講當中有提到以下的一個公式:

CHANGE DETECTION TIME = C * N

C - time to check a bind

N - number of binding

翻譯成中文就:完成一次 Change Detection 所須要花費的時間 =(約等於) 檢測一個組件變化所需的時間 * 綁定 state 的個數

爲了提升 Change Detection 的速度,咱們只需下降 C 或 N 便可。接下來咱們來看看 Angular 是如何下降 C 的,即如何減小檢測單個變化所需的時間的(後面我還會談及在某些狀況下,爲了更高的性能,能夠經過下降 N 來實現性能的提供。)。

無論是 AngularJS 仍是 Angular 的 Change Detection ,它們都須要經過檢測器(Detector)來檢測差別。在 AngularJS 中,其檢測器的僞碼相似以下:

// Detector1
while (scope) {
  var getter = watcher.get;
  var oldValue = op.last;
  var newValue = getter(scope);
  
  if (oldValue !== newValue) {
    op.last = newValue;
    var fn = watcher.listener;
    fn(oldValue, newValue);
  }
  scope = scope.next;
}
複製代碼

這段代碼很好理解,沒什麼問題。可是,Angular 以爲這樣的代碼顯然還不夠快,VM(虛擬機) 並不喜歡這樣「動態」的代碼。你可能會問了:VM 會喜歡什麼樣的代碼?我告訴你,VM 喜歡「單純」的代碼,好比:

// Detector2
// 組件A的html模板:<h1>{{data.title}}</h1>
// 組件A對應的 detector
class A_ChangeDetector {
  detectChanges() {
    var data = obj.data;
    var title = data.title;
    if (title !== this.last) {
      this.last = title;
      // 更新對應的 DOM 節點
      this.node.innerHTML = title;
    } 		  	
  }
}
複製代碼

對比上述的兩個 Detector 會發現:

Detector1 是一個通用的一個檢測器,而 Detector2 是一個針對組件A的檢測器。Detector2 的代碼更加的簡單,或者說是更加的「單純」。

這也就解釋了爲何 VM 會喜歡 Detector2 的實現了。因此能夠得出的結論是:

Detector2 運行的會比 Detector1 更快。

談到這裏,你可能會有一個和我最初同樣,產生一個問題:我認可 Detector2 比 Detector1 會稍微快一點,可是快的程度應該是微乎其微,就算採用 Detector2 的檢測方法,優化的程度應該也不怎麼明顯吧?

這就涉及到一個「數量級」的問題了。諾是單純的檢測某一個 state ,Detector2 比 Detector1 兩個檢測器的檢測速度肉眼根本沒法判斷它們之間的快慢。 可是隨着須要被檢測的 state 的數量不斷的增長,Detector2 細微的優化就會被不斷的放大,採用 Detector2 的應用的性能也就比採用 Detector1 的性能高出不少。

不錯,Angular 就是使用相似 Detector2 的檢測器。Angular 在 rumtime(若是是 AOP 編譯,則是在 Compile-time 的時候) 的時候會爲每個組件建立一個對應的 Change Detector ,因爲這些 Detector 就和上述的 Detector2 同樣是:VM friendly code(VM 更加喜歡的代碼) ,因此在某個數量級上檢測的性能忽悠很大的提高,後面我會寫一個 Demo 來直觀的感覺各個框架性能的差別。

至此,大體講完了 Angular Change Detection 常規的實現機制。接下來談談:Angular 還能夠更快?

Angular 還能夠更快?

在一些場景中,咱們會異常的關注應用的性能,好比移動端開發,好比大面積的畫面渲染。接下來,咱們來談談:Angular 其實還能夠更快

仍是前面講的那個公式:CHANGE DETECTION TIME = C * N 。若是須要提升 Change Detection 的效率,咱們還能夠減小 N ,即經過減小須要被檢測的 state 的數量來提升檢測效率。

在某些場景下,做爲開發者,咱們實際上是可以知道哪些組件是能夠不用檢測的。Angular 就提供了這種能力:告訴Angular 那些組件在什麼狀況下是不須要檢測的,其中就涉及到了一個概念:Immnutable Object 。

什麼是 Immutable Object ?

咱們日常開發當中所使用的對象基本都是 Mutable Object ,好處是可以很大程度的節省內存(由於都是在同一個對象上進行修改)。但也有如下兩個缺點:

  1. 遇到以下代碼時,很是的尷尬:
export function touchAndPrint(touchFn) {
  var data = { key: 'value' };
  touchFn(data);
  console.log(data.key); // ?
}
複製代碼

如上,若是 touchFn( ) 這個方法是別的同事寫的,我會根本知不道最後輸出 data.key 是什麼,由於 data 是一個 Mutable Object ,在 touchFn( ) 中可能會被修改。能夠看出 Mutable Object 有一種不可預測的性質,會對開發過程產生沒必要要的一些困擾,這也是爲何函數式編程變的愈來愈受歡迎的緣由。

  1. 比較兩個對象的值是否相等時,須要深度遍歷。

因爲是 Mutable Object 能夠在同一個對象上修改某個值,因此經過 === 比較符並不能判斷兩個對象中的每個值是否相等。因此咱們一般會對對象進行深度遍歷,逐一比較每個值。若是隻是一些簡單的對象那也還好,但若是頻繁的比較複雜的對象,可能就會影響應用的性能了。

回到咱們要講的 Immutable Object ,也就是「沒法被修改的對象」。Immutable Object 的優缺點和 Mutable Object 正好相反,缺點是若是每一次對對象的某個屬性賦值都產生一個新的對象,這顯然會佔據大量的內存。可是顯示有些第三方庫如:Immutable.js,它經過一些特殊的機制解決了這個問題。如今 Immutable Object 在許多框架中都有被使用到,特別是 React 。

Immutable Object 概念在 Angular Change Detection 中的使用

常規的 Change Detection 都是從跟組件開始進行髒查詢,以下圖:

做爲開發者,咱們有時是可以知道並不須要檢測全部的組件,咱們只想檢測部分組件,以下圖:

是的,Angular 提供了這種能力,其原理就是基於 Immutable Object 來實現的。

Angular 能夠經過如下代碼來告訴對應的檢測器:若是組件的 state 是一個對象,那麼你不須要檢測對象裏每個值得變化狀況(由於這個對象是 Immutable Object 類型),你只需簡單的判斷它和舊值是否絕對相等,即判斷:newObjec === oldObject 既可。若是沒有檢測到組件的變化,那就沒有必要檢測其子組件樹的變化了,由於開發者說了:若是我沒變,個人子組件是不會變的。

@component({
  selector: 'xxx',
  templateUrl: 'xxx',
  changeDetection: ChangeDetectionStrategy.OnPush
})
複製代碼

但願上述的描述你可以大體的理解設置 ChangeDetectionStrategy.OnPush 的做用。接着講講什麼時候可使用 ChangeDetectionStrategy.OnPush 。

每次 Change Detection 之因此是從根組件開始從上至下進行檢測,是由於任何一個組件均可以經過某個服務來間接的修改任意組件的 state(注意這和單向數據流並不矛盾,Angular 中的單向數據流指的是某一個具體的 state 只能從上往下流動,父組件能把 state 傳給子組件,而子組件只能夠將某個事件透傳給父組件,其並不能夠傳遞具體的 state 給父組件。)。

小結一下:Anuglar 提供了一種方式來優化 Change Detection 的性能表現,這種方式是基於 Immutable Object 並經過檢測某個組件是否 Change 來決定檢測器是否繼續檢測其全部子樹的方式來儘量的減小須要被檢測的組件數量,以達到提升檢測的效率的目的。

談談 ChangeDetectionRef 中各個方法的做用

在優化 Angular Change Detection 的過程當中,經常會使用到 ChangeDetectionRef 中提供的方法,下面我經過圖文並茂的方式逐一解釋其中的每個方法。

  • ChangeDetectionRef.markForCheck( )

該方法能夠理解爲:在執行這個方法後的第一次 Change Detection 中,忽視當前組件或是父組件中 ChangeDetectionStrategy.OnPush 對所在 Change Detection 的影響。

  • ChangeDetection.detectChanges( )

該方法會從當前組件開始觸發一次 Change Detection 。

  • ChangeDetection.checkNoChanges( )

該方法會從當前組件開始觸發一次 Change Detection ,若是有檢測某個組件 Change ,拋出異常並中止檢測。

  • ChangeDetectionRef.detach( ) 和 ChangeDetectionRef.reattach( )

開發者能夠經過這兩個方法手工的將所在組件與其子組件分離或是從新鏈接。

舉一例子,直觀的感覺優化的力量

爲了可以更加直觀的感覺對 Angular Change Detection 優化後的表現,我寫了一個項目,比較了 AngularJS Anuglar、React、Vue 四種框架在某一特定場景的性能,其中 Anuglar 有兩個版本,分別是 normal 版本和 faster 版本,另外兩個框架都是 normal 版本。而這個項目描述的場景是:加載一個日曆,在渲染日曆的過程須要不斷的向服務端(用 setTimeout 取代了 http 請求)獲取數據。項目地址:ChangeDetectionCompare

提早聲明:這裏的性能比較不是爲了說明 Angular 比另外兩個框架都話,關於這點,我後面會稍微談談本身我的的想法,這次比較只是單純的感覺對 Angular Change Detection 優化後的表現。

如下是各個版本在我電腦上完成渲染所花費的時間(都是在生產環境)。

這裏的數據都是執行三次,取速度最快的一次,數據僅供參考。

至於具體的優化原理,結合上述對 Angular Change Detection 的解釋,再看看項目的代碼,相信你可以看懂。

相關連接

相關文章
相關標籤/搜索