Categories
程式開發

Angular更改檢測終極指南


更改檢測是Angular的核心機制,一些開發者認為它很難理解。而且,官網也沒有提供有關它的官方指南。在這篇博文中,作者提供了和更改檢測相關的所有必要信息,還構建了一個演示項目,來解釋更改檢測背後的具體機制。

什麼是更改檢測

Angular的兩大宗旨是可預測和高效。框架需要組合狀態和模板,以在UI上複製應用程序的狀態:

Angular更改檢測終極指南 1

如果狀態發生任何更改,就必須更新視圖。將HTML與我們的數據同步的機制被稱為“更改檢測”。每個前端框架都有對應的實現,例如React使用虛擬DOM,Angular使用更改檢測等。我推薦大家閱讀《JavaScript框架中的更改及其檢測》,這篇文章提供了關於這一主題的很不錯的概述。

更改檢測:數據更改後更新視圖(DOM)的過程。

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

更改檢測的工作機制

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

  • 開發人員更新應用程序模型;
  • Angular通過重新渲染視圖來同步視圖中更新的模型。

我們來具體看一下這個過程:

  1. 開發人員更新數據模型,例如更新組件綁定;
  2. Angular檢測到了更改;
  3. 更改檢測從上到下檢查組件樹中的每個組件,以查看對應的模型是否已更改;
  4. 如果有新值,它將更新組件的視圖(DOM)。

以下GIF以簡化的形式演示了這一過程:

Angular更改檢測終極指南 2

這張圖顯示了一個Angular組件樹及其在應用程序引導過程中為每個組件創建的更改檢測器(CD)。檢測器會對比屬性的當前值與先前值,如果值已更改,它會將isChanged設置為true。可以看一下框架代碼中的實現,實質上就是一個===對比,對NaN有特殊處理。

更改檢測不執行深度對像比較,它只對比模板使用屬性的先前值和當前值。

Zone.js

一般來說,一個區域(zone)可以一直跟踪並攔截任何異步任務。一個區域通常具有以下階段:

  • 它在開始時是穩定的;
  • 任務在區域中運行時,它會變得不穩定;
  • 任務完成後,它會再次穩定下來。

Angular在啟動時修補了幾個瀏覽器的底層API,以便檢測應用程序中的更改。這是使用zone.js完成的,其修補了EventEmitter、DOM事件偵聽器、XMLHttpRequest和Node.js中的fs等API。

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

  • 任何瀏覽器事件(單擊、鍵入等);
  • setInterval()和setTimeout();
  • 通過XMLHttpRequest的HTTP請求。

Angular將自己的區域稱為NgZone。僅存在一個NgZone,並且僅針對此區域中觸發的異步操作觸發更改檢測。

性能

默認情況下,如果模板值已更改,Angular更改檢測將從上至下檢查所有組件。

Angular對每個組件進行更改檢測的速度非常快,因為它可以使用內聯緩存在幾毫秒內執行數千次檢查,其中內聯緩存可生成對VM優化的代碼。

如果你想了解有關這個主題的更深入的說明,建議你觀看Victor Savkin的演講:重塑更改檢測

儘管An​​gular在後台進行了大量優化,但在大型應用程序上性能可能仍會下降。在下一章節中,你將學習如何使用不同的更改檢測策略來主動改善Angular性能。

更改檢測策略

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

  • Default
  • OnPush

我們來具體研究一下這兩種策略。

默認更改檢測策略

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

Angular更改檢測終極指南 2

OnPush更改檢測策略

我們將changeDetection屬性添加到組件裝飾器元數據,就能切換到ChangeDetectionStrategy.OnPush更改檢測策略:

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

這種更改檢測策略可以跳過對這個組件及其所有子組件的非必要檢查。

下面這張GIF演示了使用OnPush更改檢測策略跳過組件樹的某些部分:

Angular更改檢測終極指南 4

使用這一策略時,Angular知道組件僅在以下情況下才需要更新:

  • 輸入引用已更改;
  • 該組件或其子組件之一觸發了一個事件處理程序;
  • 更改檢測是手動觸發的;
  • 通過異步管道鏈接到模板的一個可觀察對象發出了一個新值。

