[Angular][translate]有關Angular的變更檢測

我愛吃南瓜發表於2019-01-23

原文連結: https://blog.angularindepth.com/everything-you-need-to-know-about-change-detection-in-angular-8006c51d206f

這篇文章只是我自己一邊看原文一邊隨手翻譯的,箇中錯誤以及表述多有不正確,閱讀原文才是最佳選擇。

如果你也像我一樣想對Angular的變更檢測有更深入的瞭解,去看原始碼無疑是必須的選擇,因為網路上相關的資訊實在是太少了。大多數文章都只是指出每個元件都有自己的變更檢測器,但是並沒有繼續深入,他們大多關注於不可變物件和變更檢測策略的使用。所以這篇文章的目的是告訴你,為什麼使用不可變物件會有效?變更檢測策略是如何影響到檢測的?同時,這篇文章也能讓你能對不同場景下提出相應的效能優化方法。

這篇文章由兩部分組成,第一部分技術性較強且有很多原始碼部分的引用。主要解釋了變更檢測在底層的工作細節。基於Angular-4.0.1。需要注意的是,2.4.1之後版本的變更檢測策略有較大的變化。如果你對以前版本的變更檢測有興趣,可以閱讀這篇回答

第二部分主要講解了如何在應用中使用變更檢測,這部分對於Angular2+都是相同的。因為Angular的公共API並沒有發生變化。

核心概念-檢視View

Angular的文件中通篇都提到了一個Angular應用是一個元件樹。但是Angular底層其實使用了一個低階抽象-檢視View。檢視View和元件之間的關係很直接-一個檢視與一個元件相關聯,反之亦然。每個檢視都在它的component屬性中保持了一個與之關聯的元件例項的引用。所有的類似於屬性檢測、DOM更新之類的操作都是在檢視上進行的。因此,技術上而言把Angular應用描述成一個檢視樹更加準確,因為元件是檢視的一個高階描述。在原始碼中有關檢視是這麼描述的:

A View is a fundamental building block of the application UI. It is the smallest grouping of Elements which are created and destroyed together.

檢視是組成應用介面的最小單元,它是一系列元素的組合,一起被建立,一起被銷燬。

Properties of elements in a View can change, but the structure (number and order) of elements in a View cannot. Changing the structure of Elements can only be done by inserting, moving or removing nested Views via a ViewContainerRef. Each View can contain many View Containers.

檢視中元素的屬性可以發生變化,但是檢視中元素的數量和順序不能變化。如果想要改變的話,需要通過VireContainerRef來執行插入,移動和刪除操作。每個檢視都會包括多個View Container。

在這篇文章中,元件和元件檢視的概念是互相可替代的。

需要注意的是:網路上很多文章都把我們這裡所描述的檢視作為了變更檢測物件或者ChangeDetectorRef。事實上,Angular中並沒有一個單獨的物件用來做變更檢測,所有的變更檢測都在檢視上直接執行。

export interface ViewData {
  def: ViewDefinition;
  root: RootData;
  renderer: Renderer2;
  // index of component provider / anchor.
  parentNodeDef: NodeDef|null;
  parent: ViewData|null;
  viewContainerParent: ViewData|null;
  component: any;
  context: any;
  // Attention: Never loop over this, as this will
  // create a polymorphic usage site.
  // Instead: Always loop over ViewDefinition.nodes,
  // and call the right accessor (e.g. `elementData`) based on
  // the NodeType.
  nodes: {[key: number]: NodeData};
  state: ViewState;
  oldValues: any[];
  disposables: DisposableFn[]|null;
}
複製程式碼

檢視的狀態

每個檢視都有自己的狀態,基於這些狀態的值,Angular會決定是否對這個檢視和他所有的子檢視執行變更檢測。檢視有很多狀態值,但是在這篇文章中,下面四個狀態值最為重要:

// Bitmask of states
export const enum ViewState {
  FirstCheck = 1 << 0,
  ChecksEnabled = 1 << 1,
  Errored = 1 << 2,
  Destroyed = 1 << 3
}
複製程式碼

如果CheckedEnabled值為false或者檢視處於Errored或者Destroyed狀態時,這個檢視的變更檢測就不會執行。預設情況下,所有檢視初始化時都會帶上CheckEnabled,除非使用了ChangeDetectionStrategy.onPush。有關onPush我們稍後再講。這些狀態也可以被合併使用,比如一個檢視可以同時有FirstCheck和CheckEnabled兩個成員。

