Angular修改檢測(變更檢測)——它到底是如何工作的?

原文:https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/

Angular中的修改檢測機制比Angular 1更加透明合理。但是仍然存在很多場合(如進行性能優化的時候)需要我們真正理解這背後發生了什麼。所以讓我們從如下幾個話題來深入探究一下修改檢測機制:

  • 修改檢測機制是如何實現的?
  • Angular中的修改檢測器長什麼樣?我能看到它嗎?
  • 默認的修改檢測機制是怎樣的?
  • 開啓/關閉修改檢測機制,並手動觸發它;
  • 避免修改檢測循環:生產模式 vs 開發模式;
  • OnPush修改檢測模式究竟做了什麼?
  • 使用Immutable.js簡化Angular應用的構建;
  • 總結

如果您想了解更多關於OnPush修改檢測的內容,可以參看這篇文章:Angular OnPush修改檢測和組件設計——常見陷阱的避免

修改檢測機制是如何實現的?

Angular會檢測到組件數據的修改,並在之後自動重渲染受這些修改所影響的視圖。但是它是如何在按鈕點擊這種在頁面隨處可能發生的低級別事件後檢測到修改的呢?

要理解修改檢測是如何實現的,我們首先就得意識到Javascript被設計爲整個運行時都是可重寫的。只要你想,StringNumber中的方法都可以覆蓋掉。

覆蓋瀏覽器的默認機制

Angular會在啓動之初增強一些低級別瀏覽器API,例如addEventListener,這是一個用於註冊所有瀏覽器事件(包括點擊事件)處理器的瀏覽器函數。Angular會使用自己的版本替換掉addEventLiestener,就像這樣:

// 這是新版本的addEventListener
function addEventListener(eventName, callback) {
     // 調用真正的addEventListener
     callRealAddEventListener(eventName, function() {
        // 首先調用原本的回調函數
        callback(...);     
        // 之後調用Angular指定的功能
        var changed = angular2.runChangeDetection();
         if (changed) {
             angular2.reRenderUIPart();
         }
     });
}

新替換的addEventListener爲所有事件處理器增加了新的功能:除了調用註冊在上邊的回調函數外,還給了Angular執行修改檢測並更新UI的機會。

低級別運行時增強做了什麼?

對瀏覽器API的低級別增強是通過Angular引用的名爲Zone.js的庫實現的。弄明白“zone”是什麼是很重要的。

zone不過是一個包含了多個Javascript VM執行回合的執行上下文。這是一種我們可以用來給瀏覽器添加額外功能的通用機制。Angular在內部使用區域來觸發修改檢測。此外它也可以用來作應用剖析,或者保持運行於多個VM回合間的長堆棧追蹤。

瀏覽器異步API的支持

這些瀏覽器經常使用的機制會被增強,從而提供對修改檢測的支持:

  • 所有的瀏覽器事件(click、mouseover、keyup等);
  • setTimeout()setInterval()
  • Ajax請求

事實上,Zone.js還會增強其他一些瀏覽器API使之顯式觸發Angular修改檢測,例如Websocket。參考Zone.js的測試說明可以看到當前支持的所有API。

該機制的一個缺陷是如果由於某種原因某個異步瀏覽器API沒有被Zone.js支持,那麼修改檢測就不會觸發。例如,IndexedDB的回調。

我們已經知道了修改檢測是如何被觸發的,但觸發後它究竟做了什麼呢?

修改檢測樹

每個Angular組件都有相關聯的修改檢測器,該監測器是在應用啓動時創建的。例如,我們假設有一個TodoItem組件:

@Component({
    selector: 'todo-item',
    template: '<span class="todo noselect" (click)="onToggle()">{{todo.owner.firstname}} - {{todo.description}} - completed: {{todo.completed}}</span>'
})
export class TodoItem {
    @Input()
    todo:Todo;

    @Output()
    toggle = new EventEmitter<Object>();

