[譯] 深入分析 Angular 變更檢測

嘉文發表於2019-03-04

原文連結: Angular Change Detection Explained
原文作者: Pascal Precht
譯者: 嘉文
校驗: 嘉文
譯者注:本文是作者在 NG-GL(我也不知道是什麼) 上的一個演講,由他本人整理成文字稿。若非必要,某些重要的術語不進行翻譯。

目錄

  • 什麼是變更檢測 (Change Dectetion)?
  • 什麼引起了變更 (change) ?
  • 發生變更後,誰通知Angular?
  • 變更檢測
  • 效能
  • 更聰明的變更檢測
  • 不變物件 (Immutable Objects)
  • 減少檢測次數 (number of checks)
  • Observables
  • 更多..

什麼是變更檢測

變更檢測的基本任務是獲得程式的內部狀態並使之在使用者介面可見。這個狀態可以是任何的物件、陣列、基本資料型別,..也就是任意的JavaScript資料結構。

這個狀態在使用者介面上最終可能成為段落、表格、連結或者按鈕,並且特別對於 web 而言,會成為 DOM 。所以基本上我們將資料結構作為輸入,並生成 DOM 作為輸出並展現給使用者。我們把這一過程成為rendering(渲染)

然而,當變更發生在 runtime 的時候,它會變得很奇怪。比如當 DOM 已經渲染完成以後。我們要如何知悉 model 中什麼發生了改變,以及更新 DOM 的什麼位置?
訪問DOM樹是十分耗時的,所以我們不僅要找到應該更新 DOM 的位置,並且要儘可能少地訪問它。

這個問題有許多解決方法。比如其中一個方法是簡單地通過傳送http請求並重新渲染整個頁面。另一個方法是 ReactJs 提出的 Virtual Dom 的概念,即檢測 DOM 的新狀態與舊狀態的不同並渲染其不同的地方。

Tero 寫了一篇很棒的文章,是關於 Change and its detection in JavaScript frameworks,即不同JavaScript框架之間的變更檢測,如果你對於這個問題感興趣的話我建議你們去看一看。在這篇文章中我會專注於Angular>=2.x的版本。

什麼引起了變更(change)?

既然我們知道了變更檢測是什麼,我們可能會疑惑:到底這樣的變更什麼時候會發生呢?Angular 什麼時候知道它必須更新 view 呢?好吧,我們來看看下面的程式碼:

@Component({
  template: `
    <h1>{{firstname}} {{lastname}}</h1>
    <button (click)="changeName()">Change name</button>
  `
})
class MyApp {

  firstname:string = `Pascal`;
  lastname:string = `Precht`;

  changeName() {
    this.firstname = `Brad`;
    this.lastname = `Green`;
  }
}複製程式碼

如果這是你第一次看Angular元件,你可能得先去看看 如何寫一個tabs元件

上面這個元件簡單地展示了兩個屬性,並提供了一個方法,在點選按鈕的時候呼叫這個方法來改變這兩個屬性。這個按鈕被點選的時候就是程式狀態已經發生了改變的時候,因為它改變了這個元件的屬性。這就是我們需要更新檢視(view)的時候。

下面是另一個例子:

@Component()
class ContactsApp implements OnInit{

  contacts:Contact[] = [];

  constructor(private http: Http) {}

  ngOnInit() {
    this.http.get(`/contacts`)
      .map(res => res.json())
      .subscribe(contacts => this.contacts = contacts);
  }
}複製程式碼

這個元件儲存著一個聯絡人的列表,並且當他初始化的時候,它發起了一個 http 請求。一旦這個請求返回,這個聯絡人列表就會被更新。在這個時候,我們的程式狀態發生了改變,因而我們需要更新檢視。

通過上面兩個例子我們可以看出,基本上,程式狀態發生改變有三個原因:

  • 事件click,submit
  • XHR – 從伺服器獲取資料。
  • TimerssetTimeout(), setInterval()
    這些全都是非同步的。從中我們可以得出一個結論,基本上只要非同步操作發生了,我們的程式狀態就可能發生改變。這就是 Angular 需要被通知更新 view 的時候了

誰通知 Angular ?

到目前為止,我們已經知道了是什麼導致程式狀態的改變,但在這個檢視必須發生改變的時候,到底是誰來通知 Angular 呢?

