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

探究內部實現和具體用例


若是你想跟我同樣對 Angular 的變化檢測機制有全面的瞭解,你就不得不去查看源碼,由於網上幾乎沒有這方面的文章。大部分文章只提到每一個組件都有本身的變化檢測器,且重點在使用不可變變量(immutable)和變化檢測策略(change detection strategy)上,卻沒有進行更深刻的探討。這篇文章會帶你一塊兒瞭解爲何不可變變量能夠觸發變化檢測及變化監測策略如何 影響檢測。另外,你能夠將本文中學到的知識運用到各類須要提高性能的場景中。前端

本文包括兩部分。第一部分比較偏技術,會有不少源碼的連接。主要講解變化檢測機制是如何運做的。本文的內容是基於(當時的)最新版本 —— Angular 4.0.1。該版本中的變化檢測機制和 2.4.1 的有一點不一樣。若是你有興趣,能夠參考 Stack Overflow 上的這個回答node

第二部分展現瞭如何應用變化檢測。因爲 2.4.1 和 4.0.1 的 API 沒有發生變化,因此這一部分對於兩個版本都適用。android


核心概念:視圖(view)

Angular 的教程上一直在說,一個 Angular 應用是一顆組件樹。然而,在 Angular 內部使用的是一種叫作視圖(view)的低階抽象。視圖和組件之間是有直接聯繫的 —— 每一個視圖都有與之關聯的組件,反之亦然。視圖經過 component 屬性將其與對應的組件類關聯起來。全部的操做都在視圖中執行,好比屬性檢查和更新 DOM。因此,從技術上來講,更正確的說法是:一個 Angular 應用是一顆視圖樹。組件能夠描述爲視圖的更高階的概念。關於視圖,源碼中有這樣一段描述:ios

視圖是構成應用 UI 的基本元素。它是一組一塊兒被創造和銷燬的最小合集。git

視圖的屬性能夠更改,而視圖中元素的結構(數量和順序)不能更改。想要改變元素的結構,只能經過用 ViewContainerRef 來插入、移動或者移除嵌入的視圖。每一個視圖能夠包含多個視圖容器(View Container)。github

在這篇文章中,我會交替使用組件視圖和組件的概念。後端

值得一提的是,網上有關變化檢測文章和 StackOverflow 中的回答中,都把本文中的視圖稱爲變化檢測器對象(Change Detector Object)或者 ChangeDetectorRef。實際上,變化檢測並無單獨的對象,它實際上是在視圖上運行的。bash

每一個視圖都經過 nodes 屬性將其與子視圖相關聯,這樣就能對子視圖進行操做。app

視圖的狀態

每一個視圖都有一個 state 屬性。這是一個很是重要的屬性,由於 Angular 會根絕這個屬性的值來肯定是否要對此視圖和全部的子視圖執行變化檢測。state 屬性有不少可能的值,與本文相關的有如下幾種:dom

  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

這是變化檢測器的最後一個方法,其主要做用是保證當前執行的變化檢測中,不會有變化發生。簡單來講,它執行本文第一部分提到的列表中的第 一、七、8 步。若是發現綁定發生了變化或者 DOM 須要更新,就拋出異常。


還有疑問?

對於本文若是你有任何問題,請到 Stack Overflow 提問,而後在本文評論區貼上連接。這樣整個社區都能受益。謝謝。

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

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索