您需要了解有关"ExpressionChangedAfterItHasBeenCheckedError"错误的所有信息

最近在stackoverflow上几乎每天都有关于ExpressionChangedAfterItHasBeenCheckedError的提问,通常提出这些问题是因为Angular开发人员不了解更改检测的工作原理,以及为什么检查产生这个错误是必须的。许多开发人员甚至将其视为错误。但这当然不是。这是一种警告机制,可防止模型数据与UI之间出现不一致,以免在页面上向用户显示错误或旧数据。

本文介绍了错误出现的底层原因以及变更检查的机制,并提供了一些可能导致错误的常见模式,并提出了一些可能的修复方法。最后一章说明了此检查为什么很重要。

1. 相关的变更检查操作

正在运行的Angular应用程序是一棵组件树。在变更检测期间,Angular对每个组件执行检查,该组件包括按指定顺序执行的以下操作:

在变更检测期间还有其他操作,请参考你需要了解的有关Angular中变更检测的所有信息

每次操作后,Angular都会记住用于执行操作的值,它们被存储在组件视图的oldValues属性里。 在完成对所有组件的检查之后,Angular然后开始下一个脏检查循环,但不执行上面列出的操作,而是将当前值与上一个脏检查循环所记住的值进行比较:

  • 检查传递给子组件的值是否与现在用于更新这些组件的属性的值相同
  • 检查用于更新DOM元素的值是否与现在用于更新这些元素的值相同
  • 对所有子组件执行相同的检查

请注意,此附加检查仅在开发模式下执行。我将在文章的最后一部分中解释原因。

让我们来看一个例子。假设您有一个父组件A和一个子组件B。该A组件具有nametext属性。在其模板中,它使用引用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轮询中运行。也可以通过Promisethen回调开启一个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!错误。如果搜索该错误,您会惊讶于关于该错误的大量问题。

您可能有的最后一个问题是,为什么仅在开发模式下运行此程序?我猜这是因为不稳定的模型没有框架产生的运行时错误那样严重。毕竟,它可能在下一个脏检查运行中稳定下来。但是,最好在开发应用程序时通知可能的错误,而不是在客户端调试正在运行的应用程序。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值