关于ExpressionChangedAfterItHasBeenCheckedError

时间:2023-11-25 22:18:08

最近在*上似乎每天都有一些关于angular报错‘ExpressionChangedAfterItHasBeenCheckedError’的问题。发生这些问题通常是由于angular的开发者不懂angular变更检测的工作原理,以及为什么这个检测的报错是有必要的。很多开发者甚至认为这是angular的bug。但其实不是的。这是一个用于防止模型数据和ui之间数组不一致的一个警告机制,以便不让用户在页面上看到陈旧的或者错误的数据。

这篇文章解释了该报错和检测机制的潜在原因,提供了几种可能引发报错的通用模式和可能的修复方案。文章最后解释了为什么检测机制是重要的。
一、相关变更检测操作
一个运行中的angular应用是一个组件树。在变更检测期间,angular按照以下的特定顺序检查每一个组件:
1、更新所有子组件/指令绑定的特性
2、调用所有的自组件/指令的ngOnInit, OnChanges和ngDocCheck生命周期勾子
3、更新当前组件的DOM视图
4、为一个子组件运行变更检测
5、调用所有子组件/指令的ngAfterViewInit生命周期勾子
每一步操作后,angular会记住每一步用于操作的值,它们会被保存在控制器视图的oldValues属性中,在对所有的组件进行检查后,angular进入下一个摘要周期(原词是digest cycle,这里不知道怎么翻译更准确),而不是执行上面列表中的操作,它将当前值与上一个摘要周期中保存的值进行比较,过程如下:
1、检查传递给子组件的值与将用于更新这些组件属性的值相同
2、检查用于用于更新dom元素的值与将用于更新这些元素的值相同
3、对所有子组件执行相同的检查
请注意该额外的检查只会在开发模式下执行,我在文章最后一段已经解释了原因。
让我们看一个例子,假设你有一个父组件a和子组件b,a组件有一个‘name’变量和一个‘text’属性,在该例子模版中使用引用名称属性的表达式:
template: '<span>{{name}}</span>'
并且该例子模版中还有一个b控制器,其通过输入属性绑定将text属性传递给该组件。

@Component({
selector: 'a-comp',
template: `
{{name}} `
})
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组件,最后保存到控制视图的oldValues属性中:

view.oldValues[0] = 'A message for the child component';
然后开始调用上述列表中第二步提到的生命周期勾子。
现在开始执行第三步操作以及计算出表单式{{name}}的值为“I am A component”,angular使用该值更新dom视图并且将该值保存到oldValues:

view.oldValues[1] = 'I am A component';
接着angular执行下一步操作,并且为b控制器运行相同的检查。一旦b控制器变更检测完毕,当前的摘要循环即结束。
如果angular是在开发环境下运行的,将会执行第二次的摘要来执行上述列表中的步骤。现在想象一下,在angular把text的值“A message for the child component”传递给b组件并且保存到控制器视图中的oldValues后,text变量值在a组件上被以某种方式更新为“updated text”,现在angular运行验证摘要,然后第一步操作是检查属性text有无改变:

AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false
显而易见,text改变了,也就是在摘要周期中检查传递给子组件的值与将用于更新这些组件属性的值不相同了,所以抛出了ExpressionChangedAfterItHasBeenCheckedError错误。
同样,如果name属性值在被呈现和存储后更新,也会得到相同的报错:

AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false
你现在可能有一个疑问,即这些值是如何改变的,让我们往下看。
二、值改变的可能场景
通常导致变化的罪魁祸首往往是组件或者指令。让我们来简单快速的演示一下。我会使用尽可能最简单的例子,但是之后就要开始展示真实的场景了。你应该知道,子组件和指令能够注入其父组件,因此让我们来将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渲染的父组件属性更新或操作往往是通过服务或者可观察对象模式间接实现的,但是根本原理和原因是相同的。
现在,让我们看一些真实场景下导致错误的共同模式。
1、共享服务
该应用设计为在父组件和子组件之间共享一个服务。子组件为服务设置一个值,继而通过更新父组件的属性实现反应,我称这个父属性的更新为间接的,因为与上面的例子不同,现在子组件更新父组件属性不是非常显著的。
2、同步事件广播(Synchronous event broadcasting)
该应用设计为一个子组件发送一个事件,然后父组件监听这个事件,该事件会导致父组件一些属性值的更新。同时这些属性被用于子组件的输入绑定中。这也是一个间接的父组件属性更新。
3、动态组件实例化(Dynamic component instantiation)ngAfterViewInit
这种模式不同于之前输入绑定受到影响,而是会引起dom更新操作抛出错误。该应用设计为在父组件的ngAfterViewInit中动态的添加一个子组件,由于添加子组件需要dom修改,dom更新后继而触发ngAfterViewInit生命周期钩子,抛出错误。
三、可能的修复解决方案
如果你看一下如下的错误描述
Expression has changed after it was checked. Previous value:…

