原文連結:Everything you need to know about the
ExpressionChangedAfterItHasBeenCheckedError
error
關於
ExpressionChangedAfterItHasBeenCheckedError
,還可以參考這篇文章,並且文中有youtube
視訊講解:Angular Debugging "Expression has changed after it was checked": Simple Explanation (and Fix)
最近 stackoverflow 上幾乎每天都有人提到 Angular 丟擲的一個錯誤:ExpressionChangedAfterItHasBeenCheckedError
,通常提出這個問題的 Angular 開發者都不理解變更檢測(change detection)的原理,不理解為何產生這個錯誤的資料更新檢查是必須的,甚至很多開發者認為這是 Angular 框架的一個 bug(譯者注:Angular 提供變更檢測功能,包括自動觸發和手動觸發,自動觸發是預設的,手動觸發是在使用 ChangeDetectionStrategy.OnPush
關閉自動觸發的情況下生效。如何手動觸發,參考 Triggering change detection manually in Angular)。當然不是了!其實這是 Angular 的警告機制,防止由於模型資料(model data)與檢視 UI 不一致,導致頁面上存在錯誤或過時的資料展示給使用者。
本文將解釋引起這個錯誤的內在原因,檢測機制的內部原理,提供導致這個錯誤的共同行為,並給出修復這個錯誤的解決方案。最後章節解釋為什麼資料更新檢查是如此重要。
It seems that the more links to the sources I put in the article the less likely people are to recommend it ?. That’s why there will be no reference to the sources in this article.(譯者注:這是作者的吐槽,不翻譯)
相關變更檢測行為
一個執行的 Angular 程式其實是一個元件樹,在變更檢測期間,Angular 會按照以下順序檢查每一個元件(譯者注:這個列表稱為列表 1):
- 更新所有子元件/指令的繫結屬性
- 呼叫所有子元件/指令的三個生命週期鉤子:
ngOnInit
,OnChanges
,ngDoCheck
- 更新當前元件的 DOM
- 為子元件執行變更檢測(譯者注:在子元件上重複上面三個步驟,依次遞迴下去)
- 為所有子元件/指令呼叫當前元件的
ngAfterViewInit
生命週期鉤子
在變更檢測期間還會有其他操作,可以參考我寫的文章:《Everything you need to know about change detection in Angular》 。
在每一次操作後,Angular 會記下執行當前操作所需要的值,並存放在元件檢視的 oldValues
屬性裡(譯者注:Angular Compiler 會把每一個元件編譯為對應的 view class,即元件檢視類)。在所有元件的檢查更新操作完成後,Angular 並不是馬上接著執行上面列表中的操作,而是會開始下一次 digest cycle,即 Angular 會把來自上一次 digest cycle 的值與當前值比較(譯者注:這個列表稱為列表 2):
- 檢查已經傳給子元件用來更新其屬性的值,是否與當前將要傳入的值相同
- 檢查已經傳給當前元件用來更新 DOM 值,是否與當前將要傳入的值相同
- 針對每一個子元件執行相同的檢查(譯者注:就是如果子元件還有子元件,子元件會繼續執行上面兩步的操作,依次遞迴下去。)
記住這個檢查只在開發環境下執行,我會在後文解釋原因。
讓我們一起看一個簡單示例,假設你有一個父元件 A
和一個子元件 B
,而 A
元件有 name
和 text
屬性,在 A
元件模板裡使用 name
屬性的模板表示式:
template: '<span>{{name}}</span>'
複製程式碼
同時,還有一個 B
子元件,並將 A
父元件的 text
屬性以輸入屬性繫結方式傳給 B
子元件:
@Component({
selector: 'a-comp',
template: `
<span>{{name}}</span>
<b-comp [text]="text"></b-comp>
`
})
export class AComponent {
name = 'I am A component';
text = 'A message for the child component`;
複製程式碼
那麼當 Angular 執行變更檢測的時候會發生什麼呢?首先是從檢查父元件 A
開始,根據上面列表 1 列出的行為,第一步是更新所有子元件/指令的繫結屬性(binding property)
,所以 Angular 會計算 text
表示式的值為 A message for the child component
,並將值向下傳給子元件 B
,同時,Angular 還會在當前元件檢視中儲存這個值:
view.oldValues[0] = 'A message for the child component';
複製程式碼
第二步是執行上面列表 1 列出的執行幾個生命週期鉤子。(譯者注:即呼叫子元件 B
的 ngOnInit
,OnChanges
,ngDoCheck
這三個生命週期鉤子。)
第三步是計算模板表示式 {{name}}
的值為 I am A component
,然後更新當前元件 A
的 DOM,同時,Angular 還會在當前元件檢視中儲存這個值:
view.oldValues[1] = 'I am A component';
複製程式碼
第四步是為子元件 B
執行以上第一步到第三步的相同操作,一旦 B
元件檢查完畢,那本次 digest loop 結束。(譯者注:我們知道 Angular 程式是由元件樹構成的,當前父元件 A
元件做了第一二三步,完事後子元件 B
同樣會去做第一二三步,如果 B
元件還有子元件 C
,同樣 C
也會做第一二三步,一直遞迴下去,直到當前樹枝的最末端,即最後一個元件沒有子元件為止。這一次過程稱為 digest loop。)
如果處於開發者模式,Angular 還會執行上面列表 2 列出的 digest cycle 迴圈核查。現在假設當 A
元件已經把 text
屬性值向下傳入給 B
元件並儲存該值後,這時 text
值突變為 updated text
,這樣在 Angular 執行 digest cycle 迴圈核查時,會執行列表 2 中第一步操作,即檢查當前digest cycle 的 text 屬性值與上一次時的 text 屬性值是否發生變化:
AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
複製程式碼
結果是發生變化,這時 Angular 會丟擲 ExpressionChangedAfterItHasBeenCheckedError
錯誤。
列表 1 中第三步操作也同樣會執行 digest cycle 迴圈檢查,如果 name
屬性已經在 DOM 中被渲染,並且在元件檢視中已經被儲存了,那這時 name
屬性值突變同樣會有同樣錯誤:
AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
複製程式碼
你可能會問上面提到的 text
或 name
屬性值發生突變,這會發生麼?讓我們一起往下看。
屬性值突變的原因
屬性值突變的罪魁禍首是子元件或指令,一起看一個簡單證明示例吧。我會先使用最簡單的例子,然後舉個更貼近現實的例子。你可能知道子元件或指令可以注入它們的父元件,假設子元件 B
注入它的父元件 A
,然後更新繫結屬性 text
。我們在子元件 B
的 ngOnInit
生命週期鉤子中更新父元件 A
的屬性,這是因為 ngOnInit
生命週期鉤子會在屬性繫結完成後觸發(譯者注:參考列表 1,第一二步操作):
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';
}
複製程式碼
納尼,居然沒有報錯!!!怎麼可能?
如果你往上翻看列表 1 的操作執行順序,你會發現 ngOnInit
生命週期鉤子會在 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 渲染,通常間接是因為使用服務(services)或可觀察者(observables)引發的,不過根本原因還是一樣的。
現在讓我們看看真實世界的案例吧。
共享服務(Shared service)
這個模式案例可檢視程式碼 plunker。這個程式設計為父子元件有個共享的服務,子元件修改了共享服務的某個屬性值,響應式地導致父元件的屬性值發生改變。我把它稱為非直接父元件屬性更新,因為不像上面的示例,它明顯不是子元件立刻改變父元件屬性值。
同步事件廣播
這個模式案例可檢視程式碼 plunker。這個程式設計為子元件丟擲一個事件,而父元件監聽這個事件,而這個事件會引起父元件屬性值發生改變。同時這些屬性值又被父元件作為輸入屬性繫結傳給子元件。這也是非直接父元件屬性更新。
動態元件例項化
這個模式有點不同於前面兩個影響的是輸入屬性繫結,它引起的是 DOM 更新從而丟擲錯誤,可檢視程式碼 plunker。這個程式設計為父元件在 ngAfterViewInit
生命週期鉤子動態新增子元件。因為新增子元件會觸發 DOM 修改,並且 ngAfterViewInit
生命週期鉤子也是在 DOM 更新後觸發的,所以同樣會丟擲錯誤。
解決方案
如果你仔細檢視錯誤描述的最後部分:
Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook ?
根據上面描述,通常的解決方案是使用正確的生命週期鉤子來建立動態元件。例如上面建立動態元件的示例,其解決方案就是把元件建立程式碼移到 ngOnInit
生命週期鉤子裡。儘管官方文件說 ViewChild
只有在 ngAfterViewInit
鉤子後才有效,但是當建立檢視時它就已經填入了子元件,所以在早期階段就可用。(譯者注:Angular 官網說的是 View queries are set before the ngAfterViewInit callback is called
,就已經說明了 ViewChild
是在 ngAfterViewInit
鉤子前生效,不明白作者為啥要說之後才能生效。)
如果你 google 下就知道解決這個錯誤一般有兩種方式:非同步更新屬性和手動強迫變更檢測。儘管我列出這兩個解決方案,但不建議這麼去做,我將會解釋原因。
非同步更新
這裡需要注意的事情是變更檢測和核查迴圈(verification digests)都是同步的,這意味著如果我們在核查迴圈(verification loop)執行時去非同步更新屬性值,會導致錯誤,測試下吧:
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 turn 中作為巨集觀任務(macrotask)被執行。如果使用 Promise.then
回撥來包裝,也可能在當前 VM turn 中執行完同步程式碼後,緊接著在當前 VM turn 繼續執行回撥:(譯者注:VM turn 就是 Virtual Machine Turn,等於 browser task,這涉及到 JS 引擎如何執行 JS 程式碼的知識,這又是一塊大知識,不詳述,有興趣可以參考這篇經典文章 Tasks, microtasks, queues and schedules ,或者這篇詳細描述的文件 從瀏覽器多程式到JS單執行緒,JS執行機制最全面的一次梳理 。)
Promise.resolve(null).then(() => this.parent.name = 'updated name');
複製程式碼
與巨集觀任務(macrotask)不同,Promise.then
會把回撥構造成微觀任務(microtask),微觀任務會在當前同步程式碼執行完後再緊接著被執行,所以在核查之後會緊接著更新屬性值。想要更多學習 Angular 的巨集觀任務和圍觀任務,可以檢視我寫的 I reverse-engineered Zones (zone.js) and here is what I’ve found 。
如果你使用 EventEmitter
你可以傳入 true
引數實現非同步:
new EventEmitter(true);
複製程式碼
強迫式變更檢測
另一種解決方案是在第一次變更檢測和核查迴圈階段之間,再一次迫使 Angular 執行父元件 A
的變更檢測(譯者注:由於 Angular 先是變更檢測,然後核查迴圈,所以這段意思是變更檢測完後,再去變更檢測)。最佳時期是在 ngAfterViewInit
鉤子裡去觸發父元件 A
的變更檢測,因為這個父元件的鉤子函式會在所有子元件已經執行完它們自己的變更檢測後被觸發,而恰恰是子元件做它們自己的變更檢測時可能會改變父元件屬性值:
export class AppComponent {
name = 'I am A component';
text = 'A message for the child component';
constructor(private cd: ChangeDetectorRef) {
}
ngAfterViewInit() {
this.cd.detectChanges();
}
複製程式碼
很好,沒有報錯,不過這個解決方案仍然有個問題。如果我們為父元件 A
觸發變更檢測,Angular 仍然會觸發它的所有子元件變更檢測,這可能重新會導致父元件屬性值發生改變。
為何需要迴圈核查(verification loop)
Angular 實行的是從上到下的單向資料流,當父元件改變值已經被同步後(譯者注:即父元件模型和檢視已經同步後),不允許子元件去更新父元件的屬性,這樣確保在第一次 digest loop 後,整個元件樹是穩定的。如果屬性值發生改變,那麼依賴於這些屬性的消費者(譯者注:即子元件)就需要同步,這會導致元件樹不穩定。在我們的示例中,子元件 B
依賴於父元件的 text
屬性,每當 text
屬性改變時,除非它已經被傳給 B
元件,否則整個元件樹是不穩定的。對於父元件 A
中的 DOM 模板也同樣道理,它是 A
模型中屬性的消費者,並在 UI 中渲染出這些資料,如果這些屬性沒有被及時同步,那麼使用者將會在頁面上看到錯誤的資料資訊。
資料同步過程是在變更檢測期間發生的,特別是列表 1 中的操作。所以如果當同步操作執行完畢後,在子元件中去更新父元件屬性時,會發生什麼呢?你將會得到不穩定的元件樹,這樣的狀態是不可測的,大多數時候你將會給使用者展現錯誤的資訊,並且很難除錯。
那為何不等到元件樹穩定了再去執行變更檢測呢?答案很簡答,因為它可能永遠不會穩定。如果把子元件更新了父元件的屬性,作為該屬性改變時的響應,那將會無限迴圈下去。當然,正如我之前說的,不管是直接更新還是依賴的情況,這都不是重點,但是在現實世界中,更新還是依賴一般都是非直接的。
有趣的是,AngularJS 並沒有單向資料流,所以它會試圖想辦法去讓元件樹穩定。但是它會經常導致那個著名的錯誤 10 $digest() iterations reached. Aborting!
,去谷歌這個錯誤,你會驚訝發現關於這個錯誤的問題有很多。
最後一個問題你可能會問為什麼只有在開發模式下會執行 digest cycle 呢?我猜可能因為相比於一個執行錯誤,不穩定的模型並不是個大問題,畢竟它可能在下一次迴圈檢查資料同步後變得穩定。然而,最好能在開發階段注意可能發生的錯誤,總比在生產環境去除錯錯誤要好得多。