針對操作檢視,Angular中有一些封裝出的高階概念,詳見這裡。一個概念是ViewRef。他的_view屬性囊括了元件檢視,同時它還有一個方法detectChanges。當一個非同步事件觸發時,Angular從他的最頂層的ViewRef開始觸發變更檢測,然後對子檢視繼續進行變更檢測。

ChangeDectionRef可以被注入到元件的建構函式中。這個類的定義如下:

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 {
    /**
     * Destroys the view and all of the data structures associated with it.
     */
    abstract destroy(): void;
    abstract get destroyed(): boolean;
    abstract onDestroy(callback: Function): any
}
複製程式碼

變更檢測操作

負責對檢視執行變更檢測的主要邏輯屬於checkAndUpdateView方法。他的大部分功能都是對子元件檢視進行操作。從宿主元件開始,這個方法被遞迴呼叫作用於每一個元件。這意味著當遞迴樹展開時,在下一次呼叫這個方法時子元件會成為父元件。

當在某個特定檢視上開始觸發這個方法時,以下操作會依次發生:

  1. 如果這是檢視的第一次檢測,將ViewState.firstCheck設定為true,否則為false;
  2. 檢查並更新子元件/指令的輸入屬性-checkAndUpdateDirectiveInline
  3. 更新子檢視的變更檢測狀態(屬於變更檢測策略實現的一部分)
  4. 對內嵌檢視執行變更檢測(重複列表中的步驟)
  5. 如果繫結的值發生變化,呼叫子元件的onChanges生命週期鉤子;
  6. 呼叫子元件的OnInit和DoCheck兩個生命週期鉤子(OnInit只在第一次變更檢測時呼叫)
  7. 在子元件檢視上更新ContentChildren列表-checkAndUpdateQuery
  8. 呼叫子元件的AfterContentInit和AfterContentChecked(前者只在第一次檢測時呼叫)-callProviderLifecycles
  9. 如果當前檢視元件上的屬性發生變化,更新DOM
  10. 對子檢視執行變更檢測-callViewAction
  11. 更新當前檢視元件的ViewChildren列表-checkAndUpdateQuery
  12. 呼叫子元件的AfterViewInit和AfterViewChecked-callProviderLifecycles
  13. 對當前檢視禁用檢測

在以上操作中有幾點需要注意

深入這些操作的含義

假設我們現在有一棵元件樹:

在上面的講解中我們得知了每個元件都和一個元件檢視相關聯。每個檢視都使用ViewState.checksEnabled初始化了。這意味著當Angular開始變更檢測時,整棵元件樹上的所有元件都會被檢測;

假設此時我們需要禁用AComponent和它的子元件的變更檢測,我們只要將它的ViewState.checksEnabled設定為false就行。這聽起來很容易,但是改變state的值是一個很底層的操作,因此Angular在檢視上提供了很多方法。通過ChangeDetectorRef每個元件可以獲得與之關聯的檢視。

class ChangeDetectorRef {
  markForCheck() : void
  detach() : void
  reattach() : void
  
  detectChanges() : void
  checkNoChanges() : void
}
複製程式碼

detach

這個方法簡單的禁止了對當前檢視的檢測;

detach(): void {
    this._view.state &= ~ViewState.checksEnabled;
}
複製程式碼

在元件中的使用方法:

export class AComponent {
    constructor(
        private cd: ChangeDectectorRef,
    ) {
        this.cd.detach();
    }
}
複製程式碼

這樣就會導致在接下來的變更檢測中AComponent及子元件都會被跳過。

這裡有兩點需要注意:

  • 雖然我們只修改了AComponent的state值,但是他的子元件也不會被執行變更檢測;
  • 由於AComponent及其子元件不會有變更檢測,因此他們的DOM也不會有任何更新

下面是一個簡單示例,點選按鈕後在輸入框中修改就再也不會引起下面的p標籤的變化,外部父元件傳遞進來的值發生變化也不會觸發變更檢測:

import { Component, OnInit, ChangeDetectorRef } from `@angular/core`;
@Component({
    selector: `app-change-dection`,
    template: `
    <input [(ngModel)]="name">
    <button (click)="stopCheck()">停止檢測</button>
    <p>{{name}}</p>
    `,
    styleUrls: [`./change-dection.component.css`]
})
export class ChangeDectionComponent implements OnInit {
    name = `erik`;
    constructor(
        private cd: ChangeDetectorRef,
    ) { }
    ngOnInit() {
    }
    stopCheck() {
        this.cd.detach();
    }
}
複製程式碼

