![](https://i-blog.csdnimg.cn/blog_migrate/d552730511b5b53e441420a39dde340f.png)
似乎最近几乎每天都有关于Angular引发的ExpressionChangedAfterItHasBeenCheckedError错误的关于stackoverflow的问题。 通常会出现这些问题,因为Angular开发人员不了解更改检测的工作原理以及为什么需要进行检查才能产生此错误。 许多开发人员甚至将其视为错误。 但这当然不是。 这是一种警告机制,可防止模型数据和UI之间出现不一致,以免在页面上向用户显示错误或旧数据。
本文介绍了错误的根本原因以及检测到错误的机制,提供了一些可能导致错误的常见模式,并提出了一些可能的修复方法。 上一章说明了此检查为什么很重要的原因。
似乎与我在文章中投入的信息来源的链接越多,人们推荐它的可能性就越小😃。 这就是为什么本文将不引用源。
相关变更检测操作
正在运行的Angular应用程序是一棵组件树。 在变更检测期间,Angular对每个组件执行检查,该组件包括按指定顺序执行的以下操作:
- 更新所有子组件/指令的绑定属性
- 在所有子组件/指令上调用ngOnInit,OnChanges和ngDoCheck生命周期挂钩
- 更新当前组件的DOM
- 对子组件运行更改检测
- 为所有子组件/指令调用ngAfterViewInit生命周期挂钩
在变更检测期间还有其他操作,在“ 关于Angular进行变更检测所需了解的一切”中已经介绍了所有这些操作。
每次操作后,Angular都会记住用于执行操作的值。 它们存储在组件视图的oldValues属性中。 在对所有组件进行了检查之后,Angular会开始下一个摘要循环,但是不执行上面列出的操作,而是将当前值与上一个摘要循环所记住的值进行比较:
- 检查传递给子组件的值是否与现在用于更新这些组件的属性的值相同
- 检查用于更新DOM元素的值是否与现在用于更新这些元素的值相同
- 对所有子组件执行相同的检查
请注意,此附加检查仅在开发模式下执行。 我将在文章的最后一部分中解释原因。
让我们来看一个例子。 假设您有一个父组件A和一个子组件B。A组件具有名称和文本属性。 在其模板中,它使用引用name属性的表达式:
template: '<span> {{name}} </span>'
并且它的模板中还有B组件,并通过输入属性绑定将text属性传递给此组件:
@Component({ selector: 'a-comp', template: ` <span>{{name}}</span> <b-comp [text]="text"></b-comp> ` }) export class BComponent { name = 'I am A component'; text = 'A message for the child component`;
因此,这是Angular运行更改检测时发生的情况。 首先检查A组件。 列表中的第一个操作是更新绑定,以便它将子组件的文本表达式评估为A消息,并将其传递给B组件。 它还将此值存储在视图中:
view.oldValues[0] = 'A message for the child component';
然后,它调用列表中提到的生命周期挂钩。
现在,它执行第三次操作,并将表达式{{name}}评估为文本“我是A组件”。 它使用此值更新DOM并将评估值放入oldValues中:
view.oldValues[1] = 'I am A component';
然后,Angular执行下一个操作,并对子B组件运行相同的检查。 检查B组件后,当前摘要循环完成。
如果Angular在开发模式下运行,那么它将运行第二个摘要,执行上面列出的验证操作。 现在想象一下,在Angular将子组件的值A消息传递给B组件并将其存储后,属性文本已在A组件上更新为更新后的文本。 因此,它现在运行验证摘要,第一个操作是检查属性名称是否未更改:
AComponentView.instance.text === view.oldValues[0]; // false 'A message for the child component' === 'updated text'; // false
但是它确实存在,因此Angular抛出错误ExpressionChangedAfterItHaHasBeenCheckedError。
第三操作也是如此。 如果name属性在DOM中呈现并存储后已更新,我们将得到相同的错误:
AComponentView.instance.name === view.oldValues[1]; // false 'I am A component' === 'updated name'; // false
您现在可能会想到一个问题,这些值怎么可能改变。 让我们看看。
价值改变的原因
罪魁祸首始终是子组件或指令。 让我们快速简单地演示一下。 我将尽可能使用最简单的示例,但是之后我将展示真实的场景。 您可能知道子组件和指令可以注入其父组件。 因此,让我们的B组件注入父A组件并更新绑定的属性文本。 我们将更新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组件的模板表达式中使用的属性名称进行相同的操作:
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呈现的操作通常是通过使用服务或可观察对象间接完成的。 但是根本原因始终是相同的。
现在,让我们看一些导致错误的现实世界通用模式。
共享服务
这个笨拙的人可以说明这种模式 。 该应用程序旨在具有在父组件和子组件之间共享的服务。 子组件为服务设置一个值,该服务随后通过更新父组件上的属性来做出反应。 我将此间接属性称为“间接更新”,因为与上面的示例不同,它并不立即表明子组件会更新父组件属性。
同步事件广播
这个笨拙的人可以说明这种模式 。 该应用程序设计为具有发出事件的子组件和侦听此事件的父组件。 该事件导致某些父级属性被更新。 这些属性用作子组件的输入绑定。 这也是间接的父属性更新。
动态组件实例化
此模式是不同的,因为与以前的输入绑定受到影响的模式不同,此模式导致DOM更新操作抛出错误。 这个笨拙的人可以说明这种模式 。 该应用程序设计为具有父组件,该组件在ngAfterViewInit中动态添加子组件。 由于添加子组件需要DOM修改,并且在Angular更新DOM之后触发ngAfterViewInit生命周期挂钩,因此会引发错误。
可能的修复
如果查看错误描述,最后一条语句将说明以下内容:
检查后表达式已更改。 上一个值:…是否已在变更检测挂钩中创建它?
通常,解决方法是使用正确的更改检测挂钩创建动态组件。 例如,可以通过将组件创建移动到ngOnInit钩子来修复上一节中有关动态组件的最后一个示例。 尽管文档指出ViewChildren仅在ngAfterViewInit之后可用,但它会在创建视图时填充子代,因此它们较早可用。
如果您四处搜寻,则可能会找到针对此错误的两个最常见的修复方法-异步属性更新和强制执行其他更改检测周期。 尽管我在这里展示了它们并解释了它们为什么起作用,但我不建议您使用它们,而是重新设计您的应用程序。 我在上一章中解释了为什么。
异步更新
这里要注意的一件事是变更检测和验证摘要都是同步执行的。 这意味着,如果我们异步更新属性,则在验证循环运行时不会更新值,并且不会出现任何错误。 让我们测试一下:
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轮次中执行。 也可以在当前VM轮次中执行更新,但是在当前同步代码执行完毕后,可以使用promise的回调:
Promise.resolve(null).then(() => this.parent.name = 'updated name');
代替宏任务Promise.then创建一个micrtotask。 在当前同步代码完成执行后处理微任务队列,因此将在验证步骤之后对属性进行更新。
强制变更检测
另一种可能的解决方案是在第一个阶段和验证阶段之间对父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也会对所有子组件运行更改检测,因此父属性可能会重新更新。
为什么我们需要验证循环
Angular 从顶部到底部强制执行所谓的单向数据流 。 处理父级更改后 ,不允许层次结构中较低的组件更新父级组件的属性。 这样可以确保在第一个摘要循环之后,整个组件树都是稳定的。 如果属性中的更改需要与依赖于这些属性的使用者进行同步,则树是不稳定的。 在我们的例子中,子B组件取决于父文本属性。 只要这些属性更改,组件树就变得不稳定,直到将此更改传递给子B组件。 DOM也是如此。 它是组件上某些属性的使用者,并在UI上呈现它们。 如果某些属性未同步,则用户将在页面上看到错误的信息。
这个数据同步过程就是更改检测期间发生的事情-尤其是我一开始列出的那两个操作。 那么,如果在执行同步操作之后从子组件属性更新父属性,会发生什么情况? 没错,您剩下的是不稳定的树,这种状态的后果无法预测。 大多数情况下,您最终会看到页面上显示给用户的错误信息。 这将很难调试。
那么,为什么不运行更改检测,直到组件树稳定下来? 答案很简单-因为它可能永远不会稳定并永远运行。 如果子组件更新父组件上的属性作为对此属性更改的反应,则将出现无限循环。 当然,正如我之前说的,通过直接更新或依赖发现这种模式很简单,但是在实际应用程序中,更新和依赖通常都是间接的。
有趣的是,AngularJS没有单向数据流,因此它试图稳定树。 但这通常会导致臭名昭著的10 $ digest()迭代。 流产! 错误。 继续并搜索该错误,您会惊讶于此错误产生的大量问题。
您可能有的最后一个问题是,为什么仅在开发模式下运行此程序? 我猜这是因为不稳定的模型没有框架产生的运行时错误那样严重。 毕竟,它可能在下一个摘要运行中稳定下来。 但是,最好在开发应用程序时通知可能的错误,而不是在客户端调试正在运行的应用程序。
您发现文章中的信息有帮助吗?
![](https://i-blog.csdnimg.cn/blog_migrate/cdebfbb1bd6beedce00ba0f00d79cfed.gif)