    onToggle() {
        this.toggle.emit(this.todo);
    }
}

該組件將接收一個Todo對象作爲輸入,並會在其完成狀態屬性發生變化時發射事件。爲了讓這個實例更有趣,這個Todo包含一個嵌套對象:

export class Todo {
    constructor(public id: number, 
        public description: string, 
        public completed: boolean, 
        public owner: Owner) {
    }
}

可以看到,待辦事項有一個owner屬性,其本身擁有兩個屬性:姓和名。

待辦事項的修改檢測器長什麼樣?

我們可以真切地看到這個修改檢測器到底長什麼樣!我們只需要在Todo類中添加一些代碼,使之在某個屬性被訪問時觸發一個斷點

當斷點命中後,我們可以瀏覽堆棧追蹤並看到修改檢測的操作:
修改檢測代碼

不要擔心,你永遠不需要debug這些代碼!在這之中也沒有任何魔法,這不過是程序在啓動時構建出來的普通Javascript方法。但它做了什麼呢?

默認的修改檢測機制是怎麼工作的呢?

這個方法和這些直接命名的變量們在一開始看起來可能會十分陌生。但深入挖掘的話,我們就會注意到它做的事情非常簡單:它會比較在模板中每個表達式用到的屬性的現值和前值。

如果屬性值和之前不同,它就會將isChanged設爲true。我們基本上接近真相了!它會通過一個名爲looseNotIdentical()的方法進行值的比較,這其實就是一個對NaN場景擁有特殊邏輯的===比較方法(參考這裏)。

那麼對於嵌套的owner對象呢?

我們可以看到在修改檢測的代碼中也包含了對嵌套對象owner的修改檢測。但只有名字屬性參與了比較,姓氏屬性則沒有。

這是因爲在組件模板中並沒有使用到姓氏!同理,Todo類中的頂級屬性id也沒有進行比較。

基於這些,我們可以放心地說:

默認情況下,Angular修改檢測機制是通過檢查模板表達式中的值是否發生了變化來工作的。所有的組件中都會這麼做。

我們同樣也可以作出如下推斷:

默認情況下,Angular不會對對象進行深度比較,它只會比較模板中使用到的屬性。

爲什麼默認的修改檢測機制是這樣的?

更加透明且易用是Angular的重要目標之一,所以用戶不必對框架進行太深度的調試,也不必太過關注其內部原理。從而提高框架的開發效率。

如果您熟悉Angular 1,回想一下$digest()$apply()以及所有使用或不使用它們時的那些陷阱。Angular的主要目標之一就是避免它們。

爲什麼不比較引用呢?

現狀是Javascript的對象是可變的,並且Angular希望對此提供開箱即用的支持。

想象一下如果Angular默認的修改檢測機制是基於組件輸入的引用進行比較的話會怎樣呢?即使是像TODO這樣簡單的應用也會變得難以構建:開發者不得不十分小心地創建新的Todo對象,而不是簡單地修改屬性值。

但接下來我們就會看到,如果確實需要的話,我麼也可以自定義Angular的修改檢測機制。

性能如何?

注意待辦事項列表組件的修改檢測器是顯式引用todo屬性的。

還有一種實現方式是動態地在組件屬性間遍歷,這可以使代碼更具通用性,而不用每個組件的修改檢測代碼都是獨立的。使用這種方式的話我們不必在啓動時爲每個組件創建修改檢測器!何不使用這種方式呢?

虛擬機內部的速覽

所有的一切都是基於Javascript虛擬機來工作的。動態比較屬性,儘管編寫出來的代碼更加通用,但是輕易不能被Javascript VM的just-in-time編譯器優化。

和使用獨立代碼的修改檢測器不同,這種方式會明確地訪問組件的所有輸入屬性。而獨立代碼更接近我們手動編寫的代碼,並且更容易被虛擬機轉換爲本地代碼。

