最近在stackoverflow上几乎每天都有关于ExpressionChangedAfterItHasBeenCheckedError
的提问,通常提出这些问题是因为Angular开发人员不了解更改检测的工作原理,以及为什么检查产生这个错误是必须的。许多开发人员甚至将其视为错误。但这当然不是。这是一种警告机制,可防止模型数据与UI之间出现不一致,以免在页面上向用户显示错误或旧数据。
本文介绍了错误出现的底层原因以及变更检查的机制,并提供了一些可能导致错误的常见模式,并提出了一些可能的修复方法。最后一章说明了此检查为什么很重要。
1. 相关的变更检查操作
正在运行的Angular应用程序是一棵组件树。在变更检测期间,Angular对每个组件执行检查,该组件包括按指定顺序执行的以下操作:
- 更新所有子组件/指令的绑定属性
- 调用所有子组件或指令的
ngOnInit
,OnChanges
和ngDoCheck
生命周期钩子 - 更新当前组件的DOM
- 对子组件运行更改检测
- 用所有子组件或指令的
ngAfterViewInit
生命周期钩子
在变更检测期间还有其他操作,请参考你需要了解的有关Angular中变更检测的所有信息
每次操作后,Angular都会记住用于执行操作的值,它们被存储在组件视图的oldValues属性里。 在完成对所有组件的检查之后,Angular然后开始下一个脏检查循环,但不执行上面列出的操作,而是将当前值与上一个脏检查循环所记住的值进行比较:
- 检查传递给子组件的值是否与现在用于更新这些组件的属性的值相同
- 检查用于更新DOM元素的值是否与现在用于更新这些元素的值相同
- 对所有子组件执行相同的检查
请注意,此附加检查仅在开发模式下执行。我将在文章的最后一部分中解释原因。
让我们来看一个例子。假设您有一个父组件A
和一个子组件B
。该A组件具有name
和text
属性。在其模板中,它使用引用name
属性的表达式:
template: '<span>{{name}}</span>'
并且它的模版中还包括了B
组件,并且把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
组件。列表中的第一个操作是更新绑定,因此它将text
计算为A message for the child component
并传递给了B
组件,并且在视图中存储了它的值:
view.oldValues[0] = 'A message for the child component';
然后,它调用列表中提到的生命周期挂钩。
现在,它执行第三个操作,计算{{name}}
为I am A component
,它使用这个值更新DOM,并把它存储到oldValues
:
view.oldValues[1] = 'I am A component';
然后,Angular执行下一个操作,并对子B组件运行相同的检查。一旦B组件检查完成,当前的脏检查循环就完成了。
如果Angular在开发模式下运行,它将会运行第二个脏检查来执行上面我所说的验证。现在想象一下,当Angular将A message for the child component
传递给B
组件并存储之后,又更新A
组件的text
属性。现在它运行验证脏检查循环,第一个操作是检查text
属性是否改变:
AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
是的它改变了,因此Angular抛出了错误:ExpressionChangedAfterItHasBeenCheckedError
。
对于第三个操作也一样,如果在DOM刷新并且存储了name
值之后,再次改变name
的值,我们将会得到相同的错误:
AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
您现在可能会想到一个问题,这些值怎么可能改变。让我们看看。
2. 值改变的原因
罪魁祸首几乎都是子组件或指令,让我们快速简单地演示一下。我将尽可能使用最简单的示例,但是之后我将展示真实的场景。您可能知道子组件和指令可以注入其父组件。因此,让我们的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呈现的操作通常是通过使用服务或observables
间接完成的。但是根本原因始终是相同的。
现在,让我们看一些导致错误的现实世界通用模式。
2.1 共享服务
请查看这个例子,子组件与父组件共享一个服务,子组件更改了服务的一个值,这也会同步的更改父组件的一个属性。我称其为间接的更新父组件,因为与上面的示例不同,它并不立即表明子组件会更新父组件属性。
2.2 同步事件广播
请查看这个例子。在这个程序中子组件提交一个事件,父组件监听这个事件,这个事件会引发父组件的属性更新,这也是间接的更新父组件。
2.3 动态组件实例化
此模式是不同的,因为与以前的输入绑定受到影响的模式不同,此模式导致DOM更新操作抛出错误。请查看例子,在这个例子中,父组件在ngAfterViewInit
中动态的添加子组件,由于添加子组件需要DOM修改,并且ngAfterViewInit
是在Angular更新DOM之后触发的,因此会引发错误。
3. 可能的修复的方法
如果查看描述,最后将出现一下内容:
Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook ?
通常,解决方法是使用正确的生命周期钩子创建动态组件。例如,可以通过将组件创建移动到ngOnInit
挂钩来修复上一节中带有动态组件的最后一个示例。尽管文档指出ViewChild
仅在ngAfterViewInit
之后才可用,但在创建视图时会填充子代,因此它们可以较早的可用。
如果您在google上搜索,则可能会找到针对此错误的两个最常见的修复方法——异步更新属性和强制执行其他更改检测周期。尽管我在这里展示了并解释了它们为什么起作用,但我不建议您使用它们,而是应该重新设计您的应用程序。我在下一节中解释为什么。
3.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
安排了一个macrotask
,它将在下一个VM
轮询中运行。也可以通过Promise
的then
回调开启一个microTask
,它将在当前的VM
轮询中,但要等到当前的同步代码运行完成之后运行。
Promise.resolve(null).then(() => this.parent.name = 'updated name');
如果您正在使用EventEmitter
,则可以传递true
选项来使用异步提交:
new EventEmitter(true);
3.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();
}
好吧,没有错误。因此它似乎正在工作,但是此解决方案存在问题。当我们触发父A组件的更改检测时,Angular也会对所有子组件运行更改检测,但它们可能会再次更新父属性。
4. 为什么我们需要验证循环
Angular 从顶部到底部强制执行所谓的单向数据流。处理父级更改后,不允许层次结构中较低的组件更新父级组件的属性。这样可以确保在第一个脏循环之后,整个组件树都是稳定的。如果属性中的更改需要与依赖于这些属性的使用者进行同步,则树是不稳定的。在我们的例子中,子B组件取决于父text属性。每当这些属性更改时,组件树就变得不稳定,直到将此更改传递给子级B零件。DOM也是如此。它是组件上某些属性的使用者,并在UI上呈现它们。如果某些属性未同步,则用户将在页面上看到错误的信息。
这个数据同步过程就是更改检测期间发生的事情-尤其是我一开始列出的那两个操作。那么,如果在执行同步操作之后从子组件属性更新父属性,会发生什么情况?没错,您剩下的是不稳定的树,这种状态的后果无法预测。大多数情况下,您最终会看到页面上显示给用户的错误信息。这将很难调试。
那么,为什么不运行更改检测,直到组件树稳定下来?答案很简单-因为它可能永远不会稳定并永远运行。如果子组件更新父组件上的属性作为对此属性更改的反应,则将出现无限循环。当然,正如我之前说的,通过直接更新或依赖发现这种模式很简单,但是在实际应用程序中,更新和依赖通常都是间接的。
有趣的是,AngularJS没有单向数据流,因此它努力的想稳定树。但这通常会导致臭名昭著的10 $digest() iterations reached. Aborting!
错误。如果搜索该错误,您会惊讶于关于该错误的大量问题。
您可能有的最后一个问题是,为什么仅在开发模式下运行此程序?我猜这是因为不稳定的模型没有框架产生的运行时错误那样严重。毕竟,它可能在下一个脏检查运行中稳定下来。但是,最好在开发应用程序时通知可能的错误,而不是在客户端调试正在运行的应用程序。