[譯]關於Angular髒值檢查你應該知道的最新指南

瘋狂秀才發表於2020-08-10

原文

Angular髒值檢查

image

本文提供了您需要了解的有關變更檢測的所有必要資訊。通過使用本文構建的演示專案來解釋angular 的變更檢測機制。

Angular的變更檢測是該框架的核心機制,但(至少以我的經驗)很難理解。更不幸的是,官方網站上沒有關於此主題的官方指南。

What Is Change Detection

Angular的兩個主要目標是可預測和高效。框架需要通過組合狀態和模板來在UI上覆制應用程式的狀態:

image

如果狀態發生任何更改,也必須更新DOM。將HTML與我們的資料同步的機制稱為“更改檢測”。每個前端框架都使用其實現,例如React使用虛擬DOM,Angular使用更改檢測等等。我可以推薦文章“ JavaScript框架中的更改及其檢測”,該文章很好地概述了此主題。

更改檢測:資料更改後更新DOM的過程

作為開發人員,大多數時候我們不需要關心變更檢測,除非我們需要優化應用程式的效能。如果處理不當,更改檢測會降低大型應用程式的效能。

How Change Detection Works 變更檢測是如何工作的

變更檢測週期可以分為兩個部分:

  • 開發人員更新應用程式模型
  • Angular通過重新渲染來同步DOM中的更新模型

讓我們更詳細地看一下這個過程:

  1. 開發人員更新資料模型,例如通過更新元件繫結
  2. angular 檢測變化
  3. 變更檢測從上到下檢查元件樹中的每個元件,以檢視相應的模型是否已更改
  4. 如果有新值,它將更新元件的檢視(DOM)

    以下GIF以簡化的方式演示了此過程:

    image

該圖顯示了Angular元件樹及其在應用程式引導過程中為每個元件建立的更改檢測器(CD)。該檢測器將當前值與屬性的先前值進行比較。如果該值已更改,它將==isChanged==設定為==true==。檢查框架程式碼中的實現,這只是與NaN的特殊處理進行的===比較。

Zone.js

一般情況下,zone可以跟蹤並攔截任何非同步任務。

Zone 通常具有以下階段:

  • 開始穩定
  • 如果任務在區域中執行,它將變得不穩定,
  • 如果任務完成,它將再次變得穩定

Angular在啟動時修補了幾個低階瀏覽器API,以便能夠檢測到應用程式中的更改。這是使用zone.js完成的,該區域修補了EventEmitter,DOM事件偵聽器,XMLHttpRequest,Node.js中的fs API等API。

簡而言之,如果發生以下事件之一,則框架將觸發更改檢測

  • 任何瀏覽器事件(單擊,鍵入等)
  • setInterval() and setTimeout()
  • HTTP 請求

Angular使用其稱為NgZone的區域。僅存在一個NgZone,並且僅針對此區域中觸發的非同步操作觸發更改檢測。

Performance 效能

預設情況下,如果模板值已更改,則“Angular Change Detection ”將從上至下檢查所有元件。

Angular對每個元件執行更改檢測的速度非常快,因為它可以使用內聯快取在毫秒內執行數千次檢查,內聯快取可生成VM優化程式碼。

如果您想對此主題有更深入的說明,建議您觀看Victor Savkin關於“重塑變化檢測”的演講。

儘管Angular在後臺進行了大量優化,但是在大型應用程式上效能仍然會下降。在下一章中,您將學習如何通過使用不同的變更檢測策略來主動提高Angular效能。

Change Detection Strategies 變更檢測策略

Angular提供了兩種策略來執行更改檢測:

  • Default
  • OnPush

讓我們看一下每種變化檢測策略。

Default Change Detection Strategy

預設情況下,Angular使用ChangeDetectionStrategy.Default更改檢測策略。每當事件觸發更改檢測(例如使用者事件,計時器,XHR,promise等)時,此預設策略都會從上到下檢查元件樹中的每個元件。這種不對元件的依賴項做任何假設的保守檢查方法稱為髒檢查。它可能會對包含許多元件的大型應用程式的效能產生負面影響。

image

OnPush Change Detection Strategy

通過將changeDetection屬性新增到元件裝飾器後設資料中,我們可以切換到ChangeDetectionStrategy.OnPush更改檢測策略:

