Angular 的数据流是单向数据流,违反数据流走向会引起该问题
来自 https://indepth.dev/everything-you-need-to-know-about-the-expressionchangedafterithasbeencheckederror-error/这篇文章,解释为什么发生这种错误
似乎最近在stackoverflow上几乎每天都有一个关于Angular抛出的ExpressionChangedAfterItHasBeenCheckedError错误的问题。通常,出现这些问题是因为Angular开发人员不理解更改检测是如何工作的,以及为什么需要进行产生这种错误的检查。许多开发人员甚至认为这是一个bug。但它肯定不是。这是一种预防机制,用于防止模型数据和UI之间的不一致,从而使错误的或旧的数据不在页面上显示给用户。
相关变更检测操作
一个运行的Angular应用程序是一个组件树。在变更检测过程中,Angular会对每个组件进行检查,这些检查包括以下按照指定顺序执行的操作:
- 更新所有子组件/指令的绑定属性
- 在所有子组件/指令上调用ngOnInit, OnChanges和ngDoCheck生命周期钩子
- 更新当前组件的DOM
- 为子组件运行更改检测
- 为所有子组件/指令调用ngAfterViewInit生命周期钩子
在变更检测过程中还会执行其他操作,在这篇文章中介绍 https://indepth.dev/everything-you-need-to-know-about-change-detection-in-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 AComponent {name = 'I am A component';text = 'A message for the child component`;...}
这就是Angular运行变化检测时发生的事情。它从检查组件开始。列表中的第一个操作是更新绑定,以便它将文本表达式计算为子组件的消息,并将其向下传递给B组件。它还将这个值存储在视图中:
view.oldValues[0] = 'A message for the child component';
然后调用列表中提到的生命周期钩子。
现在,它执行第三个操作,并对文本I am A组件的表达式{{name}}求值。它用这个值更新DOM,并将计算值放入旧值中:
view.oldValues[1] = 'I am A component';
然后,Angular执行下一个操作,并对子组件B执行同样的检查。一旦检查了B组件,当前摘要循环就完成了。
如果Angular在开发模式下运行,那么它会运行第二个摘要,执行我上面列出的验证操作。现在想象一下,在Angular将子组件的值消息传递给B组件并存储它之后,以某种方式将A组件上的属性文本更新为更新后的文本。所以它现在运行验证摘要,第一个操作是检查属性文本没有改变:
AComponentView.instance.text === view.oldValues[0]; // false'A message for the child component' === 'updated text'; // false
所以Angular抛出了changedafterithasbeencheckederror错误表达式。
第三个操作也是如此。如果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呈现的操作通常通过使用服务或可观察对象间接完成。但根本原因总是一样的。
现在让我们看看一些现实世界中导致错误的常见模式。
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中的宏任何和微任务,可以前往https://blog.angularindepth.com/i-reverse-engineered-zones-zone-js-and-here-is-what-ive-found-1f48dc87659b
如果你正在使用EventEmitter,你可以通过true选项来实现异步:
new EventEmitter(true);
强制变更检测
另一个可能的解决方案是在第一种方案和认证阶段之间,为父级的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()迭代。流产!错误。继续处理这个错误,您会对这个错误产生的问题数量感到惊讶。
最后一个问题是,为什么只在开发模式中运行它?我想这是因为一个不稳定的模型并不像框架产生的运行时错误那样严重。毕竟,它可能在下一次消化运行中稳定下来。但是,在开发应用程序时得到可能出错的通知,比在客户端调试运行中的应用程序要好。
