內容來自於 Max Koretskyi aka Wizard的《A gentle introduction into change detection in Angular》
讓咱們從一個簡單的Angular組件開始。他表現應用程序的變化檢測。這個時間戳的精度爲毫秒。點擊triggers按鈕觸發檢測:node
@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運行變化檢測時,它獲取time屬性的值,經過日期管道傳遞它,並使用結果更新DOM。這一切都很正常,但當我打開控制檯的時候,我看到了一個錯誤:ExpressionChangedAfterItHasBeenCheckedError
。typescript
事實上,這讓咱們感到很是驚訝。一般這個錯誤出如今更加複雜的程序上。但爲何一個如此簡單的功能會致使這個錯誤呢?別擔憂,咱們如今就來查看他的緣由。
讓咱們先從錯誤消息開始:express
Expression has changed after it was checked. Previous value: 「textContent: 1542375826274」. Current value: 「textContent: 1542375826275」.
它告訴咱們,textContent
綁定的值是不一樣的。的確,毫秒不相同。由於Angular經過表達式time | date:'hh:mm:ss:SSS'
計算了兩次,並比較告終果。它檢測到了兩次值的差別,這就是致使錯誤的緣由。編程
但Angular爲何要這樣作?或者它何時作的?
在咱們瞭解這些問題的答案以前,咱們還須要瞭解另一些東西。
組件視圖和綁定
Angular的變化檢測主要有兩個部分:數組
每個Angular的組件都有一個HTML元素。當Angular建立DOM節點並將內容渲染到屏幕上,它須要一個地方來儲存DOM節點的引用。爲了實現這一目標,Angular內部有一個被稱爲View的數據結構。它還用於存儲對組件實例的引用和綁定表達式以前的值。而且視圖和組件之間的關係是一一對應的。下圖展現了該關係:瀏覽器
當編譯器分析模板時,它會辨識在變化檢測期間可能須要更新的DOM元素屬性。每個這樣的屬性,編譯器都會建立一個綁定。綁定定義要更新的屬性名和Angular用來獲取新值的表達式。數據結構
在咱們的例子當中,time
屬性用於textContent
的表達式中。因此,Angular會建立綁定來鏈接它和span
元素。app
實際上,綁定不是包含全部必要信息的單個對象。viewDefinition
定義模板元素和要更新的屬性的實際綁定。用於綁定的表達式在updateRenderer
方法中。
*檢查組件視圖
如你所知,Angular會對每個組件執行變化檢測。如今咱們知道每一個組件在Angular內部被稱爲視圖(view),咱們能夠說Angular對每一個視圖執行了變化檢測。異步
當Angular檢查視圖時,它只需運行編譯器爲視圖生成的全部綁定。它計算表達式並將它們的結果與視圖上舊值數組中存儲的值(oldValues
)進行比較。這就是髒檢查這個名字的由來。若是檢測到差別,它會更新與綁定相關的DOM屬性。它還須要將新值放入視圖的舊值數組中。就這樣。您如今有了更新的用戶界面。一旦完成當前組件的檢查,它將對子組件重複徹底相同的步驟。在咱們的應用程序中,在App
組件中span
元素的屬性textContent
只有一個綁定。因此在變化檢測期間,Angular會讀取組件time
屬性的值,再使用date
管道,並將它與視圖中存儲的先前值進行比較。若是檢測到不一樣,Angular會更新span
舊值(oldValues
)數組中的textContent
屬性.ide
可是錯誤又從哪裏出來的呢?
在開發模式下,每一個變化檢測週期以後,Angular會同步運行另一個檢查,已確保表達式產生的值與以前變化檢測運行期間的值相同。該檢查不是原始檢查的一部分,它在對整個組件樹的檢查完成後運行,並執行徹底相同的步驟。然而,當這一次變化檢測期間,若是檢測到不一樣那個的值,Angular不會去更新DOM,相反的,它會直接拋出錯誤ExpressionChangedAfterItHasBeenCheckedError
。
可是Angular爲何要這樣作?
如今咱們知道何時拋出錯誤了。可是爲何Angular須要這個檢測。假設在變化檢測運行期間,又有一些組件的一些屬性被更新。此時,表達式產生的新值與用戶界面中呈現的值不同。這個時候Angular應該怎麼作?它固然也能夠另外再運行一個變化檢測週期來使應用程序狀態與用戶界面同步。但若是在這期間,又有一些屬性被更新了呢?看到問題了嗎?實際上Angular可能會在變化檢測的無限循環中結束。這種狀況在AngularJS中常常發生。
爲了不這種事情,Angular強制讓數據單向流動。這種在變動檢測和結果表達式變動後運行的檢查是強制機制。一旦Angular處理了當前組件的綁定,就不能再更新綁定表達式中使用的組件屬性。
修復這個錯誤
爲了防止這種錯誤的發生,咱們須要確保在改變檢測週期表達式返回的值和檢查值相同。在咱們的例子當中,咱們能夠將變化值從time
的getter中移除,就像這樣:
export class AppComponent { _time; get time() { return this._time; } constructor() { this._time = Date.now(); } }
然而,在實際中,time
的值永遠都不會變化。咱們以前瞭解到,產生錯誤的檢查會在變動檢測週期以後同步運行。所以,若是咱們異步的去更新它,就不會出現這種錯誤。因此咱們爲了每一毫秒去更新一次time
的值,咱們使用setInterval
函數,就像這樣:
export class AppComponent { _time; get time() { return this._time; } constructor() { this._time = Date.now(); setInterval(() => { this._time = Date.now(); }, 1); } }
這個實現的確解決了咱們最初的問題。可是不幸的是,它又引入了一個新的問題。全部的定時時間,如setInterval
,都會觸發Angular的變化檢測機制。這意味着,若是經過這種方式來實現,咱們將會進入一個無線循環的變化檢測週期。爲了不觸發Angular的變化檢測,咱們須要一個不會觸發Angular變化檢測的setInterval
。幸運的是,咱們恰好有方法來實現這個需求。要了解如何作到這一點,咱們須要先了解爲何setInterval
會觸發Angular的變化檢測。
帶區域的自動變化檢測
和React不一樣,瀏覽器的任何異步事件均可以徹底自動觸發Angular的變化檢測。這是經過使用zone.js庫實現的,該庫引入了zone的概念。與廣泛的見解不一樣,zones並非Angular變化檢測的一部分。事實上,Angular變化檢測不須要zones也能夠正常工做。該庫只是提供一個異步事件的攔截方法(像setInterval
),並通知Angular。基於該通知,Angular啓動變化檢測。
有趣的是,一個網頁上能夠有許多個不一樣的zone。其中一個就是NgZone
。它是由Angular建立的。這是Angular運行的zone。並且Angular只獲取該區域內的事件通知。
可是,zone.js還提供了一個應用編程接口,能夠在Angula 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以外執行的異步操做。這保證了在變化檢測和隨後的檢查期間,time
返回相同的值。當Angular在下一個變化檢測週期讀取time
值時,該值將被更新,而且變化將被反映在屏幕上。
使用NgZone在Angular以外運行一些代碼以免觸發變化檢測是一種常見的優化技術。
Debugging
你可能想知道,是有有什麼方法能夠查看view和Angular的內部綁定。事實上,@angular/core
module中的checkAndUpdateView
方法就能作到。它在組件樹的每一個視圖(組件)上運行,並對每個view執行檢查。當我在變動檢測方面遇到問題時,我老是開始調試這個函數。
嘗試去調試它。找到這個函數並在那裏放置一個斷點。點擊按鈕觸變化檢查,檢查view變量。
第一個視圖將是宿主視圖。這是角力建立的一個根組件,用來託管咱們的應用程序組件。咱們須要不斷執行以到達它的子視圖,這將是爲咱們的應用程序組件建立的視圖。探索它!這個組件的屬性包含了app 組件實例的引用。nodes屬性包含對爲app組件模板內的元素建立的DOM節點的引用。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
時,Angular已經完成了對父組件的檢查。可是咱們在變化檢測以後又更新了父組件的屬性。
有趣的是,若是咱們如今換一個生命週期鉤子執行這個操做呢?好比說ngOnInit
。你認爲咱們還會看到這個錯誤嗎?
export class ChildComponent { constructor(private parent: AppComponent) {} ngOnInit() { this.parent.text = 'Updated text in parent component'; } }
很好,這一次錯誤並無在。事實上,咱們能夠把代碼放在任何其餘鉤子中(好比AfterViewInit
和AfterViewChecked
),而且咱們不會再控制檯看到錯誤。但這是怎麼回事呢?爲何ngAfterViewChecked
這麼特殊呢?
爲了理解這種行爲,咱們須要知道Angular在變化檢測期間執行什麼操做以及它們的順序。並且,咱們已經知道在哪裏能夠找到他們:我以前給大家看過checkAndUpdateView
方法。下面是函數主體代碼的一部分。
function checkAndUpdateView(view, ...) { ... // update input bindings on child views (components) & directives, // call NgOnInit, NgDoCheck and ngOnChanges hooks if needed Services.updateDirectives(view, CheckType.CheckAndUpdate); // DOM updates, perform rendering for the current view (component) Services.updateRenderer(view, CheckType.CheckAndUpdate); // run change detection on child views (components) execComponentViewsAction(view, ViewAction.CheckAndUpdate); // call AfterViewChecked and AfterViewInit hooks callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…); ... }
就如你所看到的,Angular也會觸發生命週期鉤子來做爲變化檢測的一部分。有趣的是,當Angular處理綁定時,有些鉤子在渲染部分以前調用,有些鉤子在渲染部分以後調用。下面的圖表演示了當Angular運行父組件的更改檢測時會發生什麼:
讓咱們一步步的來。
OnInit
,Docheck
,Onchanges
鉤子。這頗有意義,由於它剛剛更新了輸入綁定,Angular須要通知子組件輸入綁定已經初始化。AfterViewChecked
和AfterViewInit
鉤子,讓它知道它已經被檢查過了。咱們注意到,Angular是在處理完父組件的綁定以後才調用子組件的AfterViewChecked
生命週期鉤子。另外一方面,在綁定被處理以前調用OnInit鉤子。所以,即便OnInit中的text發生了變化,在接下來的檢查中,它仍將保持不變。這解釋了爲何ngOnInit鉤子沒有出現錯誤這種看似奇怪的行爲。謎團解開了!
總結
最後,讓咱們來總結一下剛剛學到的東西。Angular內部的全部組件都以稱爲視圖的數據結構表示。Angular的編譯器解析模板並建立綁定。每一個綁定定義要更新的DOM元素的屬性和用於獲取值的表達式。變動檢測期間用於比較的先前值存儲在oldValues屬性的視圖中。在變動檢測期間,Angular在綁定上運行,評估表達式,將它們與之前的值進行比較,並在必要時更新DOM。在每一個變化檢測週期後,Angular會運行檢查,以確保組件狀態與用戶界面同步。此檢查是同步執行的,可能會引起expression ExpressionChangedAfterItWasChecked
錯誤。