Angular 允許我們直接使用原生的 API。沒有任何方法需要被呼叫,Angular 就被通知去更新 DOM 了。這是魔術嗎?

如果你有看過我們最近的文章,你會知道是 Zones做了這一切。事實上,Angular 有著自己的zone,稱為NgZone, 我們寫過一篇關於它的文章 Zones in Angular. 你可能也想要看一下。

簡單描述一下就是,Angular原始碼的某個地方,有一個東西叫做ApplicationRef,它監聽NgZonesonTurnDone事件。只要這個事件發生了,它就執行tick()函式,這個函式執行變更檢測

// 真實原始碼的非常簡化版本。
class ApplicationRef {

  changeDetectorRefs:ChangeDetectorRef[] = [];

  constructor(private zone: NgZone) {
    this.zone.onTurnDone
      .subscribe(() => this.zone.run(() => this.tick());
  }

  tick() {
    this.changeDetectorRefs
      .forEach((ref) => ref.detectChanges());
  }
}複製程式碼

變更檢測

Okay cool,我們現在已經知道了什麼時候變更檢測會被觸發(triggered),但它是怎麼執行的呢?Well,我們需要注意到的第一件事情是,在 Angular 中,每個元件都有它自己的 change detector (變更檢測器)

這是很明顯的,因為這讓我們可以單獨的控制每個元件的變更檢測何時發生以及如何執行。我們後面再細說這一點。

我們假設元件樹的某處發生了一個事件,可能是一個按鈕被點選。接下來會發生什麼?我們剛剛知道了, zones 執行給定的 handler (事件處理函式)並且在執行完成後通知 Angular,接著 Angular 執行變更檢測。

既然每個元件都有它自己的變更檢測器,並且一個 Angular 應用包含著一個元件樹,那麼邏輯上我們也有一個change detector 樹 (變更檢測器樹)。這棵樹也可以被看成是一個有向圖,該有向圖的資料總是從頂端流向低端。

資料總是由頂端流向底端的原因在於,對於每一個元件,變更檢測總是從頂端開始執行,每次都是從根元件開始。這非常棒,因為單向的資料流相較於迴圈的資料流更容易預測。我們永遠知道檢視中使用的資料從哪裡來,因為它只能源於它所在的元件。

另一個有趣的觀察是,在單通道中變更檢測會更加穩定。這意味著,如果當我們第一次執行完變更檢測後,只要其中一個元件導致了任何的副作用,Angular 就會 throw an error。

效能

預設的,在事件發生的時候,即使我們每次都檢測每個元件,Angular 也是非常快的,它會在幾毫秒內執行成千上萬次的檢測。這主要是因為Angular 生成了 VM friendly code (對虛擬機器友好的程式碼),

這是啥意思?Well, 當我們說每個元件都有它自己的變更檢測器的時候,並不是真的說在 Angular 有這樣一個普遍的東西( genetic thing )負責每一個元件的變更檢測。

這樣做的原因在於,它(變更檢測器)必須被編寫成動態的,這樣它才能夠檢測所有的元件,不管這個元件的模型結構是怎樣的。而 VMs 不喜歡這種動態程式碼,因為 VMs 不能優化它們。當一個物件的結構不總是相同的時候,它通常被稱作多型的( polymorphic )。

Angular 對於每個元件都在 runtime 生成變更檢測器類,而這些變更檢測器類是單態的,因為他們確切地知道這個元件的模型是怎樣的。VMs可以完美地優化這些程式碼,這使得它執行得非常快。好訊息是,我們並不需要管那麼多,因為 Angular 自動地做了這些工作。

可以看看 Victor Savkin 關於Change Detection Reinvented 的演講,你可以得到更深入的解釋。

更聰明的變更檢測

我們知道,一旦 event 發生,Angular 必須每次都檢測所有的元件,因為..well,可能是因為應用的狀態發生了改變。但如果我們讓 Angular 對應用中狀態發生改變的那部分執行變更檢檢測,豈不是美滋滋?

是的,這很美滋滋,並且我們可以做到。只要通過下面幾種資料結構——Immutables 和 Observables.(原文是:It turns out there are data structures that give us some guarantees of when something has changed or not – Immutables and Observables 才疏學淺,不能準確翻譯,抱歉orz)如果我們恰好使用了這些資料結構並且我們告訴了 Angular,那麼變更檢測就會變得 much much faster. Okay cool, 那麼要如何做?

理解易變性( Mutability )

為了理解為什麼以及如何 immutable data structures (不可變的資料結構) 有助於更快的變更檢測,我們需要理解 mutability 到底是什麼。假設我們有下面的元件:

@Component({
  template: `<v-card [vData]="vData"></v-card>`
})
class VCardApp {