結論是,獨立代碼生成的顯式修改檢測機制非常快(比Angular 1快得多)、可預知且易於推導。

但是,如果我們還是遇到了性能問題,該如何對修改檢測進行優化呢?

OnPush修改檢測模式

如果待辦列表變得非常大,我們會設置讓TodoList組件僅在修改了待辦列表的引用值時更新自己。可以通過修改組件的修改檢測策略爲OnPush實現:

@Component({
    selector: 'todo-list',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
export class TodoList {
    ...
}

現在讓我們給應用添加一對按鈕:一個通過直接修改列表中的首個元素來更改完成狀態,另一個會在列表中添加一個待辦事項。代碼如下:

@Component({
    selector: 'app',
    template: `<div>
                    <todo-list [todos]="todos"></todo-list>
               </div>
               <button (click)="toggleFirst()">切換首個元素</button>
               <button (click)="addTodo()">添加待辦事項</button>`
})
export class App {
    todos:Array = initialData;

    constructor() {
    }

    toggleFirst() {
        this.todos[0].completed = ! this.todos[0].completed;
    }

    addTodo() {
        let newTodos = this.todos.slice(0);
        newTodos.push( new Todo(1, "TODO 4", 
            false, new Owner("John", "Doe")));
        this.todos = newTodos;
    }
}

我們來看看這兩個按鈕做了什麼:

  • 第一個按鈕“切換首個元素”不起作用!這是因爲toogleFirst()方法直接修改了列表中的某個元素。因爲輸入屬性todos引用本身沒有變化,所以TodoList無法檢測到這個修改。
  • 第二個按鈕起作用!注意addTodo()方法會創建一個待辦事項列表的拷貝,將新的事項添加在新的拷貝中,並在最後將成員變量待辦事項列表替換爲這個拷貝的列表。因爲組件檢測到了輸入屬性的引用發生了變化——變成了新的列表,所以修改檢測觸發了。
  • 在第二個按鈕中,如果直接修改當前的待辦事件列表的話,就不起作用了!必須創建一個新的列表。

OnPush真的僅僅是比較輸入引用嗎?

如果嘗試在某個待辦事項上點擊,你會發現它依然可以正確工作,這和我們剛剛的結論不符!即使你將TodoItem切換爲OnPush也一樣。這是因爲OnPush不僅僅會檢查組件的輸入,如果一個組件發射了事件,那麼修改檢測也會被觸發。

引用Victor Savkin在他的博客中的說法

使用OnPush檢測器的時候,框架會在其輸入屬性發生更改時、組件發射事件時或Observable發射事件時對這個OnPush組件進行檢查。

儘管可以帶來更好的性能,但是在使用可變對象時使用OnPush會帶來很高的複雜度成本。這可能會導致很難推導和復現的bug。但又一種辦法可以使OnPush可用。

使用Immutable.js簡化Angualr應用的構建

如果只使用不可變對象和不可變數組來構建應用,那麼我們就可以顯式地在任何地方使用OnPush而不必擔心跌倒在修改檢測bug的風險中。這是因爲在使用了不可變對象後,修改數據的唯一方式就是創建一個新的不可變對象並替代之前的對象。通過不可變對象,我們有了如下保障:

  • 新的不可變對象總是會觸發OnPush修改檢測;
  • 因爲修改數據的唯一方式就是創建新的對象,所以忘記創建對象的新拷貝時不用擔心偶然引發bug。

要想過渡到不可變模式,一個好的選擇是使用Immutable.js庫。這個庫爲構建應用提供瞭如不可變對象(Map)和不可變列表這樣的不可變的原始類型。

這個庫也可以類型安全地使用,參考之前的文章裏的實例。

避免修改檢測循環:生產模式 vs 開發模式

Angular修改檢測的重要特徵之一是它不像Angular 1,後者實現了一種雙向數據流,當控制器類中的數據更新時,會執行修改檢測並更新視圖。

儘管視圖的更新本身不會觸發進一步的修改,但之後的其他修改卻會觸發更多的視圖更新,所以Angular 1引入了消化循環。

如何在Angular中觸發修改檢測循環?

生命週期回調函數是觸發修改檢測循環的一種手段。例如在TodoList組件中我們可以調用其他組件的回調從而修改某個綁定:

ngAfterViewChecked() {
    if (this.callback && this.clicked) {
        console.log("正在修改狀態…");
        this.callback(Math.random());
    }
}

控制檯中會顯示一條錯誤消息:

EXCEPTION: Expression ‘{{message}} in App@3:20’ has changed after it was checked

只有在開發模式下運行Angular時纔會拋出這條錯誤信息。在生產模式下又會怎樣呢?

enableProdMode();

@NgModule({
    declarations: [App],
    imports: [BrowserModule],
    bootstrap: [App]
})
export class AppModule {}

platformBrowserDynamic().bootstrapModule(AppModule);

在生產模式下,異常不再拋出,該問題將無法檢測到。

修改檢測問題會頻繁發生嗎?

我們確實需要避免觸發修改檢測循環。以防萬一,只要我們總是在開發階段使用開發模式,就可以避免這個問題。

這種保證是以Angular總是兩次運行變化檢測爲代價的,第二次檢測的目的就是爲了避免此類場景。在生產模式下,修改檢測則只會執行一次。

開啓/關閉修改檢測,並手動觸發

有時候我們希望關閉修改檢測,比如這樣的場景:大量數據通過websocket服務端蜂擁而至,而我們則希望只要每5秒鐘觸發一次具體UI局部更新就好。爲了實現這種效果,我們先給組件注入修改檢測器:

constructor(private ref: ChangeDetectorRef) {
    ref.detach();
    setInterval(() => {
      this.ref.detectChanges();
    }, 5000);
  }

如你所見,我們分離了修改檢測器,這會導致修改檢測功能的關閉。之後我們通過每5秒鐘調用一次detectChanges()方法來手動觸發它。

總結

Angular默認的修改檢測機制同Angular 1很類似:它會在瀏覽器事件之前和之後比較模板表達式中的值從而確定是否存在修改。所有的組件都會執行。但是也有一些重要的不同點:

第一點是不存在修改檢測循環(在Angular 1中稱爲消化循環)。這使得僅通過查看模板和控制器就可以推斷出每個組件。

另一點不同之處,因爲修改檢測器的構建方式不同,組件的修改檢測機制比之前快得多。

最後,和Angular 1不同, 修改檢測機制是可以自定義的。

關於修改檢測我們真的要了解這麼多嗎?

對於95%的應用場景,可以信誓旦旦地說,Angular的修改檢測都能良好的工作,並且關於它我們並不需要了解得太多。但是弄明白修改檢測是如何工作的依然有用,有如下原因:

  • 首先它可以幫助我們弄明白一些在開發時可能會遇到的異常信息,如修改檢測循環;
  • 有助於我們閱讀異常堆棧追蹤,那些突然蹦出來的zone.afterTurnDone()看起來終於不那麼頭痛了;
  • 在性能短缺(不過你真的確定不在那些巨型數據表格上使用分頁機制嗎?)的場景下,理解修改檢測可以幫助我們進行性能優化。

通過下文給出的參考資料,可以獲取更多關於Angular修改檢測的知識。

如果您喜歡這篇文章,我們邀請您訂閱Angular大學

我們的YouTube頻道

訂閱我們的YouTube頻道可以獲取我們課程的免費早期預覽。下面是一個一小時示例課:

Free Angular for Beginners Cours

參考資料

Angular中的修改檢測 by Victor Savkin(@victorsavkin)

Zones Ng-Conf-2014演講 by Brain Ford (@briantford)

Ng-Nl Change Detection Explained 演講 by Pascal Precht (@PascalPrecht) - 暫無鏈接

其他關於Angular的博文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章