我們來仔細看看這些事件。

輸入引用更改

在默認的更改檢測策略中,每當@Input()數據被更改或修改時,Angular都會運行更改檢測器。使用OnPush策略時,只有當一個新引用被作為@Input()值傳遞時,才會觸發更改檢測器。

數值、字符串、布爾值、null和undefined之類的原始類型按值傳遞。對象和數組也按值傳遞,但是修改對象屬性或數組條目不會創建新的引用,因此不會觸發OnPush組件的更改檢測。要觸發更改檢測器,你需要傳遞一個新的對像或數組引用。

你可以使用這個簡單的演示來測試這一行為。

  1. 使用ChangeDetectionStrategy.Default修改HeroCardComponent的age;
  2. 帶有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent不能反映更改的age(組件周圍會顯示紅色邊框);
  3. 在“Modify Heroes”面板中單擊“Create new object reference”;
  4. 現在更改檢測會檢查帶有ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent。

Angular更改檢測終極指南 5

為防止更改檢測錯誤,一個小技巧是在構建應用程序時只使用不可變的對象和列表,然後在所有地方都使用OnPush更改檢測。不可變對像只能通過創建新的對象引用來修改,因此我們可以保證:

  • 每次更改都會觸發OnPush更改檢測;
  • 我們不會忘記創建新的對象引用,否則會導致一些錯誤。

Immutable.js是一個不錯的選擇,這個庫為對象(Map)和列表(List)提供了持久的不可變數據結構。通過npm安裝這個後,我們就有了類型定義,這樣就可以在IDE中使用類型泛型、錯誤檢測和自動完成功能。

觸發事件處理程序

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

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

  • setTimeout
  • setInterval
  • Promise.resolve().then()(當然Promise.reject().then()也是一樣)
  • this.http.get(’…’).subscribe()(也就是任何RxJS可觀察的訂閱)

你可以使用這個簡單的演示測試此行為。

  1. 在使用ChangeDetectionStrategy.OnPush的HeroCardOnPushComponent中單擊“Change Age”按鈕;
  2. 可以看到更改檢測被觸發,並檢查所有組件。

Angular更改檢測終極指南 6

手動觸發更改檢測

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

  • ChangeDetectorRef上的detectChanges(),它會在這個視圖及其子級上運行更改檢測,並遵循已有的更改檢測策略。它可以與detach()結合使用,以實現本地更改檢測檢查。
  • ApplicationRef.tick(),它會依照組件的更改檢測策略,觸發整個應用程序的更改檢測。
  • ChangeDetectorRef上的markForCheck()不會觸發更改檢測,但會將所有OnPush祖先標記為要檢查一次,在當前或下一個更改檢測週期中檢查。即使已標記的組件正在使用OnPush策略,也將運行更改檢測。

手動運行更改檢測不是什麼hack手段,但你只能在合理的情況下使用它。

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

Angular更改檢測終極指南 7

你可以在這個簡單的演示中使用“DC”(detectChanges())和“MFC”(markForCheck())按鈕來測試其中一些動作。

異步管道

內置的AsyncPipe訂閱一個可觀察對象,並返回它發出的最新值。

每次發出新值時,AsyncPipe內部都會調用markForCheck,請參見其源代碼

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

如圖所示,AsyncPipe使用OnPush更改檢測策略自動運行。因此建議盡量多用它,以便將來從默認更改檢測策略切換到OnPush上。

你可以在異步演示中看到這種行為。

Angular更改檢測終極指南 8

第一個組件通過AsyncPipe將一個可觀察對象直接綁定到模板:

{{ (hero$ | async).name }}
  hero$: Observable;

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

而第二個組件訂閱這個可觀察對象並更新數據綁定值:

{{ hero.name }}
  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()。

避免更改檢測循環

Angular有一種檢測更改檢測循環的機制。在開發模式下,框架運行兩次更改檢測,以檢查自首次運行以來該值是否已更改。在生產模式下,更改檢測僅運行一次以獲得更好的性能。