不禁思考,它是在变化检测勾子中创建的吗?

通常,修复方案即通过正确的变更检测机制来创建动态组件。例如上面章节中的最后一个例子,可以将动态组件的创建过程移到ngOnInit生命周期勾子中,尽管文档说明ViewChild只能在ngAfterViewInit之后使用,但是在创建视图的时候,它归属于子组件,因此可以更早使用。

如果你用谷歌搜索相关资料,你可能会发现针对这个报错的两个最大众化的解决方案 —— 异步属性更新和强制附加变化检测周期,尽管我把这两个解决方案放在了这里,同时还解释了它们的工作原理,但是我不推荐使用这些方案,而是应该重新设计你的应用。这一点我会在文章末尾阐述原因。
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函数调度了宏任务,然后将在下面的vm回调中执行,但是需要在当前同步代码完成之后通过使用promise回调来执行。
Promise.resolve(null).then(() => this.parent.name = 'updated name');
替代宏任务promise.then来创建一个微任务,在当前同步代码完成执行之后,微任务队列被处理,因此在验证步骤之后将发生对属性的更新。了解更多angular中的宏任何和微任务,可以前往I reverse-engineered Zones (zone.js) and here is what I’ve found.(https://blog.angularindepth.com/i-reverse-engineered-zones-zone-js-and-here-is-what-ive-found-1f48dc87659b)

如果你在使用EventEmitter,你可以传递true参数选项来设置异步机制

new EventEmitter(true);
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();
}
}
没有报错,似乎正确工作了,但是该解决方案存在一个问题,当触发对父组件的更新检测时,angular将运行对所有子组件的变更检测,会存在父组件属性更新的可能。
四、为什么需要验证环
Angular从上到下强制执行所谓的单向数据流。 在父级更改处理完毕后,层级中较低的组件不允许更新父组件的属性。 这确保了在第一个摘要循环之后整个组件树是稳定的。 如果需要与依赖于这些属性的使用者同步的属性发生更改,则树不稳定。 在我们的例子中,一个B子组件依赖于父文本属性。 只要这些属性发生更改,组件树就会变得不稳定,直到将此更改传递给子组件B。 DOM也是如此。 它是组件上某些属性的使用者,它在UI上呈现它们。 如果某些属性未同步,用户将在页面上看到不正确的信息。
这个数据同步过程就是变化检测过程中发生的情况 - 特别是我在开始时列出的两个操作。 那么,如果在同步操作执行后更新子组件属性的父属性,会发生什么情况? 对,你留下了不稳定的树,这种状态的后果是不可能预测的。 大多数情况下,您最终会在页面上向用户显示不正确的信息。 这将很难调试。
那么为什么不运行变化检测直到组件树稳定? 答案很简单 - 因为它可能永远不会稳定下来并永远运行。 如果一个子组件更新父组件上的一个属性,作为对该属性更改的反应,则会发生无限循环。 当然,正如我之前所说的,使用直接更新或依赖关系来发现这种模式是微不足道的,但在实际应用程序中,更新和依赖关系通常是间接的。
有趣的是,AngularJS没有单向数据流,因此它试图稳定树。 但它通常会导致臭名昭着的10 $ digest()迭代达成。中止! 错误。 继续,谷歌这个错误,你会惊讶于这个错误产生的问题的数量。
最后一个问题是为什么只在开发模式下运行它? 我想这是因为一个不稳定的模型并不像框架产生的运行时错误那样严重。 毕竟它可能稳定在下一个摘要运行。 但是,开发应用程序时可能出现的错误比在客户端上调试正在运行的应用程序更好。

译自:Everything you need to know about the `ExpressionChangedAfterItHasBeenCheckedError` error

https://blog.angularindepth.com/everything-you-need-to-know-about-the-expressionchangedafterithasbeencheckederror-error-e3fd9ce7dbb4