  constructor() {
    this.vData = {
      name: `Christoph Burgdorf`,
      email: `christoph@thoughtram.io`
    }
  }

  changeData() {
    this.vData.name = `Pascal Precht`;
  }
}複製程式碼

VCardApp 使用<v-card>作為子元件,該子元件有一個輸入屬性vData,我們將VCardApp的屬性vData傳入子元件。vData是一個包含兩個屬性的物件。另外還有一個changeData()方法,這個方法改變vData的 name。 這裡沒有什麼特別的魔法。

這裡的重要部分在於changeData()通過改變它的name屬性改變了vData,儘管那個屬性會被改變,但是vData的引用是沒有變的。

假設一些 event 導致了changeData()被執行,變更檢測會怎麼執行呢?首先,vData.name 被改變了,然後它被傳入了<v-card>. <v-card>的變更檢測器開始檢測傳進來的vData是否未發生改變,答案是 yes,沒有改變。因為這個物件的引用沒有被改變。然而,它的 name 屬性被改變了,所以即便如此 Angular 仍會為那個物件(vData)執行變更檢測。

由於在 JavaScript 中物件預設是易變的(multable)(除了基本資料型別),每次當 event 發生的時候 Angular 必須保守地對於每個元件都跑一次變更檢測,

這時候, immutable data structures (不可變資料結構)可以派上用場了。

不可變物件(Immutable Objects)

不可變物件保證了這個物件是不能改變的。這意味著如果我們使用著不可變物件,同時試圖改變這個物件,那我們總是會得到一個新的引用,因為原來那個物件是不可變的。

減少檢測的次數

當輸入屬性沒有發生改變的時候,Angular 會跳過整個子樹的變更檢測。我們剛剛說了,”改變”意味著 “新的引用” (“change” means “new reference”)。如果我們在Angular App使用不可變物件,我們只需要做的就是告訴 Angular,如果輸入沒有發生改變,這個元件就可以跳過變更檢測。

我們通過研究<v-card>來看看它是怎麼工作的:

@Component({
  template: `
    <h2>{{vData.name}}</h2>
    <span>{{vData.email}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class VCardCmp {
  @Input() vData;
}複製程式碼

可以看到,VCardCmp只取決於輸入屬性。很好。如果它的所有輸入屬性都沒有變化的話,我們可以讓Angular跳過對於這顆子樹的變更檢測了,只要設定變更檢測策略為OnPush就可以了

@Component({
  template: `
    <h2>{{vData.name}}</h2>
    <span>{{vData.email}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class VCardCmp {
  @Input() vData;
}複製程式碼

That`s it! 你可以試著想象一棵更大的元件樹,只要我們使用了不可變物件,就可以跳過整棵子樹(的變更檢測)。

Jurgen Van Moere 寫了一篇 深度文章 ,關於他如何使用Angular 和 Immutablejs 寫了一個賊快的掃雷。請確保你也看了這一篇文章。

Observables

正如前文所說,當變更發生的時候 Observables 也給了我們一個保證。不像不可變物件,當變更發生的時候。Observables 不提供給我們新的引用。 取而代之的是,他們觸發事件(fire events),並且讓我們註冊監聽(subscribe)這些事件來對這些事件做出反應。

所以,如果我們使用Observables 並且 想要使用OnPush來跳過對子樹的變更檢測,但是這些物件的引用永遠不會改變,我們該怎麼辦呢?事實上,對於某些事件,Angular 有一個非常聰明的方法來使得元件樹上的這條路被檢測,而這個方法正是我們需要的。

為了理解這是什麼意思,我們看看下面這個元件:

@Component({
  template: `{{counter}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class CartBadgeCmp {

  @Input() addItemStream:Observable<any>;
  counter = 0;

  ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // 程式狀態改變
    })
  }
}複製程式碼

假設我們正在寫一個有購物車的網上商城。使用者將商品放入購物車時,我們希望有一個小計時器出現在我們的頁面上,這樣一來使用者可以知道購物車中的商品數目。

CartBadgeCmp就是做這樣一件事。它有一個counter作為輸入屬性,這個counter是一個事件流,它會在某個商品被加入購物車時被 fired。

我不會在這篇文章中對 Observables 的工作原理進行太多細節描述,你可以先看看這篇文章 Taking advantage of Observables in Angular

除此之外,我們設定了變更檢測策略為OnPush,因而變更檢測不會總是執行,而是僅當元件的輸入屬性發生改變時執行。

然而,如前文提到的,addItemStreem永遠也不會發生改變,所以變更檢測永遠不會在這個元件的子樹中發生。這是不對的,因為元件在生命週期鉤子 ngOnInit 中 subscribe 了這個事件流,並對counter遞增了。這是一個程式狀態的改變,並且我們希望它反應到我們的檢視中,對吧?

下圖是我們的變更檢測樹可能的樣子(我們已經將所有元件設定為OnPush)。當事件發生的時候,沒有變更檢測會執行。

那麼對於這個變更,我們要如何通知Angular呢?我們要如何告知Angular ,即使整棵樹都被設定成了OnPush,對於這個元件變更檢測依然需要執行呢?

別擔心,Angular 幫我們考慮了這一點。如前所述,變更檢測總是自頂向下執行。那麼我們需要的只是一個探測(detect)自根元件變更發生的那個元件的整條路徑而已。Angular無法知道是哪一條,但我們知道。

我們可以通過依賴注入使用一個元件的ChangeDetectorRef,通過它你可以使用一個叫做markForCheck()的API。這個做的事情正好是我們需要的! 它標記了從當前元件到根元件的整條路徑,當下一次變更檢測發生的時候,就會檢測到他們。
我們把它注入到我們的元件:

constructor(private cd: ChangeDetectorRef) {}複製程式碼

然後告訴Angular,標記整條路徑,從這個元件到根元件都需要被checked:

ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // application state changed
      this.cd.markForCheck(); // marks path
    })
  }
}複製程式碼