我在ExpressionChangedAfterCheckedError演示中強加了這個錯誤,打開瀏覽器控制台就能看到:

Angular更改檢測終極指南 9

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

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

要搞清楚為什麼會導致錯誤,我們需要查看更改檢測運行期間的各個步驟:

Angular更改檢測終極指南 10

如你所見,在渲染了當前視圖的DOM更新之後,將調用AfterViewInit生命週期hook。如果我們更改這個hook中的值,它在第二次更改檢測中將具有不同的值(如上所述,第二次檢測在開發模式下是自動觸發的),因此Angular將拋出ExpressionChangedAfterCheckedError。

我強烈建議你閱讀Max Koretskyi撰寫的《Angular更改檢測全面解析》,它詳細探討了著名的ExpressionChangedAfterCheckedError的底層實現和用例。

運行代碼時不進行更改檢測

可以在NgZone外部運行某些代碼塊,這樣就不會觸發更改檢測。

  constructor(private ngZone: NgZone) {}

  runWithoutChangeDetection() {
    this.ngZone.runOutsideAngular(() => {
      // 后面的setTimeout不会触发更改检测
      setTimeout(() => doStuff(), 1000);
    });
  }

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

Angular更改檢測終極指南 11

你能看到這個動作已在控制台中記錄了下來,但是HeroCard組件沒有被檢查,意味著它們的邊框不會變成紅色。

這個機制對由Protractor運行的端到端測試很有用,尤其是在測試中使用browser.waitForAngular的情況下。將每個命令發送到瀏覽器後,Protractor將等待到區域變得穩定為止。如果使用setInterval,區域將永遠不會穩定,並且測試可能會超時。

RxJS可觀察對象可能會遇到相同的問題,但你需要按照Zone.js對非標準API的支持文檔所述,將修補版本添加到polyfill.ts中:

import 'zone.js/dist/zone';  // 用Angular CLI加入进来.
import 'zone.js/dist/zone-patch-rxjs'; // 导入RxJS补丁来确保RxJS运行在正确的区域中

如果沒有這個修補程序,你可以在ngZone.runOutsideAngular內部運行可觀察對象的代碼,但它仍會作為在NgZone內部的任務來運行。

停用更改檢測

在一些特殊的情況下有必要停用更改檢測。例如,如果你使用WebSocket將大量數據從後端推送到前端,則相應的前端組件應該每10秒才更新一次。在這種情況下,我們可以調用detach()來停用更改檢測,並使用detectChanges()手動觸發它:

constructor(private ref: ChangeDetectorRef) {
    ref.detach(); // 停用更改检测
    setInterval(() => {
      this.ref.detectChanges(); // 手动触发更改检测
    }, 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 8開始,你可以選擇使用Ivy的預覽版本,並幫助其開發和改進。

Angular團隊將確保新的渲染引擎仍以正確的順序處理所有框架的生命週期hooks,以便更改檢測能正常工作。因此,你還是會在應用程序中看到相同的ExpressionChangedAfterCheckedError。

Max Koretskyi在這篇文章中寫道:

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

你可以在本文末尾的“推薦文章”部分中找到另外兩篇與Ivy相關的有趣文章。

總結

Angular更改檢測是一種強大的框架機制,可確保我們的UI以可預測和高效的方式表示我們的數據。可以肯定地說,更改檢測適用於大多數應用程序,尤其是包含的組件少於50個的應用。

作為開發人員,當你需要深入研究這一主題時,往往出於以下兩個原因:

  • 你收到一個ExpressionChangedAfterCheckedError,並需要解決它。
  • 你需要提高應用程序性能。

希望本文能幫助你更好地了解Angular的更改檢測。請隨意使用我的演示項目來嘗試不同的更改檢測策略。

https://github.com/Mokkapps/angular-change-detection-demo

推薦文章

原文鏈接
https://www.mokkapps.de/blog/the-last-guide-for-angular-change-detection-you-will-ever-need/