最近在stackoverflow上似乎每天都有一些關於angular報錯‘ExpressionChangedAfterItHasBeenCheckedError’的問題。發生這些問題通常是由於angular的開發者不懂angular變更檢測的工作原理,以及為什么這個檢測的報錯是有必要的。很多開發者甚至認為這是angular的bug。但其實不是的。這是一個用於防止模型數據和ui之間數組不一致的一個警告機制,以便不讓用戶在頁面上看到陳舊的或者錯誤的數據。
這篇文章解釋了該報錯和檢測機制的潛在原因,提供了幾種可能引發報錯的通用模式和可能的修復方案。文章最后解釋了為什么檢測機制是重要的。
一、相關變更檢測操作
一個運行中的angular應用是一個組件樹。在變更檢測期間,angular按照以下的特定順序檢查每一個組件:
1、更新所有子組件/指令綁定的特性
2、調用所有的自組件/指令的ngOnInit, OnChanges和ngDocCheck生命周期勾子
3、更新當前組件的DOM視圖
4、為一個子組件運行變更檢測
5、調用所有子組件/指令的ngAfterViewInit生命周期勾子
每一步操作后,angular會記住每一步用於操作的值,它們會被保存在控制器視圖的oldValues屬性中,在對所有的組件進行檢查后,angular進入下一個摘要周期(原詞是digest cycle,這里不知道怎么翻譯更准確),而不是執行上面列表中的操作,它將當前值與上一個摘要周期中保存的值進行比較,過程如下:
1、檢查傳遞給子組件的值與將用於更新這些組件屬性的值相同
2、檢查用於用於更新dom元素的值與將用於更新這些元素的值相同
3、對所有子組件執行相同的檢查
請注意該額外的檢查只會在開發模式下執行,我在文章最后一段已經解釋了原因。
讓我們看一個例子,假設你有一個父組件a和子組件b,a組件有一個‘name’變量和一個‘text’屬性,在該例子模版中使用引用名稱屬性的表達式:
template: '<span>{{name}}</span>'
並且該例子模版中還有一個b控制器,其通過輸入屬性綁定將text屬性傳遞給該組件。
@Component({
selector: 'a-comp',
template: `
{{name}}
`
})
export class AComponent {
name = 'I am A component';
text = 'A message for the child component`;
所以當angular運行變更檢測時會發生什么。變更檢測從檢查a組件開始。上述列表中第一步是更新綁定的屬性,以便得到text表達式值為“A message for the child component”,並將該值傳遞給b組件,最后保存到控制視圖的oldValues屬性中:
view.oldValues[0] = 'A message for the child component';
然后開始調用上述列表中第二步提到的生命周期勾子。
現在開始執行第三步操作以及計算出表單式{{name}}的值為“I am A component”,angular使用該值更新dom視圖並且將該值保存到oldValues:
view.oldValues[1] = 'I am A component';
接着angular執行下一步操作,並且為b控制器運行相同的檢查。一旦b控制器變更檢測完畢,當前的摘要循環即結束。
如果angular是在開發環境下運行的,將會執行第二次的摘要來執行上述列表中的步驟。現在想象一下,在angular把text的值“A message for the child component”傳遞給b組件並且保存到控制器視圖中的oldValues后,text變量值在a組件上被以某種方式更新為“updated text”,現在angular運行驗證摘要,然后第一步操作是檢查屬性text有無改變:
AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
顯而易見,text改變了,也就是在摘要周期中檢查傳遞給子組件的值與將用於更新這些組件屬性的值不相同了,所以拋出了ExpressionChangedAfterItHasBeenCheckedError錯誤。
同樣,如果name屬性值在被呈現和存儲后更新,也會得到相同的報錯:
AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
你現在可能有一個疑問,即這些值是如何改變的,讓我們往下看。
二、值改變的可能場景
通常導致變化的罪魁禍首往往是組件或者指令。讓我們來簡單快速的演示一下。我會使用盡可能最簡單的例子,但是之后就要開始展示真實的場景了。你應該知道,子組件和指令能夠注入其父組件,因此讓我們來將b組件注入到a組件中並且更新其綁定的屬性text,我們將會在ngOnInit生命周期勾子中更新該屬性值,因為ngOnInit是在綁定完成之后被觸發的,代碼如下:
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'updated text';
}
}
不出所料,我們得到了報錯:
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.
現在,我們用a組件表達式中的name屬性做同樣的操作:
ngOnInit() {
this.parent.name = 'updated name';
}
現在一切正常,怎么回事?
如果你仔細觀察上述變更檢測的執行順序就會發現ngOnInit生命周期勾子是在dom更新操作之前被觸發的,這就是為什么上面沒有得到報錯的原因。我們現在需要一個在dom更新操作完成之后的生命周期勾子,比如ngAfterViewInit就非常適合:
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngAfterViewInit() {
this.parent.name = 'updated name';
}
}
這次我們又得到了預料中的錯誤:
AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.
當然,真實場景的案例是錯綜復雜的,那些導致dom渲染的父組件屬性更新或操作往往是通過服務或者可觀察對象模式間接實現的,但是根本原理和原因是相同的。
現在,讓我們看一些真實場景下導致錯誤的共同模式。
1、共享服務
該應用設計為在父組件和子組件之間共享一個服務。子組件為服務設置一個值,繼而通過更新父組件的屬性實現反應,我稱這個父屬性的更新為間接的,因為與上面的例子不同,現在子組件更新父組件屬性不是非常顯著的。
2、同步事件廣播(Synchronous event broadcasting)
該應用設計為一個子組件發送一個事件,然后父組件監聽這個事件,該事件會導致父組件一些屬性值的更新。同時這些屬性被用於子組件的輸入綁定中。這也是一個間接的父組件屬性更新。
3、動態組件實例化(Dynamic component instantiation)ngAfterViewInit
這種模式不同於之前輸入綁定受到影響,而是會引起dom更新操作拋出錯誤。該應用設計為在父組件的ngAfterViewInit中動態的添加一個子組件,由於添加子組件需要dom修改,dom更新后繼而觸發ngAfterViewInit生命周期鈎子,拋出錯誤。
三、可能的修復解決方案
如果你看一下如下的錯誤描述
Expression has changed after it was checked. Previous value:…
不禁思考,它是在變化檢測勾子中創建的嗎?
通常,修復方案即通過正確的變更檢測機制來創建動態組件。例如上面章節中的最后一個例子,可以將動態組件的創建過程移到ngOnInit生命周期勾子中,盡管文檔說明ViewChild只能在ngAfterViewInit之后使用,但是在創建視圖的時候,它歸屬於子組件,因此可以更早使用。
如果你用谷歌搜索相關資料,你可能會發現針對這個報錯的兩個最大眾化的解決方案 —— 異步屬性更新和強制附加變化檢測周期,盡管我把這兩個解決方案放在了這里,同時還解釋了它們的工作原理,但是我不推薦使用這些方案,而是應該重新設計你的應用。這一點我會在文章末尾闡述原因。
1、異步屬性更新
這里需要注意的是,變更檢測和驗證摘要是同步執行的,這意味着如果我們異步更新屬性,當驗證循環正在運行中時,屬性值不會變化更新,應用也就不會拋出錯誤了,讓我們試一下:
export class BComponent {
name = 'I am B component';
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
setTimeout(() => {
this.parent.text = 'updated text';
});
}
ngAfterViewInit() {
setTimeout(() => {
this.parent.name = 'updated name';
});
}
}
的確沒有拋出錯誤,setTimeout函數調度了宏任務,然后將在下面的vm回調中執行,但是需要在當前同步代碼完成之后通過使用promise回調來執行。
Promise.resolve(null).then(() => this.parent.name = 'updated name');
替代宏任務promise.then來創建一個微任務,在當前同步代碼完成執行之后,微任務隊列被處理,因此在驗證步驟之后將發生對屬性的更新。了解更多angular中的宏任何和微任務,可以前往I reverse-engineered Zones (zone.js) and here is what I’ve found.(https://blog.angularindepth.com/i-reverse-engineered-zones-zone-js-and-here-is-what-ive-found-1f48dc87659b)
如果你在使用EventEmitter,你可以傳遞true參數選項來設置異步機制
new EventEmitter(true);
2、強制更新檢測
另一個可能的解決方案是在第一種方案和認證階段之間,為父級的a組件強制增加一個變更檢測周期,執行這一方案最好的地方是在ngAfterViewInit生命周期勾子中,因為它是在給所有子組件執行變更檢測時被觸發,所以有可能會更新父組件的屬性。
export class AppComponent {
name = 'I am A component';
text = 'A message for the child component';
constructor(private cd: ChangeDetectorRef) {
}
ngAfterViewInit() {
this.cd.detectChanges();
}
}
沒有報錯,似乎正確工作了,但是該解決方案存在一個問題,當觸發對父組件的更新檢測時,angular將運行對所有子組件的變更檢測,會存在父組件屬性更新的可能。
四、為什么需要驗證環
Angular從上到下強制執行所謂的單向數據流。 在父級更改處理完畢后,層級中較低的組件不允許更新父組件的屬性。 這確保了在第一個摘要循環之后整個組件樹是穩定的。 如果需要與依賴於這些屬性的使用者同步的屬性發生更改,則樹不穩定。 在我們的例子中,一個B子組件依賴於父文本屬性。 只要這些屬性發生更改,組件樹就會變得不穩定,直到將此更改傳遞給子組件B。 DOM也是如此。 它是組件上某些屬性的使用者,它在UI上呈現它們。 如果某些屬性未同步,用戶將在頁面上看到不正確的信息。
這個數據同步過程就是變化檢測過程中發生的情況 - 特別是我在開始時列出的兩個操作。 那么,如果在同步操作執行后更新子組件屬性的父屬性,會發生什么情況? 對,你留下了不穩定的樹,這種狀態的后果是不可能預測的。 大多數情況下,您最終會在頁面上向用戶顯示不正確的信息。 這將很難調試。
那么為什么不運行變化檢測直到組件樹穩定? 答案很簡單 - 因為它可能永遠不會穩定下來並永遠運行。 如果一個子組件更新父組件上的一個屬性,作為對該屬性更改的反應,則會發生無限循環。 當然,正如我之前所說的,使用直接更新或依賴關系來發現這種模式是微不足道的,但在實際應用程序中,更新和依賴關系通常是間接的。
有趣的是,AngularJS沒有單向數據流,因此它試圖穩定樹。 但它通常會導致臭名昭着的10 $ digest()迭代達成。中止! 錯誤。 繼續,谷歌這個錯誤,你會驚訝於這個錯誤產生的問題的數量。
最后一個問題是為什么只在開發模式下運行它? 我想這是因為一個不穩定的模型並不像框架產生的運行時錯誤那樣嚴重。 畢竟它可能穩定在下一個摘要運行。 但是,開發應用程序時可能出現的錯誤比在客戶端上調試正在運行的應用程序更好。
譯自:Everything you need to know about the `ExpressionChangedAfterItHasBeenCheckedError` error
https://blog.angularindepth.com/everything-you-need-to-know-about-the-expressionchangedafterithasbeencheckederror-error-e3fd9ce7dbb4