探究內部實現和具體用例


如果你想跟我一樣對 Angular 的變化檢測機制有全面的瞭解,你就不得不去檢視原始碼,因為網上幾乎沒有這方面的文章。大部分文章只提到每個元件都有自己的變化檢測器,且重點在使用不可變變數(immutable)和變化檢測策略(change detection strategy)上,卻沒有進行更深入的探討。這篇文章會帶你一起了解為什麼不可變變數可以觸發變化檢測及變化監測策略如何 影響檢測。另外,你可以將本文中學到的知識運用到各種需要提升效能的場景中。

本文包括兩部分。第一部分比較偏技術,會有很多原始碼的連結。主要講解變化檢測機制是如何運作的。本文的內容是基於(當時的)最新版本 —— Angular 4.0.1。該版本中的變化檢測機制和 2.4.1 的有一點不同。如果你有興趣,可以參考 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 屬性有很多可能的值,與本文相關的有以下幾種:

  1. FirstCheck
  2. ChecksEnabled
  3. Errored
  4. Destroyed

如果 CheckesEnabledfalse 或者檢視的狀態是 Errored 或者 Destroyed,變化檢測就會跳過此檢視和其所有子檢視。預設情況下,所有的檢視都以 ChecksEnabled 作為初始值,除非使用了 ChangeDetectionStrategy.OnPush。後面會對此進行更多的解釋。檢視的可以同時有多個狀態,比如,可以同時是 FirstCheckChecksEnabled

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 方法中。此方法主要是對元件檢視執行操作。而且會對從宿主元件開始的所有元件遞迴地呼叫此方法。也就是說,在下次遞迴中,子元件就變成了父元件。

當為某個檢視觸發這個方法時,會按照以下順序執行操作:

  1. 如果檢視是第一次被檢測,將 ViewState.firstCheck 設定為 true,如果之前已經檢測過了,設定為 false
  2. 檢查並更新子元件或子指令例項的輸入屬性
  3. 更新子檢視的變化檢測狀態(這也是變化檢測策略的一部分)
  4. 對嵌入的檢視執行變化檢測(重複此列表中的步驟)
  5. 如果繫結發生了改變,對子元件呼叫 OnChanges 生命週期鉤子
  6. 對子元件呼叫 OnInitngDoCheckOnInit 只會在第一次檢測時呼叫)
  7. 更新子檢視元件例項的 ContentChildren 查詢列表
  8. 對子元件例項呼叫 AfterContentInitAfterContentChecked 生命週期鉤子(AfterContentInit 只會在第一次檢測時呼叫)
  9. 如果當前檢視元件例項的屬性發生改變,更新當前檢視的 DOM 插值
  10. 對子檢視執行變化檢測(重複此列表中的步驟)
  11. 更新當前試圖元件例項的 ViewChildren 查詢列表
  12. 對子元件例項呼叫 AfterViewInitAfterViewChecked 生命週期鉤子(AfterViewInit 只在第一次檢測時呼叫)
  13. 取消對當前檢視的檢查(這也是變化檢測策略的一部分)

對於上面的操作列表,以下幾點值得一提:

首先,子元件會在子檢視被檢測之前觸發 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 屬性變成了 truespan 中的文字並不會更新。然而,如果去掉 this.cd.detach(),就會按照預想的樣子更新了。

reattach

如第一部分所說,如果 AComponent 的輸入繫結 aProp 發生了變化,AComponentOnchanges 宣告週期鉤子就會被觸發。這意味著一旦我們得知輸入屬性發生了變化,就可以對當前元件啟動變化檢測器來檢測變化,然後在下一個週期將其分離。這段程式碼就是這個作用:

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 需要更新,就丟擲異常。


還有疑問?

對於本文如果你有任何問題,請到 Stack Overflow 提問,然後在本文評論區貼上鍊接。這樣整個社群都能受益。謝謝。

請在 TwitterMedium 上關注我以獲得更多資訊

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