【譯】淺談Angular中的變化檢測

RichardoLu發表於2019-03-27

關於變化檢測機制,zones和ExpressionChangedAfterItHasBeenCheckedError錯誤的綜述

【譯】淺談Angular中的變化檢測


如果您更喜歡看視訊的話,請點選這裡

本文刪去了譯者認為與主題無關的內容,您可以點選檢視原文


初次相遇

下面是一個簡單的Angular元件,它在應用中發生變化檢測時將時間渲染到螢幕上。時間戳的精度是毫秒。點選按鈕觸發變化監測:

【譯】淺談Angular中的變化檢測

元件的程式碼實現如下:

@Component({
    selector: 'my-app',
    template: `
        <h3>
            Change detection is triggered at:
            <span [textContent]="time | date:'hh:mm:ss:SSS'"></span>
        </h3>
        <button (click)="0">Trigger Change Detection</button>
    `
})
export class AppComponent {
    get time() {
        return Date.now();
    }
}
複製程式碼

如你所見,這個元件非常基礎。元件類中有個名為time的getter,返回當前的時間戳。我將它繫結到了HTML中的span上。

Angular不允許空的表示式,所以我在click的回撥中放了一個0

這裡體驗這個元件。當Angular執行變化檢測時,它將time屬性的值傳給date管道,並使用返回的結果更新DOM。看起來似乎沒有什麼不對的。然而,當我開啟控制檯的時候卻看到了ExpressionChangedAfterItHasBeenCheckedError 錯誤。

ExpressionChangedAfterItHasBeenCheckedError

這令人吃驚。一般來說,這個錯誤出現在複雜得多的應用中。那我們是怎麼在這麼簡單的一個功能中觸發了這個錯誤?別擔心,我們現在來調查一下。

先看一下報錯的資訊。

表示式在被檢查之後發生了變化。之前的值:“textContent: 1542375826274”。 現在的值:“textContent: 1542375826275”。

它告訴我們,表示式產生的被繫結到textContent上的值改變了。可以看到毫秒的數值確實不一樣了。所以Angular將time | data: 'hh:mm:ss:SSS'表示式計算了兩次並且將結果進行了比較。Angular檢測到了兩個值不同,這就是報錯的原因。

但是為什麼Angular要對值進行比較?它在什麼時候做了這件事?

這些問題激發了我的好奇心,並最終使我深入到變化檢測的內部原理。因為為了找到這些問題的答案,我必須開始除錯。我不停地除錯,再除錯。好吧。。。我想我大概花了一兩個月 ?。我們先從第二個問題開始,這個錯誤在什麼時候被丟擲的。但我要先分享一些我的發現,這些發現可以幫助我們理解上面的錯誤。

元件檢視和資料繫結

在Angular的變化檢測中有兩個主要的構成元素:

  • 一個元件的檢視
  • 相關的資料繫結

Angular中的每個元件都有一個由HTML元素構成的模板。Angular建立了DOM節點以便將模板中的內容渲染到螢幕上,它需要有一個地方儲存這些DOM節點的引用。為此,在Angular內部有一個稱為檢視的資料結構。它也被用來儲存元件例項的引用以及繫結表示式之前的值。元件和檢視之間是一對一的關係。下面是圖示:

元件和檢視

編譯器在分析模板時,它會識別可能需要在變化檢測期間被更新的DOM元素的屬性。編譯器為每個這樣的屬性建立一個繫結。資料繫結定義了需要更新的屬性名稱和Angular用於獲取新值的表示式。

在我們的例子中,time屬性被用在textContent屬性的表示式中。所以Angular建立了繫結並將它關聯到span元素:

資料繫結

在實際的實現中,繫結不是一個有著所有必須資訊的單獨的物件。一個viewDefinition為模板元素和需要更新的屬性定義了繫結。用於繫結的表示式被置於updateRenderer函式中。

檢查元件檢視

