OnPush 元件中 NgDoCheck 和 AsyncPipe 的區別

Ice Panpan發表於2018-12-17

原文:The difference between NgDoCheck and AsyncPipe in OnPush components
作者:Max Koretskyi
原技術博文由 Max Koretskyi 撰寫釋出,他目前於 ag-Grid 擔任開發大使(Developer Advocate)
譯者按:開發大使負責確保其所在的公司認真聽取社群的聲音並向社群傳達他們的行動及目標,其作為社群和公司之間的紐帶存在。
譯者:Ice Panpan;校對者:vaanxy

OnPush 元件中 NgDoCheck 和 AsyncPipe 的區別

這篇文章是對Shai這條推特的迴應。他詢問使用 NgDoCheck 生命週期鉤子來手動比較值而不是使用 asyncPipe 是否有意義。這是一個非常好的問題,需要對引擎的工作原理有很多瞭解:變化檢測(change detection),管道(pipe)和生命週期鉤子(lifecycle hooks)。那就是我探索的入口?。

在本文中,我將向您展示如何手動處理變更檢測。這些技術使您可以更好地掌控 Angular 的輸入繫結(input bindings)的自動執行和非同步值檢查(async values checks)。掌握了這些知識之後,我還將與您分享我對這些解決方案的效能影響的看法。讓我們開始吧!

OnPush 元件

在 Angular 中,我們有一種非常常見的優化技術,需要將 ChangeDetectionStrategy.OnPush 新增到元件中。假設我們有如下兩個簡單的元件:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <b-comp></b-comp>
    `
})
export class AComponent {}

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`
})
export class BComponent {}
複製程式碼

這樣設定之後, Angular 每次都會對 AB 兩個元件執行變更檢測。如果我們現在為 B 元件新增上 OnPush 策略:

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {}
複製程式碼

只有在輸入繫結的值發生變化時 Angular 才會對 B 執行變更檢測。由於它現在沒有任何繫結,因此該元件只會在初始化的時候檢查一次。

手動觸發變更檢測

有沒有辦法強制對 B 元件進行變更檢測?是的,我們可以注入 changeDetectorRef 並使用它的方法 markForCheck 來指示 Angular 需要檢查該元件。並且由於 NgDoCheck 鉤子仍然會被 B 元件觸發,所以我們應該在 NgDoCheck 中呼叫 markForCheck

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    constructor(private cd: ChangeDetectorRef) {}

    ngDoCheck() {
        this.cd.markForCheck();
    }
}
複製程式碼

現在,當 Angular 檢查父元件 A 時,將始終檢查 B 元件。現在讓我們看看我們可以在哪裡使用它。

輸入繫結

我之前說過,Angular 只在 OnPush 元件的繫結發生變化時執行的變化檢測。所以讓我們看一下輸入繫結的例子。假設我們有一個通過輸入繫結從父元件傳遞下來的物件:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
}
複製程式碼

在父元件 A 中,我們定義了一個物件,並實現了在單擊按鈕時來更新物件名稱的 changeName 方法:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <button (click)="changeName()">Trigger change detection</button>
        <b-comp [user]="user"></b-comp>
    `
})
export class AComponent {
    user = {name: 'A'};

    changeName() {
        this.user.name = 'B';
    }
}
複製程式碼

如果您現在執行此示例,則在第一次變更檢測後,您將看到使用者名稱稱被列印出來:

User name: A
複製程式碼

但是當我們點選按鈕並回撥中更改名稱時:

changeName() {
    this.user.name = 'B';
}
複製程式碼

該名稱並沒有在螢幕上更新,這是因為 Angular 對輸入引數執行淺比較,並且對 user 物件的引用沒有改變。那我們怎麼解決這個問題呢?

好吧,我們可以在檢測到差異時手動檢查名稱並觸發變更檢測:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
    previousName = '';

    constructor(private cd: ChangeDetectorRef) {}

    ngDoCheck() {
        if (this.previousName !== this.user.name) {
            this.previousName = this.user.name;
            this.cd.markForCheck();
        }
    }
}
複製程式碼

如果您現在執行此程式碼,你將在螢幕上看到更新的名稱。

非同步更新

現在,讓我們的例子更復雜一點。我們將介紹一種基於 RxJs 的服務,它可以非同步發出更新。這類似於 NgRx 的體系結構。我將使用一個 BehaviorSubject 作為值的來源,因為我們需要在這個流的最開始設定初始值:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <button (click)="changeName()">Trigger change detection</button>
        <b-comp [user]="user"></b-comp>
    `
})
export class AComponent {
    stream = new BehaviorSubject({name: 'A'});
    user = this.stream.asObservable();

    changeName() {
        this.stream.next({name: 'B'});
    }
}
複製程式碼

所以我們需要在子元件中訂閱這個流並從中獲取到 user 物件。我們需要訂閱流並檢查值是否更新。這樣做的常用方法是使用 AsyncPipe

AsyncPipe

所以這裡是子元件 B 的實現:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{(user | async).name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
}
複製程式碼

這是演示。但是,還有另一種不使用管道的方法嗎?

手動檢查並且變更檢測

是的,我們可以手動檢查值並在需要時觸發變更檢測。正如開頭的例子一樣,我們可以使用 NgDoCheck 生命週期鉤子:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input('user') user$;
    user;
    previousName = '';

    constructor(private cd: ChangeDetectorRef) {}

    ngOnInit() {
        this.user$.subscribe((user) => {
            this.user = user;
        })
    }

    ngDoCheck() {
        if (this.previousName !== this.user.name) {
            this.previousName = this.user.name;
            this.cd.markForCheck();
        }
    }
}
複製程式碼

