原文地址:Everything you need to know about change detection in Angular
如果你像我一樣,想對Angular
的變更檢測機制有一個深入的理解,由於在網上並沒有多少有用的資訊,你只能去看原始碼。大多數文章都會提到每一個元件都會有一個屬於自己的變更檢測器(change detector
),它負責檢查和這個元件,但是他們幾乎都僅限於在說怎麼使用immutable
資料和變更檢測策略,這篇文章將會讓你明白為什麼使用immutable
可以工作,並且髒檢查機制是如何影響檢查的過程的。還有,這篇文章將會引發你對效能優化方面的一些場景的思考。
這篇文章包含2部分,第一部相當的有技術含量,它包含了一些指向原始碼的連結,它詳細的介紹了髒檢查機制在Angular
的底層是怎麼執行的,所有內容是基·Angular的
最新 版本-4.0.1
(注:作者寫這篇文章的時候,Angular
的最新版本是4.0.1
), 髒檢查機制的實現在這個版本的實現和之前的2.4.1
版本是不一樣的,如果你對之前版本的實現感興趣的話,你可以在這個stackoverflow的答案上學習到一些東西。
第二部分介紹了變更檢測在應用程式中該怎麼使用,這部分內容既適用於之前的2.4.1
版本,也使用於最新的4.0.1
版本,因為這部分的API並沒有改變。
將檢視(view)作為一個核心概念
在Angular的教程中提到過,一個Angular應用程式就是一個元件樹,然而,Angular在底層用了一個低階的抽象,叫做 檢視(view)。一個檢視和一個元件之間有直接的關聯:一個檢視對應著一個元件,反之亦然。一個檢視通過一個叫component
的屬性,保持著對與其所關聯的那個元件類的例項的引用。所有的操作(比如屬性檢查,DOM更新等),都會表現在檢視上面,因此從技術上來講,更正確的說法是,Angular
是一個檢視樹,一個元件可以被看做是一個檢視的更高階的概念。下面是一些原始碼中的關於檢視的介紹.
一個檢視是一個應用程式UI的基本組成單位,它是能夠被一起建立和銷燬的最小的一個元素集合。
在一個檢視中,元素的屬性可以改變,但是它的結構(數量和順序)不會被改變,只有通過一個ViewContainerRef
來插入、移動或是刪除內嵌的檢視這些操作才可以改變元素的結構。每一個檢視可以包含多個檢視容器。
在本文中,我將交替使用元件檢視和元件的概念。
在這裡有一點需要注意的是,網上的所有文章和StackOverflow上的一些回答將變更檢測視為變更檢測器物件或者`ChangeDetectorRef`,指的就是我在這裡所說的檢視(view)。實際上,沒有一個單獨的物件來進行變更檢測,並且檢視才是變更檢測所執行的地方。
複製程式碼
每一個檢視通nodes屬性對它的子檢視有一個引用,因此,它可以在它的子檢視中執行一些操作。
檢視狀態(View state)
每一個檢視都有一個狀態,它扮演著非常重要的角色,因為根據這個狀態的值,Angular來決定是要對這個檢視以及它的子檢視進行變更檢測還是忽略掉。有許多可能的狀態,但是下面的這幾個是與本文相關的幾個。
- FirstCheck
- ChecksEnabled
- Errored
- Destroyed
如果ChecksEnabled
是false或者檢視是Errored
或者Destroyed
的狀態,變更檢測將會跳過這個檢視以及它的子檢視。預設的,所有的檢視都被初始化為ChecksEnabled
的狀態,除非你設定了ChangeDetectionStrategy.OnPush
。稍後將會詳細介紹。檢視的狀態也可以合併,例如,一個檢視既可以有FirstCheck
的狀態,也可以由ChecksEnabled
的狀態。
Angular
有許多高階的概念來操作檢視,我在這裡寫了一些,其中一個就是viewRef,它封裝了基本的元件檢視,還有一個指定的方法detectChanges,當一個非同步事件發生的時候,Angular
將會在它的頂級viewRef
觸發變更檢測,它會在對它自己進行變更檢測後對它的子檢視進行變更檢測。
你可以通過ChangeDetectorRef
標記將這個viewRef
注入到一個元件的constructor
中:
export class AppComponent {
constructor(cd: ChangeDetectorRef) { ... }
複製程式碼
可以看下這兩個類的定義
export declare abstract class ChangeDetectorRef {
abstract checkNoChanges(): void;
abstract detach(): void;
abstract detectChanges(): void;
abstract markForCheck(): void;
abstract reattach(): void;
}
export abstract class ViewRef extends ChangeDetectorRef {
...
}
複製程式碼
變更檢測操作
主邏輯負責對存在於checkAndUpdateView函式中的檢視進行變更檢測,它的大部分功能在子元件上執行,這個函式從主元件開始被每一個元件遞迴的呼叫,這就意味著隨著遞迴樹的展開,子元件在下一個呼叫中成為父元件。
當為特定檢視觸發此函式時,它按照指定的順序執行以下操作:
-
如果一個檢視是第一次被檢查,則將
ViewState.firstCheck
設定為true,如果是已經被檢查過了,則設定為false
. -
檢查並更新在子元件/指令例項上的輸入屬性。
-
更新子檢視變更檢測狀態(一部分是變更檢測策略的實現)。
-
對內嵌的檢視執行變更檢測(重複列出的這些步驟)。
-
如果繫結的值改變的話,在子元件中呼叫
OnChanges
生命週期鉤子。 -
呼叫子元件的
OnInit
和ngDoCheck
生命週期鉤子(OnInit只有在第一次檢查的時候才會被呼叫)。 -
在子檢視元件例項中更新
ContentChildren
queryList
。 -
在子元件例項中呼叫
AfterContentInit
和AfterContentChecked
生命週期鉤子(AfterContentInit
只有在第一次檢查的時候才會被呼叫)。 -
如果當前檢視元件例項上的屬性變化的話,更新DOM插值表示式。
-
對子檢視執行變更檢查(重複這個列表裡的步驟)。
-
更新當前檢視元件例項中的
ViewChildren
查詢列表。 -
在當前元件例項中呼叫
AfterViewInit
和AfterViewChecked
生命週期鉤子(AfterViewInit
只有在第一次檢查的時候才會被呼叫)。 -
禁用當前檢視的檢查(一部分是變更檢測策略的實現)。
基於上面的執行列表,有幾個需要強調的事情。
第一個事情就是onChanges
生命週期鉤子是發生在子元件中的,它在子檢視被檢查之前觸發的,並且即使這個子檢視沒有進行變更檢測它也會觸發。這是個很重要的資訊,本文的第二部分你將會看到我們怎麼利用這個資訊。
第二個事情就是當檢視被檢測的時候,它的DOM的更新是作為變更檢測機制的一部分的,也就是說如果一個元件沒有被檢查,即使這個元件的被用到模板上的屬性改變了,DOM也不會被更新。模板是在第一次檢查前就被渲染了,我所指的DOM更新實際上指的是插值表示式的更新,因此如果你有一個這樣的模板<span>some {{name}}</span>
,DOM元素span
將會在第一次檢查前就被渲染,而在檢查的時候,只有{{name}}
這部分才會被渲染。
另外一個有趣的發現是在變更檢測期間,一個子元件的檢視的狀態會被改變。我在前面提到過所有的元件檢視在初始化時預設都是ChecksEnabled
的的狀態,但是對於那些使用了OnPush
策略的元件來說,變更檢測將會在第一次檢查後被禁用。(上面操作列表中的第9步):
if (view.def.flags & ViewFlags.OnPush) {
view.state &= ~ViewState.ChecksEnabled;
}
複製程式碼
這意味著在後面的變更檢測在執行檢查時,這個元件及它的所有子元件將會被忽略掉。文件中說一個設定了OnPush
策略的元件只有在它繫結的輸入屬性改變的時候才會被檢查,因此必須通過設定ChecksEnabled
位來啟用檢查,這也是下面的程式碼所做的(步驟2):
if (compView.def.flags & ViewFlags.OnPush) {
compView.state |= ViewState.ChecksEnabled;
}
複製程式碼
只有當父級檢視繫結改變並且子元件檢視被初始化為ChangeDetectionStrategy.OnPush
策略時,狀態才會被更新。
最後,當前檢視的變更檢測負責開啟它的子檢視的變更檢測(步驟8)。這是檢查子元件檢視狀態的地方,如果ChecksEnabled
是true
,那麼執行變更檢測,下面是相關的程式碼:
viewState = view.state;
...
case ViewAction.CheckAndUpdate:
if ((viewState & ViewState.ChecksEnabled) &&
(viewState & (ViewState.Errored | ViewState.Destroyed)) === 0) {
checkAndUpdateView(view);
}
}
複製程式碼
現在你已經知道了檢視的狀態控制著是否要對這個檢視以及它的子元件執行變更檢測,所以問題是我們能控制這些狀態碼?答案是可以,這也是本文第二部分要講的內容。
有的宣告週期鉤子在DOM更新之前被呼叫(3,4,5),有的是在之後(9)。因此如果你有下面的元件層級關係:A -> B -> C
,下面就是宣告週期鉤子被呼叫和繫結更新的順序。
A: AfterContentInit
A: AfterContentChecked
A: Update bindings
B: AfterContentInit
B: AfterContentChecked
B: Update bindings
C: AfterContentInit
C: AfterContentChecked
C: Update bindings
C: AfterViewInit
C: AfterViewChecked
B: AfterViewInit
B: AfterViewChecked
A: AfterViewInit
A: AfterViewChecked
複製程式碼
探索含義(Exploring the implications)
我們假設有下面的一個元件樹:
正如我們上面所學到的,每一個元件都有一個與之相關聯的元件檢視,每一個檢視初始化時的ViewState.ChecksEnabled
都為true
,這就意味著當Angular執行變更檢測時,元件樹上的每一個元件杜輝被檢查。
假設我們想禁用掉AComponent
及它的子元件的變更檢測,我們只需要很簡單的把它的ViewState.ChecksEnabled
設定為false
就可以的。直接改變狀態是一個低階的操作,因此Angular為我們提供了一些在檢視上可用的公共方法。每一個元件都可以通過ChangeDetectorRef
來獲得與其關聯的檢視的引用,Angular文件中為這個類定義瞭如下的公共介面:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
複製程式碼
讓我們看看我們看以從中收穫點什麼吧。
deatch
第一個我們可以操作檢視的方法是deatch
,它僅僅是能夠禁用掉對當前檢視的檢查:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }
複製程式碼
讓我們看看怎麼在程式碼中使用它:
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
複製程式碼
它確保了在接下來的變更檢測中,以AComponent
為開始的左側部分將會被忽略掉(橘黃色的元件將不會被檢查):
在這裡有兩個地方需要注意--第一個就是就是我們改變了AComponent
的檢測狀態,所有它的子元件也不會被檢查。第二個就是由於左側的元件們北郵執行變更檢測,所有他們呢的模板檢視也不會被更新,下面是一個小例子來證明這一點:
@Component({
selector: 'a-comp',
template: `<span>See if I change: {{changed}}</span>`
})
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.changed = 'false';
setTimeout(() => {
this.cd.detach();
this.changed = 'true';
}, 2000);
}
複製程式碼
第一次(檢查)的時候,span
標籤將會被渲染成文字See if I change: false
. 當2秒後,changed
屬性變為true
的時候,span
標籤中的文字將不會改變,但當我們刪掉this.cd.detach()
的時候,一切都會如期執行。
reattach
像本文中第一部分中所說的那樣,如果繫結的輸入屬性aProp
在AppComponent
中改變了,AComponent
的OnChanges
生命週期鉤子仍舊會觸發。這就意味著一旦我們輸入屬性改變了,我們就可以啟用當前檢視的變更檢測器去執行變更檢測,然後在下個事件迴圈中再把它從deatch
(變更檢測樹中分離)掉,下面的程式碼片段證明了這一點:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.reattach();
setTimeout(() => {
this.cd.detach();
})
}
複製程式碼
其實,reattach
僅僅對ViewState.ChecksEnabled
進行了位操作:
reattach(): void { this._view.state |= ViewState.ChecksEnabled; }
複製程式碼
這跟我們把ChangeDetectionStrategy
設定為OnPush
幾乎是等價的:在第一次變更檢測執行完後就禁用掉,然後當父元件繫結的屬性改變時再啟用檢查,檢查完了之後再禁用掉。
注意只有在禁用分支的最頂層的元件的OnChanges
鉤子才會被觸發,而不是禁用分支的所有元件。
markForCheck
reattach
方法只能對當前的元件啟用檢查,但是如果當前的元件的父元件沒有啟用髒檢查的話,它將不起作用,這就意味著reattach
方法僅僅對禁用分支的頂層元件起作用。
我們需要一個方法來對所有的父元件一直到根元件都啟用髒檢查,這裡有一個markForCheck
的方法:
let currView: ViewData|null = view;
while (currView) {
if (currView.def.flags & ViewFlags.OnPush) {
currView.state |= ViewState.ChecksEnabled;
}
currView = currView.viewContainerParent || currView.parent;
}
複製程式碼
從上面的實現中可以看到,它僅僅是向上遍歷,對所有的父元件啟用檢查一直到根元件。
什麼時候它是有用的呢?就像是ngOnChanges
一樣,即使元件使用OnPush
策略,ngDoCheck
生命週期鉤子也會被觸發,同樣的,只有在禁用分支的最頂層的元件中才會被觸發,而不是禁用分支的所有元件。但是我們可以用這個鉤子來執行一些定製化的邏輯,使我們的元件可以在一個變更檢測週期中執行檢查。由於Angular
僅僅檢查物件的引用,我們可以實現一些物件屬性的髒檢查:
Component({
...,
changeDetection: ChangeDetectionStrategy.OnPush
})
MyComponent {
@Input() items;
prevLength;
constructor(cd: ChangeDetectorRef) {}
ngOnInit() {
this.prevLength = this.items.length;
}
ngDoCheck() {
if (this.items.length !== this.prevLength) {
this.cd.markForCheck();
this.prevLenght = this.items.length;
}
}
複製程式碼
detectChanges
有一種方法只在當前檢視和它的子檢視只執行一次變更檢測,那就是detectChanges方法, 這個方法在執行變更檢測時候不管當前元件的狀態是什麼,那就意味著當前的檢視可能會保持禁用檢查的狀態,在下一個常規的變更檢測進行時,它將不會被檢查,下面是一個例子:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.detectChanges();
}
複製程式碼
當輸入屬性改變的時候,即使變更檢測器還保持著分離的狀態,DOM也會更新。
checkNoChanges
變更檢測器上最後一個有用的方法是在執行當前的變更檢測時,確保沒有變化發生。基本上,它執行了本文第一部分那個步驟中的1,7,8的操作,並且當它發現一個繫結值變化了或是決定DOM應該要被更行的時候,將會丟擲一個異常。