如你所知,在Angular中,每個元件都會執行變化檢測。我們現在已經知道元件在內部被表達為檢視,因此我們可以說每個檢視都會執行變化檢測。

當Angular檢查一個檢視時,它只會執行所有編譯器為檢視生成的繫結。它對錶達式求值然後將它們的結果存在檢視的oldValues陣列中。這就是髒檢查名字的由來。如果它檢測到了變化,它就會更新與繫結相關的DOM屬性。並且它需要將這個新的值放入檢視的oldValues陣列。之後你就得到了一個更新過的UI。一旦Angular完成了當前元件的檢測,它會遞迴地去檢查子元件。

在我們的應用中,只有一個繫結,連線到App元件中的span元素的textContent屬性。所以在變化檢測期間,Angular讀取了元件類的time屬性的值,並將其應用到date管道上,然後將返回值與儲存在檢視中的舊值相比較。如果它檢測到不同,Angular會更新spantextContent屬性和oldValues陣列。

但是我們的錯誤是從哪裡跑出來的?

在開發模式下,每一次變化檢測迴圈之後,Angular同步地執行另一次檢查以確保表示式生成的值與之前在變化檢測中的相同。這個檢查不是原始變化監測迴圈的一部分。它在整個元件樹的變化檢查結束之後執行完全相同的步驟。然而,在這一次檢查中,當Angular檢測到了變化時不會更新DOM。相反,它會丟擲ExpressionChangedAfterItHasBeenCheckedError 錯誤。

Detecg changes

為什麼

我們現在知道了這個錯誤在什麼時候會被丟擲。**但是為什麼Angular需要做這次檢查?**好吧,想象一下,元件類中的某些屬性在變化檢測執行期間已經被更新了。而結果是,表示式產生了與我們渲染到UI中的值不一致的新值。那麼Angular做了什麼?它當然可以再執行一次變化檢測以同步應用狀態和UI。但假如在這個過程中,某些屬性再次被更新了呢?看到了嗎?Angular可能會在無限的變化檢測迴圈中崩潰。事實上,這在AngularJS中經常發生。

為了避免這種情況,Angular強制實行了被稱為單項資料流的模式。並且在變化檢測之後執行的檢查和由此產生的ExpressionChangedAfterItHasBeenCheckedError 錯誤是強制的機制。一旦Angular處理完了當前元件的繫結,你就不能再更新繫結表示式中使用的屬性。

修復錯誤

為了阻止這個錯誤,我們需要確保表示式在變化檢測期間與隨後的檢查中返回的值是相同的。在我們的例子中,我們可以通過將求值部分移除timegetter來做到這一點:

export class AppComponent {
    _time;
    get time() {  return this._time; }

    constructor() {
        this._time = Date.now();
    }
}
複製程式碼

但這樣做的話,getter time返回的值始終都是一樣的。我們仍然需要更新這個值。我們在之前瞭解到產生錯誤的檢查在變化檢測迴圈之後立即同步執行。那如果我們非同步地去更新它,就可以避免這個錯誤。所以我們可以使用setInterval函式每隔1ms就更新該值。

export class AppComponent {
    _time;
    get time() {  return this._time; }

    constructor() {
        this._time = Date.now();
        
        setInterval(() => {
            this._time = Date.now();
        }, 1);
    }
}
複製程式碼

這個方法解決了我們最初的問題。但不幸的是,它帶來了新的問題。所有的計時器,像setInterval,都會觸發Angular的變化檢測。這意味著使用了這種方法,我們會陷入無窮無盡的變化檢測迴圈中。**為了避免這個問題,我們需要一種不會觸發變化檢測的方式來執行setInterval。**我們很幸運,確實有這樣的一種方式。首先我們需要理解為什麼在Angular中setInterval會觸發變化檢測,才能知道怎麼去達到我們的目的。

zones提供的自動變化檢測

