[譯] 關於 Angular 的變化檢測,你需要知道的一切

如果你想跟我一樣對 Angular 的變化檢測機制有全面的瞭解,你就不得不去檢視原始碼,因為網上幾乎沒有這方面的文章。大部分文章只提到每個元件都有自己的變化檢測器,且重點在使用不可變變數(immutable)和變化檢測策略(change detection strategy)上,卻沒有進行更深入的探討。這篇文章會帶你一起了解 為什麼 不可變變數可以觸發變化檢測及變化監測策略 如何 影響檢測。另外,你可以將本文中學到的知識運用到各種需要提升效能的場景中。
本文包括兩部分。第一部分比較偏技術,會有很多原始碼的連結。主要講解變化檢測機制是如何運作的。本文的內容是基於(當時的)最新版本 —— Angular 4.0.1。該版本中的變化檢測機制和 2.4.1 的有一點不同。如果你有興趣,可以參考 ofollow,noindex">Stack Overflow 上的這個回答 。
第二部分展示瞭如何應用變化檢測。由於 2.4.1 和 4.0.1 的 API 沒有發生變化,所以這一部分對於兩個版本都適用。
核心概念:檢視(view)
Angular 的教程上一直在說,一個 Angular 應用是一顆元件樹。然而,在 Angular 內部使用的是一種叫做 檢視(view) 的低階抽象。檢視和元件之間是有直接聯絡的 —— 每個檢視都有與之關聯的元件,反之亦然。檢視通過 component
屬性將其與對應的元件類 關聯 起來。所有的操作都在檢視中執行,比如屬性檢查和更新 DOM。所以,從技術上來說,更正確的說法是:一個 Angular 應用是一顆檢視樹。元件可以描述為檢視的更高階的概念。關於檢視, 原始碼 中有這樣一段描述:
檢視是構成應用 UI 的基本元素。它是一組一起被創造和銷燬的最小合集。
檢視的屬性可以更改,而檢視中元素的結構(數量和順序)不能更改。想要改變元素的結構,只能通過用 ViewContainerRef
來插入、移動或者移除嵌入的檢視。每個檢視可以包含多個檢視容器(View Container)。
在這篇文章中,我會交替使用元件檢視和元件的概念。
值得一提的是,網上有關變化檢測文章和 StackOverflow 中的回答中,都把本文中的檢視稱為變化檢測器物件(Change Detector Object)或者 ChangeDetectorRef。實際上,變化檢測並沒有單獨的物件,它其實是在檢視上執行的。
每個檢視都通過 nodes
屬性 將其與子檢視相關聯,這樣就能對子檢視進行操作。
檢視的狀態
每個檢視都有一個 state
屬性 。這是一個非常重要的屬性,因為 Angular 會根絕這個屬性的值來確定是否要對此檢視和所有的子檢視執行變化檢測。 state
屬性有很多 可能的值 ,與本文相關的有以下幾種:
- FirstCheck
- ChecksEnabled
- Errored
- Destroyed
如果 CheckesEnabled
是 false
或者檢視的狀態是 Errored
或者 Destroyed
,變化檢測就會跳過此檢視和其所有子檢視。預設情況下,所有的檢視都以 ChecksEnabled
作為初始值,除非使用了 ChangeDetectionStrategy.OnPush
。後面會對此進行更多的解釋。檢視的可以同時有多個狀態,比如,可以同時是 FirstCheck
和 ChecksEnabled
。
Angular 中有很多高階概念來操作檢視。我在這篇文章中講過其中一些。其中一個概念是 ViewRef 。它封裝了 底層元件檢視 ,裡面還有一個命名很恰當的方法,叫做 detectChanges
。當非同步事件發生時,Angular 會在最頂層的 ViewRef 上 觸發變化檢測 。最頂層的 ViewRef 自己執行了變化檢測後,就會 對其子檢視進行變化檢測 。
你可以使用 ChangeDetectorRef
令牌來將 viewRef
注入到元件的建構函式中:
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
查詢列表 - 對子元件例項 呼叫
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
的文件中說,只有在它的繫結發生變化時,才會執行檢測。所以要設定 CheckesEnabled
位來啟用檢測。下面這段程式碼就是這個作用(第 2 步操作):
if (compView.def.flags & ViewFlags.OnPush) { compView.state |= ViewState.ChecksEnabled; } 複製程式碼
只有當父檢視的繫結發生了變化,且子元件檢視初始化為 ChangeDetectionStrategy.OnPush
時,才會更新狀態。
最後,當前檢視的變化檢測也負責啟動子檢視的變化檢測(第 8 步)。此處會檢查子元件檢視的狀態,如果是 ChecksEnabled
,那麼就對其執行變化檢測。這是相關的程式碼:
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 複製程式碼
總結
假設我們有如圖所示的元件樹

一顆元件樹
根據前面說的,每個元件都有一個檢視與之相關聯。每一個檢視都初始化為 ViewState.ChecksEnabled
,也就是說當 Angular 進行變化檢測時,這棵樹中的每一個元件都會被檢測。
假如我們想禁用 AComponent
和它的子元件的變化檢測,只需要將 ViewState.ChecksEnabled
設定為 false
。由於改變狀態是低階操作,所以 Angular 為我們提供了許多檢視的公共方法。每個元件都可以通過 ChangeDetectorRef
令牌來獲取與之相關聯的檢視。Angular 文件中對這個類定義瞭如下公共介面:
class ChangeDetectorRef { markForCheck() : void detach() : void reattach() : void detectChanges() : void checkNoChanges() : void } 複製程式碼
來看下我們可以如何使用這些介面。
detach
第一個允許我們操作狀態的是 detach
,它可以對當前檢視禁用檢查:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; } 複製程式碼
來看下如何在程式碼中使用:
export class AComponent { constructor(public cd: ChangeDetectorRef) { this.cd.detach(); } 複製程式碼
這保證了在接下來的變化檢測中,從 AComponent
開始,左子樹都會被跳過(橙色的元件都不會被檢測):

這裡需要注意兩點——首先,儘管我們改變的是 AComponent
的狀態,其所有子元件都不會被檢測。第二,由於整個左子樹的元件都不執行變化檢測,它們模板中的 DOM 也不會更新。下面的例子簡單描述了一下這種情況:
@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
。兩秒之後, changed
屬性變成了 true
, span
中的文字並不會更新。然而,如果去掉 this.cd.detach()
,就會按照預想的樣子更新了。
reattach
如第一部分所說,如果 AComponent
的輸入繫結 aProp
發生了變化, AComponent
的 Onchanges
宣告週期鉤子就會被觸發。這意味著一旦我們得知輸入屬性發生了變化,就可以對當前元件啟動變化檢測器來檢測變化,然後在下一個週期將其分離。這段程式碼就是這個作用:
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 需要更新,就丟擲異常。