你可以在這檢視

我們希望把值的比較與更新邏輯從 NgDoCheck 中移至訂閱的回撥函式,因為我們是從那裡獲取到新值的:

export class BComponent {
    @Input('user') user$;
    user = {name: null};

    constructor(private cd: ChangeDetectorRef) {}

    ngOnInit() {
        this.user$.subscribe((user) => {
            if (this.user.name !== user.name) {
                this.cd.markForCheck();
                this.user = user;
            }
        })
    }
}
複製程式碼

例子在這

有趣的是,這其實正是 AsyncPipe 背後的工作原理

@Pipe({name: 'async', pure: false})
export class AsyncPipe implements OnDestroy, PipeTransform {
  constructor(private _ref: ChangeDetectorRef) {}

  transform(obj: ...): any {
    ...
    this._subscribe(obj);

    ...
    if (this._latestValue === this._latestReturnedValue) {
      return this._latestReturnedValue;
    }

    this._latestReturnedValue = this._latestValue;
    return WrappedValue.wrap(this._latestValue);
  }

  private _subscribe(obj): void {
    ...
    this._strategy.createSubscription(
        obj, (value: Object) => this._updateLatestValue(obj, value));
  }

  private _updateLatestValue(async: any, value: Object): void {
    if (async === this._obj) {
      this._latestValue = value;
      this._ref.markForCheck();
    }
  }
}
複製程式碼

那麼那種解決方案更快?

現在我們知道如何使用手動進行變更檢測而不是使用 AsyncPipe,讓我們回答下最一開始的問題。那種方法更快?

嗯...這取決於你如何比較它們,但在其他條件相同的情況下,手動方法會更快。儘管我不認為兩者會有明顯區別。以下是為什麼手動方法可以更快的幾個例子。

就記憶體而言,您不需要建立 Pipe 類的例項。就編譯時間而言,編譯器不必花時間解析管道特定語法並生成管道特定輸出。就執行時間而言,節省了非同步管道為元件進行變更檢測所呼叫的函式的時間。這個例子演示了當程式碼中包含 pipe 時 updateRenderer 所生成的程式碼:

function (_ck, _v) {
    var _co = _v.component;
    var currVal_0 = jit_unwrapValue_7(_v, 3, 0, asyncpipe.transform(_co.user)).name;
    _ck(_v, 3, 0, currVal_0);
}
複製程式碼

如您所見,非同步管道的程式碼呼叫管道例項上的 transform 方法以獲取新值。管道將返回從訂閱中收到的最新值。

將其與為手動方法生成的普通程式碼進行比較:

function(_ck,_v) {
    var _co = _v.component;
    var currVal_0 = _co.user.name;
    _ck(_v,3,0,currVal_0);
}
複製程式碼

這就是 Angular 在檢查 B 元件時呼叫的方法。

一些更有趣的事情

與執行淺比較的輸入繫結不同,非同步管道的實現根本不執行比較(感謝 Olena Horal 注意到這一點)。它將每個新發射的值認為是更新,即使它與先前發射的值一樣。下面的程式碼是父元件 A 的實現,它每次都發射出相同的物件。儘管如此,Angular 仍然會對 B 元件進行變更檢測:

export class AComponent {
    o = {name: 'A'};
    user = new BehaviorSubject(this.o);

    changeName() {
        this.user.next(this.o);
    }
}
複製程式碼

這意味著每次發出新值時,使用非同步管道的元件都會被標記以進行檢查。並且 Angular 將在下次執行變更檢測時檢查該元件,即使該值未更改。

這是應用於什麼情況呢?嗯...在我們的例子中,我們只關注 user 物件的 name 屬性,因為我們需要在模板中使用它。我們並不關心整個物件以及物件的引用可能會改變的事實。如果 name 沒有發生改變,我們不需要重新渲染元件。但你無法用非同步管道來避免這種情況。

NgDoCheck 並不是沒有問題:)由於僅在檢查父元件時觸發鉤子,如果其中一個父元件使用 OnPush 策略並且在變更檢測期間未檢查,則不會觸發該鉤子。因此,當您通過服務收到新值時,不能依賴它來觸發變更檢測。在這種情況下,我在訂閱回撥中呼叫 markForCheck 方法是正確的解決方案。

總結

基本上,手動比較可以讓您更好地控制檢查。您可以定義何時需要檢查元件。這與許多其他工具相同 - 手動控制為您提供了更大的靈活性,但您必須知道自己在做什麼。為了獲得這些知識,我鼓勵您投入時間和精力學習和閱讀更多文章

你不用擔心 NgDoCheck 生命週期鉤子被呼叫的頻率,或者它會比管道的 transform 方法更頻繁地被呼叫。首先,我上面已經展示瞭解決方案,當使用非同步流時,你應該在訂閱的回撥中而非在該鉤子函式中手動執行變更檢測。其次,只有在父元件被檢測後才會呼叫該鉤子函式。如果父元件沒有被檢查,則不會呼叫該鉤子。對於管道而言,由於流中的淺比較和更改引用的原因,管道的 transform 方法被呼叫的次數只會和手動方法相同甚至更多。

想要了解更過關於 Angular 中 change detection 的相關知識?

從這5篇文章入手會讓你成為Angular Change Detection 的專家。如果你想要牢固掌握 Angular 中變更檢測機制,那麼這一系列的文章是必讀的。每一篇文章都會基於前一篇文章中所解釋的相關資訊,既包含高層次的概述又囊括了具體的實現細節,並且都附有相關原始碼。

相關文章