與React相反,Angular中的變化檢測可以完全自動地由瀏覽器中的任何一個非同步事件觸發。通過使用zone.js這個庫,這種觸發變化監測的方式得以實現,同時引入了zones的概念。與一般的看法相反,zones不是Angular變化檢測機制的一部分。事實上,Angular的執行並不需要它們。這個庫僅僅提供了一種攔截非同步事件的方法,比如setInterval,並且通知Angular發生了非同步事件。Angular基於這個通知來執行變化檢測。

有趣的是,在一個網頁中,你可以有很多不同的zones。其中一個是NgZone。它在Angular啟動的時候被建立。Angular應用就執行在這個zone中。只有在zone中發生的非同步事件才會通知Angular。

zones

但是,zone.js也提供了一個API,以便在Angular zone之外的zone中執行某些程式碼。其他zone中發生非同步事件時,Angular並不會收到通知。沒有通知就意味著沒有變化檢測。這個方法名叫runOutsideAngular並由NgZone服務實現。

下面是注入NgZone並且在Angular zone之外執行setInterval的程式碼:

export class AppComponent {
    _time;
    get time() {
        return this._time;
    }

    constructor(zone: NgZone) {
        this._time = Date.now();

        zone.runOutsideAngular(() => {
            setInterval(() => {
                this._time = Date.now()
            }, 1);
        });
    }
}
複製程式碼

現在我們不停地更新時間,但是這些操作是非同步的,並且在Angular zone之外。這保證了變化檢測期間和接下來的檢查中getter time返回相同的值。此外,Angular在下一次變化檢測中讀取到的time的值將會被更新並且變化會被反映在螢幕上。

使用NgZone來在Angular之外執行某些程式碼以避免觸發變化檢測是一種常用的優化技巧。

除錯

你也許想知道是否有辦法看到Angular中的檢視和繫結。事實上,確實有。在@angular/core模組中有一個名為checkAndUpdateView的函式。它遍歷元件樹中的檢視(元件)並對每個檢視執行檢測。當我遇到與變化檢測相關的問題是,我總是從這個函式開始除錯。

自己嘗試使用這個demo去進行除錯。開啟控制檯,找到那個函式並打上斷點。點選按鈕觸發變化監測。審查view變數。下面的動圖是我的演示。

除錯

第一個view會成為宿主檢視。它是Angular建立的一個根元件,用來託管app元件。我們需要恢復執行,以獲得它的子檢視,也就是我們AppComponent的檢視。去探索它吧。component屬性存放了App元件的例項。node屬性存放了DOM節點的引用,這些DOM節點是為App元件的模板中的元素建立的。oldValues陣列儲存了繫結表示式的結果。


操作的順序

我們剛剛瞭解到,因為單項資料流的限制,在元件被檢查後,你不能在變化檢測期間改變元件的某些屬性。絕大多數時候,當Angular對子元件進行變化檢測時,資料的更新通過共享服務或者同步事件進行廣播。但是也有可能直接將一個父元件注入到一個子元件中,然後通過生命週期鉤子更新父元件的狀態。下面的程式碼可以表明這一點:

@Component({
    selector: 'my-app',
    template: `
        <div [textContent]="text"></div>
        <child-comp></child-comp>
    `
})
export class AppComponent {
    text = 'Original text in parent component';
}

@Component({
    selector: 'child-comp',
    template: `<span>I am child component</span>`
})
export class ChildComponent {
    constructor(private parent: AppComponent) {}

    ngAfterViewChecked() {
        this.parent.text = 'Updated text in parent component';
}
複製程式碼

你可以在這裡進行檢驗。我們簡單地定義了兩個元件的層級。父元件宣告瞭text屬性,並繫結到檢視中。子元件在構造器中注入了父元件並在ngAfterViewChecked生命週期鉤子中更新了它的屬性。你能猜到我們將在控制檯中看到什麼嗎? ?

沒錯,熟悉的ExpressionChangedAfterItWasChecked錯誤。這是因為當Angular在子元件中呼叫ngAfterViewChecked生命週期鉤子時,父級App元件的繫結已經被檢查過了。但我們在檢查之後更新了父元件中的text屬性。

不過這裡有一個有趣的地方。假如我換一個鉤子呢?也就是說,在ngOnInit中去做這件事。你覺得我們還會看到這個錯誤嗎?

export class ChildComponent {
    constructor(private parent: AppComponent) {}