@Component({
    selector: 'hero-card',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
export class HeroCard {
    ...
}

這種更改檢測策略可以跳過對此元件及其所有子元件的不必要檢查。
下一個GIF演示了使用OnPush更改檢測策略跳過元件樹的各個部分:
image

使用此策略,Angular知道僅在以下情況下才需要更新元件:

  • 輸入屬性已更改, 標記為@Input() 的屬性;
  • 該元件或其子元件之一觸發事件處理程式
  • 手動觸發變化檢測
  • 通過非同步管道連結到模板的可觀察物件發出新值, 如 data | async

    讓我們仔細看看這些事件型別。

    Input Reference Changes

    在預設的更改檢測策略中,每當@Input()資料被更改或修改時,Angular將執行更改檢測器。使用OnPush策略,僅當新引用作為@Input()值傳遞時,才會觸發更改檢測器。

    JavaScript中的所有內容都是按引用傳遞的,但是所有基元都是不可變的,並且它們的文字表示均指向相同的基元例項/引用。修改物件屬性或陣列條目不會建立新引用,因此不會觸發OnPush元件上的更改檢測。要觸發變更檢測器,您需要傳遞一個新的物件或陣列引用。

您可以使用簡單DEMO測試此行為:

  1. 使用ChangeDetectionStrategy.Default修改HeroCardComponent的 age
  2. 驗證帶有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent不能反映更改的age(通過元件周圍的紅色邊框顯示)
  3. 在“修改英雄”皮膚中單擊“建立新物件引用”
  4. 驗證是否通過更改檢測檢查了具有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent

image

為防止更改檢測錯誤,在所有地方僅使用不可變的物件和列表使用OnPush更改檢測來構建應用程式可能會很有用。不可變物件只能通過建立新的物件引用來修改,因此我們可以保證:

  • 每次更改都會觸發OnPush更改檢測
  • 我們不要忘了建立一個新的物件引用,否則可能導致錯誤;

Immutable.js是一個不錯的選擇,該庫為物件(地圖)和列表(列表)提供了持久不變的資料結構。通過npm安裝庫提供了型別定義,以便我們可以在IDE中利用型別泛型,錯誤檢測和自動完成功能。

Event Handler Is Triggered

如果OnPush元件或其子元件之一觸發事件處理程式(例如單擊按鈕),則將觸發更改檢測(針對元件樹中的所有元件)。

請注意,以下操作不會觸發使用OnPush更改檢測策略的更改檢測:

  • setTimeOut
  • setInterval
  • Promise.resolve().then(), (of course, the same for Promise.reject().then())
  • this.http.get('...').subscribe() (in general, any RxJS observable subscription)

    You can test this behavior using the simple demo:

    1. Click on "Change Age" button in HeroCardOnPushComponent which uses ChangeDetectionStrategy.OnPush
    2. 驗證觸發了變更檢測並檢查所有元件

image

Trigger Change Detection Manually 手動觸發變更檢測

存在三種手動觸發更改檢測的方法:

  • ChangeDetectorRef的detectChanges()通過牢記更改檢測策略在此檢視及其子級上執行更改檢測。它可以與detach()結合使用以實現本地更改檢測檢查。
  • ApplicationRef.tick()通過遵守元件的更改檢測策略來觸發整個應用程式的更改檢測
  • ChangeDetectorRef上的markForCheck()不會觸發更改檢測,但會將所有OnPush祖先標記為要檢查一次,作為當前或下一個更改檢測週期的一部分。即使已標記的元件使用OnPush策略,它也將執行更改檢測。
手動執行變更檢測不是黑客,但您只能在合理的情況下使用它,

下圖以可視表示形式顯示了不同的ChangeDetectorRef方法:

image

您可以在DEMO中使用“ DC”(detectChanges())和“ MFC”(markForCheck())按鈕來測試其中一些操作。

### Async Pipe

內建的AsyncPipe訂閱一個observable並返回它發出的最新值。

每次發出新值時,AsyncPipe內部都會呼叫markForCheck,請參見其原始碼:

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

如圖所示,AsyncPipe使用OnPush更改檢測策略自動執行。因此,建議儘可能使用它,以便以後執行從預設更改檢測策略到OnPush的切換。

您可以在非同步演示中看到這種行為。

image

第一個元件通過AsyncPipe將可觀察物件直接繫結到模板

<mat-card-title>{{ (hero$ | async).name }}</mat-card-title>
 hero$: Observable<Hero>;

  ngOnInit(): void {
    this.hero$ = interval(1000).pipe(
        startWith(createHero()),
        map(() => createHero())
      );
  }

而第二個元件訂閱可觀察物件並更新資料繫結值:

<mat-card-title>{{ hero.name }}</mat-card-title>
  hero: Hero = createHero();

  ngOnInit(): void {
    interval(1000)
      .pipe(map(() => createHero()))
        .subscribe(() => {
          this.hero = createHero();
          console.log(
            'HeroCardAsyncPipeComponent new hero without AsyncPipe: ',
            this.hero
          );
        });
  }

如您所見,沒有AsyncPipe的實現不會觸發更改檢測,因此我們需要為可觀察物件發出的每個新事件手動呼叫detectChanges()

避免變化檢測迴圈和ExpressionChangedAfterCheckedError

Angular包括一種檢測變化檢測迴圈的機制。在開發模式下,框架執行兩次更改檢測,以檢查自第一次執行以來該值是否已更改。在生產模式下,更改檢測僅執行一次即可獲得更好的效能。

我在ExpressionChangedAfterCheckedError演示中強加了該錯誤,如果開啟瀏覽器控制檯,則可以看到它:

image

在此演示中,我通過更新ngAfterViewInit生命週期掛鉤中的hero屬性來強制執行錯誤:

ngAfterViewInit(): void {
    this.hero.name = 'Another name which triggers ExpressionChangedAfterItHasBeenCheckedError';
  }

要了解為什麼這會導致錯誤,我們需要檢視更改檢測執行期間的不同步驟:

image

如我們所見,在呈現了當前檢視的DOM更新之後,將呼叫AfterViewInit生命週期掛鉤。如果我們更改此掛鉤中的值,則它將在第二次更改檢測執行中具有不同的值(如上所述,這是在開發模式下自動觸發的),因此Angular將丟擲ExpressionChangedAfterCheckedError。

我可以強烈推薦Max Koretskyi撰寫的有關Angular中的更改檢測所需的所有知識,它詳細探討了著名的ExpressionChangedAfterCheckedError的基礎實現和用例

沒有更改檢測的執行程式碼

可以在NgZone外部執行某些程式碼塊,以便它不會觸發更改檢測。

  constructor(private ngZone: NgZone) {}

  runWithoutChangeDetection() {
    this.ngZone.runOutsideAngular(() => {
      // the following setTimeout will not trigger change detection
      setTimeout(() => doStuff(), 1000);
    });
  }

這個簡單的演示提供了一個按鈕來觸發Angular區域之外的動作:

image

您應該看到該操作已記錄在控制檯中,但是HeroCard元件未選中,這意味著它們的邊框不會變成紅色。

此機制對於由量角器執行的E2E測試很有用,特別是如果您在測試中使用browser.waitForAngular。將每個命令傳送到瀏覽器後,量角器將等待,直到區域變得穩定為止。如果使用setInterval,則區域將永遠不會變得穩定,並且測試可能會超時。

RxJS可觀察物件可能發生相同的問題,但是您需要按照Zone.js對非標準API的支援中所述,將修補版本新增到polyfill.ts中:

import 'zone.js/dist/zone';  // Included with Angular CLI.
import 'zone.js/dist/zone-patch-rxjs'; // Import RxJS patch to make sure RxJS runs in the correct zone

如果沒有此修補程式,則可以在ngZone.runOutsideAngular內部執行可觀察的程式碼,但仍可以作為任務在NgZone內部執行

停用變更檢測

在特殊的使用情況下,有必要停用更改檢測。例如,如果您使用WebSocket將大量資料從後端推送到前端,則相應的前端元件僅應每10秒更新一次。在這種情況下,我們可以通過呼叫detach()來停用更改檢測,並使用detectChanges()手動觸發它:

constructor(private ref: ChangeDetectorRef) {
    ref.detach(); // deactivate change detection
    setInterval(() => {
      this.ref.detectChanges(); // manually trigger change detection
    }, 10 * 1000);
  }

在Angular應用程式的引導過程中,也可以完全停用Zone.js。這意味著自動更改檢測功能已完全停用,我們需要手動觸發使用者介面更改,例如通過呼叫ChangeDetectorRef.detectChanges()。

首先,我們需要註釋掉從polyfills.ts匯入的Zone.js:

import 'zone.js/dist/zone';  // Included with Angular CLI.

接下來,我們需要在main.ts中傳遞noop區域:

platformBrowserDynamic().bootstrapModule(AppModule, {
      ngZone: 'noop';
}).catch(err => console.log(err));

有關停用Zone.js的更多詳細資訊,請參見文章沒有Zone.Js的Angular Elements

Ivy

從Angular 9開始,Angular預設使用Ivy,它是Angular的下一代編譯和渲染管道。

Ivy仍然以正確的順序處理所有框架生命週期掛鉤,以便更改檢測像以前一樣工作。因此,您仍將在應用程式中看到相同的ExpressionChangedAfterCheckedError。

Max Koretskyi在文章中寫道

如您所見,所有熟悉的操作仍在這裡。但是操作順序似乎已經改變。例如,現在看來Angular首先檢查子元件,然後才檢查嵌入式檢視。由於目前沒有編譯器可以生成適合於檢驗我的假設的輸出,因此我不確定。

您可以在此博文末尾的“推薦文章”部分中找到另外兩個與Ivy相關的有趣文章。

最後

Angular Change Detection是一種強大的框架機制,可確保我們的UI以可預測和高效的方式表示我們的資料。可以肯定地說,更改檢測僅適用於大多數應用程式,尤其是當它們不包含50多個元件時。

作為開發人員,您通常需要深入探討此主題,原因有兩個:

  • 您收到一個ExpressionChangedAfterCheckedError並需要解決它
  • 您需要提高應用程式效能

我希望本文可以幫助您更好地瞭解Angular的變更檢測。隨意使用我的演示專案來試用不同的變更檢測策略。

推薦文章

相關文章