文章翻譯已征得原作者同意抵乓,原文鏈接:link
正文
最近在stackoverflow上總看有人問到使用Angular時會報 ExpressionChangedAfterItHasBeenCheckedError
的錯誤悯蝉。大部分提問的人不太明白Angular的變化監(jiān)測機(jī)制,或者不明白為什么需要這個報錯信息峭沦。一些開發(fā)者甚至認(rèn)為這是個bug,但是這其實不是bug足绅。
本篇文章將深入講解導(dǎo)致這個錯誤的原因與被監(jiān)測到的原理压语,還會展示該錯誤經(jīng)常出現(xiàn)的場景,并且給出幾個可行的解決方案编检。在最后一章將會解釋Angular為什么需要這個監(jiān)測機(jī)制。
關(guān)于變化監(jiān)測
每個Angular應(yīng)用都是以組件樹的形態(tài)呈現(xiàn)的扰才。Angular在變化監(jiān)測階段會按以下的順序?qū)γ總€組件執(zhí)行操作(List1):
- 更新所有綁定在子component/directive上的屬性
- 調(diào)用所有子component/directive的
ngOnInit
,ngOnChanges
,ngDoCheck
生命周期函數(shù) - 解析允懂、更新當(dāng)前組件DOM上的value
- 運(yùn)行子component的變化監(jiān)測流程(List1)
- 調(diào)用所有子component/directive上的
ngAfterViewInit
生命周期
還有一些其他的操作在變化監(jiān)測階段被執(zhí)行,我在這篇文章中詳細(xì)列出了這些流程:Everything you need to know about change detection in Angular
每一步操作后衩匣,Angular會保存與這次操作有關(guān)的values值蕾总,這個值被存在組件view的 oldValues
屬性中。(開發(fā)模式下)在所有組件完成變化監(jiān)測之后Angular會開始下一個監(jiān)測流程琅捏,第二次監(jiān)測流程并不會再次執(zhí)行上面列出的變化監(jiān)測流程生百,而會比較之前變化監(jiān)測循環(huán)保存的值(存在oldValues中的)與當(dāng)前監(jiān)測流程的值是否一致(List2):
- 檢查被傳遞到子組件的values(oldValues)與當(dāng)前組件要被用于更新的values(instance.value)是否一致
- 檢查被用于更新DOM元素的values(oldValues)與當(dāng)前要被用于這些組件更新的values(instance.value)是否一致
- 對所有子component執(zhí)行相同的檢查
注意:這些額外的檢查(List2)只發(fā)生在開發(fā)模式下,我會在后面的章節(jié)中解釋其中原因柄延。
接下來我們來看一個例子蚀浆。假設(shè)你有一個父組件A和一個子組件B,A組件中有兩個屬性:name
和 text
搜吧,A組件的模板中使用了 name
屬性:
template: '<span>{{name}}</span>'
然后在模板中加入B組件市俊,并且通過輸入屬性綁定給B組件輸入 text
屬性:
@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在開始變化監(jiān)測后會發(fā)生什么呢?(List1)變化監(jiān)測會從A組件開始檢查滤奈,第一步將 text
表達(dá)式中的 A message for the child component
向下傳遞到B組件摆昧,并且將這個值存在view上:
view.oldValues[0] = 'A message for the child component';
然后到了變化監(jiān)測列表里的第二步,調(diào)用相應(yīng)的生命周期函數(shù)蜒程。
接下來執(zhí)行第三步绅你,將 {{name}}
表達(dá)式解析為 I am A component
文本。將解析好的值更新到DOM上昭躺,并且存入 oldValues
:
view.oldValues[1] = 'I am A component';
最后Angular對B組件執(zhí)行相同的操作(List1)忌锯,一旦B組件完成以上的操作,此次變化監(jiān)測循環(huán)便完成了领炫。
如果Angular在開發(fā)模式下運(yùn)行汉规,那么將會執(zhí)行另一個監(jiān)測流程(List2)。text
屬性在傳遞給B組件時的值是 A message for the child component
并存入 oldValues
驹吮,現(xiàn)在想象一下A組件在此之后將 text
的值更新為 updated text
针史。然后List2的第一步將會檢查 text
屬性是否被改變:
AComponentView.instance.text === view.oldValues[0]; // false
'updated text' === 'A message for the child component'; // false
這個時候Angular就該拋出這個錯誤了
ExpressionChangedAfterItHasBeenCheckedError
。
同理碟狞,如果更新已經(jīng)被渲染在DOM中并且被存在 oldValues
中的 name
屬性啄枕,也會拋出相同的錯誤
AComponentView.instance.name === view.oldValues[1]; // false
'updated name' === 'I am A component'; // false
現(xiàn)在你可能會有些疑惑,這些值怎么會被改變呢族沃?我們接著往下看频祝。
數(shù)據(jù)改變的原因
罪魁禍?zhǔn)滓话愣际亲咏M件或指令泌参,下面我們來看一個簡單的案例。我會先用盡可能簡單的例子來重現(xiàn)場景常空,稍后也會給出真實場景下的例子沽一。大家都知道父組件能使用子組件或指令,這里給出一個父組件為A漓糙,子組件為B铣缠,并且B組件有一個綁定屬性 text
。我們將在子組件的 ngOnInit
(此時數(shù)據(jù)已綁定)生命周期鉤子中更新 text
屬性:
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'updated text';
}
}
我們看見了預(yù)期的錯誤:
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.
現(xiàn)在我們對被用于父組件模板的 name
屬性做相同的操作:
ngOnInit() {
this.parent.name = 'updated name';
}
這時候程序并沒有報錯昆禽,為什么會這樣呢蝗蛙?
如果你仔細(xì)看變化監(jiān)測(List1)的執(zhí)行順序,你會發(fā)現(xiàn)子組件的 ngOnInit
將在當(dāng)前component的DOM更新之前被調(diào)用(在記錄oldValues前改變了數(shù)據(jù))醉鳖,這就是為什么上面的例子中更改 name
屬性卻不會報錯捡硅。我們需要一個在DOM中values更新之后的鉤子來做實驗, ngAfterViewInit
是一個不錯的選擇:
export class BComponent {
@Input() text;
constructor(private parent: AppComponent) {}
ngAfterViewInit() {
this.parent.name = 'updated name';
}
}
我們又一次得到了預(yù)期的錯誤:
AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.
當(dāng)然現(xiàn)實中遇到的情況會更加錯綜復(fù)雜盗棵,父組件中屬性在二次監(jiān)測之前被更新通常是使用的外部服務(wù)或observabals間接導(dǎo)致的壮韭。但是其本質(zhì)原因是相同的。
現(xiàn)在我們來看一些真實案例纹因。
共享服務(wù)
例子:plunker泰涂。這個應(yīng)用中父組件和子組件共用一個共享服務(wù),子元素通過共享服務(wù)設(shè)置一個屬性的值并反映到父元素上辐怕。這個模式下子元素改變父元素的值的方式并不像上面簡單例子中那么顯而易見逼蒙,是間接更新了父元素的屬性。
同步事件廣播
例子:plunker寄疏。這個應(yīng)用中父元素監(jiān)聽一個子元素廣播的事件是牢,這個事件導(dǎo)致父元素的屬性被更新,這個屬性又被用于子元素的Input綁定陕截。這同樣間接更新了父元素的屬性驳棱。
動態(tài)的組件實例化
這種模式與之前兩種模式略有不同,前兩種模式都是List2中的第一步檢測拋出的錯誤农曲,而這種模式是由DOM更新檢測(List2第二步)拋出的錯誤社搅。例子:plunker。這個應(yīng)用中父組件在 ngAfterViewInit
生命周期中動態(tài)添加子組件乳规,該生命周期發(fā)生在當(dāng)前組件DOM初次更新之后形葬,而添加子組件將會修改DOM結(jié)構(gòu),那么前后兩次DOM中所使用的values值就不同了(前提是子組件帶有新的value引用)暮的,所以拋出了錯誤笙以。
可行解決方案
如果你仔細(xì)看報錯信息的最后一句:
Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook ?
動態(tài)創(chuàng)建組件的情況下,解決這個問題最好的方案是改變創(chuàng)建組件時所處的生命周期鉤子冻辩。比如之前章節(jié)中動態(tài)創(chuàng)建組件的流程就可以被移到 ngOnInit
中猖腕。即使文檔中說明了 ViewChildren
只能在 ngAfterViewInit
之后被獲取到拆祈,但是創(chuàng)建視圖時就在填充子組件了,所以能提前獲取 ViewChildren
倘感。
如果你google過這個錯誤放坏,那么你應(yīng)該看過一些回答推薦使用異步更新數(shù)據(jù)和強(qiáng)制增加一個變化監(jiān)測循環(huán)兩種方法來解決這個錯誤。即使我把這兩種方法也列出來了老玛,我也更推薦重新設(shè)計你的應(yīng)用而不是使用這兩種方法來解決這個問題淤年,我將會在后面的文章給出理由。
異步更新
你應(yīng)該注意到一件事逻炊,不管是變化監(jiān)測還是第二次的驗證digest都是同步執(zhí)行的。這意味著如果我們在代碼中異步更新屬性的值犁享,那么在第二次驗證循環(huán)運(yùn)行時這些屬性是不會被改變的余素,那么也就不會報錯了。讓我們來試一下:
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
將函數(shù)加入macrotask隊列中桨吊,函數(shù)會在下一個VM周期里被調(diào)用。也可以通過使用promise里的 then
回調(diào)將函數(shù)加入當(dāng)前VM周期其他同步代碼被執(zhí)行完之后:
Promise.resolve(null).then(() => this.parent.name = 'updated name');
Promise.then
并不會被放入macrotask凤巨,而是創(chuàng)建一個microtask视乐。microtask隊列將在當(dāng)前周期中所有同步代碼被執(zhí)行完畢之后執(zhí)行,因此屬性的更新會發(fā)生在驗證步驟之后敢茁。想學(xué)習(xí)更多關(guān)于micro和macro task在Angular中的應(yīng)用可以看這篇文章:I reverse-engineered Zones (zone.js) and here is what I’ve found佑淀。
給 EventEmitter
傳一個 true
能使事件的emit變?yōu)楫惒剑?/p>
new EventEmitter(true);
強(qiáng)制變化監(jiān)測
另一個解決方案是在父組件A的第一和第二次驗證之間強(qiáng)制加一個變化監(jiān)測循環(huán)。觸發(fā)強(qiáng)制變化監(jiān)測的最佳位置是在 ngAfterViewInit
生命周期內(nèi)彰檬,這時候所有的子組件的流程都已經(jīng)執(zhí)行完畢伸刃,所以隨便在之前的哪個位置改變父組件的屬性都無所謂:
export class AppComponent {
name = 'I am A component';
text = 'A message for the child component';
constructor(private cd: ChangeDetectorRef) {
}
ngAfterViewInit() {
this.cd.detectChanges();
}
嗯,一樣沒有報錯逢倍,好像可以很開心的運(yùn)行程序了捧颅。其實這里有個問題,當(dāng)在父組件A中觸發(fā)新添加的變化監(jiān)測時较雕,Anuglar同樣會為所有的子組件運(yùn)行一次變化監(jiān)測碉哑,那么父組件可能會被又一次更新。
為什么需要第二次監(jiān)測循環(huán)
Angular強(qiáng)制使用至上而下的單向數(shù)據(jù)流亮蒋,在父元素完成變化監(jiān)測之后不允許內(nèi)部子組件在第二次變化監(jiān)測前改變父組件的屬性扣典。這能確保第一次變化監(jiān)測后的組件樹是穩(wěn)定的。如果在監(jiān)測循環(huán)周期里有屬性的改變導(dǎo)致依賴這些屬性的使用者需要同步更新變化慎玖,那么這棵組件樹就是不穩(wěn)定的激捏。上面例子中子組件B依賴父組件的 text
屬性,每當(dāng)屬性的值改變凄吏,在這些改變被傳遞到B組件之前這棵組件樹都處于不穩(wěn)定的狀態(tài)远舅。這同樣體現(xiàn)在DOM與屬性之間的關(guān)系上闰蛔,DOM作為這些屬性的使用者,然后將這些屬性渲染到UI界面上图柏。如果某些屬性沒有同步更新到界面上序六,用戶將會看到錯誤的界面。
數(shù)據(jù)流的同步過程發(fā)生在文章開頭列出的兩堆操作中蚤吹,所以如果你在數(shù)據(jù)同步過程完成之后再通過子組件修改父組件中的屬性會發(fā)生什么呢例诀?是的,你留下了一個不穩(wěn)定的組件樹裁着,其中數(shù)據(jù)變更的順序?qū)o法預(yù)測繁涂。大部分時候這將會給用戶呈現(xiàn)出一個有錯誤數(shù)據(jù)的頁面,而且問題的排查將十分困難二驰。
可能你會問了扔罪,那為什么不等到組件樹穩(wěn)定之后再進(jìn)行變化監(jiān)測呢?答案很簡單桶雀,組件樹可能永遠(yuǎn)不會穩(wěn)定下來矿酵,一個子組件更新了父組件中的屬性,父組件的屬性又更新子組件的狀態(tài)矗积,子組件狀態(tài)的更新又觸發(fā)更新父組件的屬性...這將是個無限循環(huán)全肮。之前我展示了很多組件對屬性直接更新或依賴的情況,但實際中的應(yīng)用對屬性的更新和依賴通常是間接棘捣,不易排查的辜腺。
有趣的是,AngularJS(Angular 1.x)并沒有使用單向數(shù)據(jù)流也能很大程度的保證組件樹的穩(wěn)定哪自。但是我們經(jīng)常會看到一個臭名昭著的錯誤 10 $digest() iterations reached. Aborting!
瞧毙。隨便去google一下就能找到大量關(guān)于這個錯誤的問題矩动。
最后一個問題是男图,為什么第二次循環(huán)監(jiān)測只在開發(fā)模式下運(yùn)行栈戳?我猜想這是因為數(shù)據(jù)層不穩(wěn)定在框架運(yùn)行時并不會產(chǎn)生引人關(guān)注的錯誤,畢竟數(shù)據(jù)在下一次監(jiān)測循環(huán)后就會穩(wěn)定下來。當(dāng)然,在開發(fā)時期將可能得錯誤解決總好過在上線后的應(yīng)用中排查錯誤韵卤。
初次翻譯略顯生澀,如有錯誤歡迎指出月而。