    ngOnInit() {
        this.parent.text = 'Updated text in parent component';
    }
}
複製程式碼

這一次不會再報錯了請檢視demo。事實上,我們可以把這段程式碼放到任何其他的鉤子中(不包括AfterViewInitAfterViewChecked),就不會在控制檯中看到這個錯誤。那麼這裡發生了什麼?為什麼ngAfterViewChecked鉤子如此特殊?

為了理解這個行為,我們需要知道Angular在變化檢測期間執行了什麼操作並且是以什麼順序執行的。我們已經知道該去哪裡找到答案:我之前展示過的checkAndUpdateView函式。下面是該函式體裡面的一部分程式碼:

function checkAndUpdateView(view, ...) {
    ...       
    // 更新子檢視(元件)和指令中的繫結,
    // 如果有需要的話,呼叫NgOnInit, NgDoCheck and ngOnChanges鉤子
    Services.updateDirectives(view, CheckType.CheckAndUpdate);
    
    // DOM更新,為當前檢視(元件)執行渲染
    Services.updateRenderer(view, CheckType.CheckAndUpdate);
    
    // 在子檢視(元件)中執行變化檢測
    execComponentViewsAction(view, ViewAction.CheckAndUpdate);
    
    // 呼叫AfterViewChecked和AfterViewInit鉤子
    callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…);
    ...
}
複製程式碼

如你所見,Angular會在變化檢測期間觸發生命週期鉤子。**有趣的是當Angular在處理繫結時,一些鉤子在渲染之前被呼叫,一些鉤子在渲染之後被呼叫。**下面這張圖演示了在Angular為父元件執行變化檢測期間發生了什麼:

發生了什麼

讓我們一步步地來理清它。首先,它為元件更新輸入繫結。之後它又呼叫了元件上的OnInitDoCheckOnchanges鉤子。這一步是有意義的,因為它剛剛更新了輸入繫結所以Angular需要通知子元件輸入繫結已經被初始化了。然後Angular為當前元件執行渲染。在這之後,它為子元件執行變化檢測。這意味著它會在子檢視中重複這些操作。最後,它呼叫了子元件上的AfterViewCheckedAfterViewInit鉤子讓其知道已經被檢查了。

在這裡我們可以注意到Angular在處理了父元件的繫結之後之後呼叫子元件的AfterViewChecked生命週期鉤子。另一方面,OnInit鉤子在繫結被處理之前呼叫。所以即使在OnInit中改變了text的值,在隨後的檢查中它仍然是相同的。這就解釋了在ngOnInit中不會有錯誤的奇怪行為。謎底揭曉?。

總結

現在我們總結一下剛剛學到的東西。Angular中的所有元件在內部都被表示為一種叫檢視的資料結構。Angular的編譯器解析模板並建立繫結。每一個繫結定義了一個要更新的DOM元素的屬性和用於求值的表示式。檢視中的oldValues屬性儲存了在變化檢測中被用於比較的舊值。在變化檢測期間,Angular遍歷所有繫結,對錶達式求值,將它們與舊值比較,如果有必要的話就更新DOM。每個變化檢測迴圈之後,Angular執行一次檢查以確保組建的狀態與使用者介面同步。這次檢查是同步執行的並且可能會丟擲ExpressionChangedAfterItWasChecked錯誤。


推薦

這5篇文章將會使你成為Angular變化檢測上的專家

如果你正在找尋關於Angular中變化監測的更深入的解釋,這篇文章會是一個好的起點。它收集了一些有關變化檢測的深度好文,例如zones,DOM更新機制,單項資料流和ExpressionChangedAfterItWasChecked錯誤。

相關文章