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

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


若是您更喜歡看視頻的話,請點擊這裏javascript

本文刪去了譯者認爲與主題無關的內容,您能夠點擊查看原文java


初次相遇

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

組件的代碼實現以下:typescript

@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

這使人吃驚。通常來講,這個錯誤出如今複雜得多的應用中。那咱們是怎麼在這麼簡單的一個功能中觸發了這個錯誤?別擔憂,咱們如今來調查一下。app

先看一下報錯的信息。異步

表達式在被檢查以後發生了變化。以前的值:「textContent: 1542375826274」。 如今的值:「textContent: 1542375826275」。ide

它告訴咱們,表達式產生的被綁定到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錯誤。

相關文章
相關標籤/搜索