[譯] 關於 `ExpressionChangedAfterItHasBeenCheckedError` 錯誤你所須要知道的事情

原文連接:Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError errorexpress

關於 ExpressionChangedAfterItHasBeenCheckedError,還能夠參考這篇文章,而且文中有 youtube 視頻講解:Angular Debugging "Expression has changed after it was checked": Simple Explanation (and Fix)api

最近 stackoverflow 上幾乎天天都有人提到 Angular 拋出的一個錯誤:ExpressionChangedAfterItHasBeenCheckedError,一般提出這個問題的 Angular 開發者都不理解變動檢測(change detection)的原理,不理解爲什麼產生這個錯誤的數據更新檢查是必須的,甚至不少開發者認爲這是 Angular 框架的一個 bug(譯者注:Angular 提供變動檢測功能,包括自動觸發和手動觸發,自動觸發是默認的,手動觸發是在使用 ChangeDetectionStrategy.OnPush 關閉自動觸發的狀況下生效。如何手動觸發,參考 Triggering change detection manually in Angular)。固然不是了!其實這是 Angular 的警告機制,防止因爲模型數據(model data)與視圖 UI 不一致,致使頁面上存在錯誤或過期的數據展現給用戶。瀏覽器

本文將解釋引發這個錯誤的內在緣由,檢測機制的內部原理,提供致使這個錯誤的共同行爲,並給出修復這個錯誤的解決方案。最後章節解釋爲何數據更新檢查是如此重要。bash

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.(譯者注:這是做者的吐槽,不翻譯)angular2

相關變動檢測行爲

一個運行的 Angular 程序實際上是一個組件樹,在變動檢測期間,Angular 會按照如下順序檢查每個組件(譯者注:這個列表稱爲列表 1):框架

  • 更新全部子組件/指令的綁定屬性
  • 調用全部子組件/指令的三個生命週期鉤子:ngOnInitOnChangesngDoCheck
  • 更新當前組件的 DOM
  • 爲子組件執行變動檢測(譯者注:在子組件上重複上面三個步驟,依次遞歸下去)
  • 爲全部子組件/指令調用當前組件的 ngAfterViewInit 生命週期鉤子

在變動檢測期間還會有其餘操做,能夠參考我寫的文章:《Everything you need to know about change detection in Angular》less

在每一次操做後,Angular 會記下執行當前操做所須要的值,並存放在組件視圖的 oldValues 屬性裏(譯者注:Angular Compiler 會把每個組件編譯爲對應的 view class,即組件視圖類)。在全部組件的檢查更新操做完成後,Angular 並非立刻接着執行上面列表中的操做,而是會開始下一次 digest cycle,即 Angular 會把來自上一次 digest cycle 的值與當前值比較(譯者注:這個列表稱爲列表 2):dom

  • 檢查已經傳給子組件用來更新其屬性的值,是否與當前將要傳入的值相同
  • 檢查已經傳給當前組件用來更新 DOM 值,是否與當前將要傳入的值相同
  • 針對每個子組件執行相同的檢查(譯者注:就是若是子組件還有子組件,子組件會繼續執行上面兩步的操做,依次遞歸下去。)

記住這個檢查只在開發環境下執行,我會在後文解釋緣由。異步

讓咱們一塊兒看一個簡單示例,假設你有一個父組件 A 和一個子組件 B,而 A 組件有 nametext 屬性,在 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 列出的執行幾個生命週期鉤子。(譯者注:即調用子組件 BngOnInitOnChangesngDoCheck 這三個生命週期鉤子。)

第三步是計算模板表達式 {{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
複製代碼

你可能會問上面提到的 textname 屬性值發生突變,這會發生麼?讓咱們一塊兒往下看。

屬性值突變的緣由

屬性值突變的罪魁禍首是子組件或指令,一塊兒看一個簡單證實示例吧。我會先使用最簡單的例子,而後舉個更貼近現實的例子。你可能知道子組件或指令能夠注入它們的父組件,假設子組件 B 注入它的父組件 A,而後更新綁定屬性 text。咱們在子組件 BngOnInit 生命週期鉤子中更新父組件 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'.
複製代碼

如今咱們再一樣改變父組件 Aname 屬性:

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 呢?我猜可能由於相比於一個運行錯誤,不穩定的模型並非個大問題,畢竟它可能在下一次循環檢查數據同步後變得穩定。然而,最好能在開發階段注意可能發生的錯誤,總比在生產環境去調試錯誤要好得多。

相關文章
相關標籤/搜索