reattach

文章第一部分提到:如果AComponent的輸入屬性aProp發生變化,OnChanges生命週期鉤子仍會被呼叫,這意味著一旦我們得知輸入屬性發生變化,我們可以啟用當前元件的變更檢測並在下一個tick中繼續detach變更檢測。

reattach(): void { 
    this._view.state |= ViewState.ChecksEnabled; 
}
複製程式碼
export class ChangeDectionComponent implements OnInit, OnChanges {
    @Input() aProp: string;
    name = `erik`;
    constructor(
        private cd: ChangeDetectorRef,
    ) { }
    ngOnInit() {
    }
    ngOnChanges(change) {
        this.cd.reattach();
        setTimeout(() => {
            this.cd.detach();
        });
    }
}
複製程式碼

上面這種做法幾乎與將ChangeDetectionStrategy改為OnPush是等效的。他們都在第一輪變更檢測後禁用了檢測,當父元件向子元件傳值發生變化時啟用變更檢測,然後又禁用變更檢測。

需要注意的是,在這種情況下,只有被禁用檢測分支最頂層元件的OnChanges鉤子才會被觸發,並不是這個分支的所有元件的OnChanges都會被觸發,原因也很簡單,被禁用檢測的這個分支內不存在了變更檢測,自然內部也不會向子元素變更所傳遞的值,但是頂層的元素仍可以接受到外部變更的輸入屬性。

譯註:其實將retach()和detach()放在ngOnChanges()和OnPush策略還是不一樣的,OnPush策略的確是只有在input值的引用發生變化時才出發變更檢測,這一點是正確的,但是OnPush策略本身並不影響元件內部的值的變化引起的變更檢測,而上例中元件內部的變更檢測也會被禁用。如果將這段邏輯放在ngDoCheck()中才更正確一點。

maskForCheck

上面的reattach()方法可以對當前元件開啟變更檢測,然而如果這個元件的父元件或者更上層的元件的變更檢測仍被禁用,用reattach()後是沒有任何作用的。這意味著reattach()方法只對被禁用檢測分支的最頂層元件有意義。

因此我們需要一個方法,可以將當前元素及所有祖先元素直到根元素的變更檢測都開啟。ChangeDetectorRef提供了markForCheck方法:

let currView: ViewData|null = view;
while (currView) {
  if (currView.def.flags & ViewFlags.OnPush) {
    currView.state |= ViewState.ChecksEnabled;
  }
  currView = currView.viewContainerParent || currView.parent;
}
複製程式碼

在這個實現中,它簡單的向上迭代並啟用對所有直到根元件的祖先元件的檢查。

這個方法在什麼時候有用呢?禁用變更檢測策略之後,ngDoCheck生命週期還是會像ngOnChanges一樣被觸發。當然,跟OnChanges一樣,DoCheck也只會在禁用檢測分支的頂部元件上被呼叫。但是我們就可以利用這個生命週期鉤子來實現自己的業務邏輯和將這個元件標記為可以進行一輪變更檢測。

由於Angular只檢測物件引用,我們需要通過對物件的某些屬性來進行這種髒檢查:

// 這裡如果外部items變化為改變引用位置,此元件是不會執行變更檢測的
// 但是如果在DoCheck()鉤子中呼叫markForCheck
// 由於OnPush策略不影響DoCheck的執行,這樣就可以偵測到這個變更
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

Angular提供了一個方法detectChanges,對當前元件和所有子元件執行一輪變更檢測。這個方法會無視元件的ViewState,也就是說這個方法不會改變元件的變更檢測策略,元件仍會維持原有的會被檢測或不會被檢測狀態。

export class AComponent {
  @Input() inputAProp;

  constructor(public cd: ChangeDetectorRef) {
    this.cd.detach();
  }

  ngOnChanges(values) {
    this.cd.detectChanges();
  }
}
複製程式碼

通過這個方法我們可以實現一個類似Angular.js的手動呼叫髒檢查。

checkNoChanges

這個方法是用來當前變更檢測沒有產生任何變化。他執行了文章第一部分1,7,8三個操作,並在發現有變更導致DOM需要更新時丟擲異常。

結束!哈!

相關文章