Boom, that`s it! 下圖就是當 observable 事件發生之後元件樹的樣子:

現在,當變更檢測執行的時候,

是不是很酷?一旦變更檢測結束,它就會恢復為整棵樹恢復OnPush狀態。

更多

事實上,還有很多API沒有被這篇文章提及,就交給你自己去深入研究啦。

在這個repository中還有一些demos可以玩玩,你可以在你自己的電腦跑一下。

希望這篇文章會讓你對immutable data structures和Observable如何讓我們的Angular 應用執行的更加快 有一個更加清晰的認識。

譯者注 (高能預警)

為什麼高能呢,因為你看到下面的 reference 可能會立馬關掉網頁了。It`s ok,你先點一個贊再走。
對於真的想理解 Angular 變更檢測(並且有著良好的英語閱讀和聽力基礎)的同學繼續往下看。

譯者在翻譯完這篇以後將文中的部分連結也看了一下,對整個 Angular 的整個變更檢測機制有了更加深入的理解,在此也給大家推薦一下。(可能需要翻牆)

  • Change Detection Reinvented Victor Savkin 這個視訊講的內容與本文差不多,相互對照著看理解更加深入。
  • Change And Its Detection In JavaScript Frameworks 這篇文章講了不同JS框架之間實現變更檢測的不同,只是一個概覽,也可以看看。值得注意的是這裡對於 Angular 講的是 AngularJs ,即 Angular1.x 的版本,Angular versition >= 2 是不一樣的,具體的不同請了解 zooms。(看下面)
  • Understanding zones 介紹了zone.js
  • Zones in Angular 介紹了 Angular 是如何使用 zone.js (通過擴充套件zone.js) 來實現變更檢測的。
    另外我發現一個有趣的現象,外國人寫 blog 喜歡到處引用別人的 blog 或者演講,你看這一篇引用了另一篇,另一篇又引用了別的…這形成了一棵樹,那對於這棵樹要進行 BFS 還是 DFS 呢?(即看到引用裡面先去看引用的文章,還是先把當前文章看完再去看文中引用的)我的建議是如果文中提到這篇文章是基於那篇引用文的,那當然必須先看了,否則的話還是先把當前文章看完。

翻譯不易T T,若有幫助請給予鼓勵。

相關文章