最近在使用ngAfterViewInit的時候發生了一些錯誤。又發現自己對變更檢測的流程其實不是很理解,所以來梳理一下過程,並來講講這個錯誤為什麼會發生。
變更檢測
首先來了解一下angular對元件的檢測操作:
當Angular 對每個元件進行檢查,這些元件大致按指定順序執行的以下操作:
- 更新所有子元件/指令的繫結屬性 (例如@Input)
- 在所有子元件/指令上呼叫 ngOnInit、OnChanges 和 ngDoCheck 生命週期hook
- 更新當前元件的 DOM
- 為子元件執行變更檢測
- 為所有子元件/指令呼叫 ngAfterViewInit 生命週期鉤子
但是在開發模式下會額外執行以下的操作檢查:
在每次操作之後,Angular 都會記住它用來執行操作的變數的值。它們儲存在元件檢視的 oldValues 屬性中。
Angular 執行下列的操作:
- 檢查傳遞給子元件的值是否與oldValues相同
- 檢查用於更新 DOM 元素的值是否與oldValues相同
- 對所有子元件執行相同的檢查
丟擲ExpressionChangedAfterItHasBeenCheckedError錯誤的例子
舉個例子:
假設現在是開發模式
定義了A元件,並傳遞text給B元件
@Component({
selector: 'a-comp',
template: `
<span>{{name}}</span>
<b-comp [text]="text"></b-comp> `
})
export class AComponent {
name = 'Im A'
text = 'A to B`;
}
@Component({
selector: 'b-comp',
})
export class BComponent {
@Input() text;
constructor(private parent: AComponent) {}
ngOnInit() {
this.parent.text = 'B to A';
}
}
讓我們按照的5步變更檢測進行:
- 更新所有子元件/指令的繫結屬性
angular先執行B元件的text與A元件的text繫結,B元件的text = "A to B"。
在開發模式下,會記錄這個值view.oldValues[0] = '傳遞給子元件'; - 在子元件上呼叫 ngOnInit等鉤子
此時執行AComponent.text = "B to A";
執行到第二步的時候發生異常,丟擲ExpressionChangedAfterItHasBeenCheckedError
如下圖
這就是違反了開發模式下的檢查所丟擲的錯誤。
簡單來說,就是在前一步已經確立好值的情況下,下一步反過來了又將它改變。與oldValue衝突.
即ExpressionChangedAfterItHasBeenCheckedError這個單詞的字面意思。
第二個例子
假如我們在B這個子元件中這麼做會不會丟擲錯誤呢?
ngOnInit() { this.parent.name = 'updated name'; }
有可能你想:這不也是在ngOnInit中變更父元件的值嘛,肯定會報錯。
但結果是不會,原因是什麼呢?
別忘了,在A中是這麼定義name的:
@Component({
template: `
<span>{{name}}</span>
})
export class AComponent {
name = 'Im A'
}
看出點什麼了嗎? 沒錯,有關name的操作在第三步才執行。
即:更新本元件的DOM
總結:
其實原理很簡單:在前一步已經確立好值後下一步不要更改它,不要與oldValue衝突
專案中的例子
專案中遇到是動態元件方面的報錯例子,也是丟擲ExpressionChangedAfterItHasBeenCheckedError。
簡單介紹一下程式碼:
export class App {
@ViewChild(FormItemDirective, {static: true})
appFormItem: FormItemDirective;
constructor(private r: ComponentFactoryResolver) {
}
ngAfterViewInit() {
const f = this.r.resolveComponentFactory(BComponent);
this.appFormItem.viewContainerRef.createComponent(f);
}
}
根據5步流程,報錯的原因就很簡單了:
該元件在 ngAfterViewInit 中動態新增一個子元件。由於新增子元件需要修改 DOM,並且在 Angular 更新 DOM 後觸發 ngAfterViewInit 生命週期鉤子,又去修改DOM,因此會引發錯誤。
如圖:第五步時又去修改第三步已經確立的DOM。
解決方法
這裡以專案中的修改為例子,講講如何解決第5步中修改了第3步中已經確立的DOM發生的問題。
1. 把修改提前
很簡單的方法,既然是在第三步中確立的DOM, 那麼在第一步和第二步中修改它不就行了。
在第一步中修改,可以使用@Input,因為更新子元件的繫結屬性是在第一步中完成。
@Input() set setValue(value: Type) { // do someting }
在第二步中修改,ngOnInit等鉤子是在第二步中完成
ngOnInit() { // do something }
2. 強制變更檢測
另一種可能的解決方案是為父 A 元件強制執行另一個更改檢測週期。最好的地方是在 ngAfterViewInit 生命週期鉤子中,因為它是在對所有子元件執行更改檢測時觸發的,因此它們比較可能更新父元件屬性。
使用ChangeDetectorRef.detectChanges()就能完成這個操作。
export class AppComponent {
constructor(private cd: ChangeDetectorRef) { }
ngAfterViewInit() {
this.cd.detectChanges();
}
幾個問題
為什麼angular需要這麼驗證?
Angular 強制執行所謂的從上到下的單向資料流。處理父級更改後,不允許層次結構較低的元件更新父元件的屬性.
這確保了在變更檢測之後,整個元件樹是穩定的。如果需要與依賴於這些屬性的消費者同步的屬性發生變化,則樹是不穩定的。
為什麼只在開發模式下執行它?
可能因為整合模式不像開發模式執行時錯誤那樣嚴重。畢竟它可能會在下一次摘要執行中穩定下來。
但是,最好在開發模式下就解決它,而不是留給客戶端來嘗試除錯它。