[译]Angular-关于`ExpressionChangedAfterItHasBeenCheckedError`你需要知道的一切

[译]Angular-关于`ExpressionChangedAfterItHasBeenCheckedError`你需要知道的一切

技术杂谈小彩虹2021-07-19 6:01:0070A+A-

文章翻译已征得原作者同意,原文链接:link

正文

最近在stackoverflow上总看有人问到使用Angular时会报 ExpressionChangedAfterItHasBeenCheckedError 的错误。大部分提问的人不太明白Angular的变化监测机制,或者不明白为什么需要这个报错信息。一些开发者甚至认为这是个bug,但是这其实不是bug。

本篇文章将深入讲解导致这个错误的原因与被监测到的原理,还会展示该错误经常出现的场景,并且给出几个可行的解决方案。在最后一章将会解释Angular为什么需要这个监测机制。

关于变化监测

每个Angular应用都是以组件树的形态呈现的。Angular在变化监测阶段会按以下的顺序对每个组件执行操作(List1):

还有一些其他的操作在变化监测阶段被执行,我在这篇文章中详细列出了这些流程:Everything you need to know about change detection in Angular

每一步操作后,Angular会保存与这次操作有关的values值,这个值被存在组件view的 oldValues 属性中。(开发模式下)在所有组件完成变化监测之后Angular会开始下一个监测流程,第二次监测流程并不会再次执行上面列出的变化监测流程,而会比较之前变化监测循环保存的值(存在oldValues中的)与当前监测流程的值是否一致(List2):

  • 检查被传递到子组件的values(oldValues)与当前组件要被用于更新的values(instance.value)是否一致
  • 检查被用于更新DOM元素的values(oldValues)与当前要被用于这些组件更新的values(instance.value)是否一致
  • 对所有子component执行相同的检查

注意:这些额外的检查(List2)只发生在开发模式下,我会在后面的章节中解释其中原因。

接下来我们来看一个例子。假设你有一个父组件A和一个子组件B,A组件中有两个属性:nametext,A组件的模板中使用了 name 属性:

template: '<span>{{name}}</span>'

然后在模板中加入B组件,并且通过输入属性绑定给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在开始变化监测后会发生什么呢?(List1)变化监测会从A组件开始检查,第一步将 text 表达式中的 A message for the child component 向下传递到B组件,并且将这个值存在view上:

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组件执行相同的操作(List1),一旦B组件完成以上的操作,此次变化监测循环便完成了。

如果Angular在开发模式下运行,那么将会执行另一个监测流程(List2)。text 属性在传递给B组件时的值是 A message for the child component 并存入 oldValues ,现在想象一下A组件在此之后将 text 的值更新为 updated text。然后List2的第一步将会检查 text 属性是否被改变:

AComponentView.instance.text === view.oldValues[0]; // false
'updated text' === 'A message for the child component'; // false

这个时候Angular就该抛出这个错误了

ExpressionChangedAfterItHasBeenCheckedError

同理,如果更新已经被渲染在DOM中并且被存在 oldValues 中的 name 属性,也会抛出相同的错误

AComponentView.instance.name === view.oldValues[1]; // false
'updated name' === 'I am A component'; // false

现在你可能会有些疑惑,这些值怎么会被改变呢?我们接着往下看。

数据改变的原因

罪魁祸首一般都是子组件或指令,下面我们来看一个简单的案例。我会先用尽可能简单的例子来重现场景,稍后也会给出真实场景下的例子。大家都知道父组件能使用子组件或指令,这里给出一个父组件为A,子组件为B,并且B组件有一个绑定属性 text 。我们将在子组件的 ngOnInit (此时数据已绑定)生命周期钩子中更新 text 属性:

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'.

现在我们对被用于父组件模板的 name 属性做相同的操作:

ngOnInit() {
    this.parent.name = 'updated name';
}

这时候程序并没有报错,为什么会这样呢?

如果你仔细看变化监测(List1)的执行顺序,你会发现子组件的 ngOnInit 将在当前component的DOM更新之前被调用(在记录oldValues前改变了数据),这就是为什么上面的例子中更改 name 属性却不会报错。我们需要一个在DOM中values更新之后的钩子来做实验, 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'.

当然现实中遇到的情况会更加错综复杂,父组件中属性在二次监测之前被更新通常是使用的外部服务或observabals间接导致的。但是其本质原因是相同的。

现在我们来看一些真实案例。

共享服务

例子:plunker。这个应用中父组件和子组件共用一个共享服务,子元素通过共享服务设置一个属性的值并反映到父元素上。这个模式下子元素改变父元素的值的方式并不像上面简单例子中那么显而易见,是间接更新了父元素的属性。

同步事件广播

例子:plunker。这个应用中父元素监听一个子元素广播的事件,这个事件导致父元素的属性被更新,这个属性又被用于子元素的Input绑定。这同样间接更新了父元素的属性。

动态的组件实例化

这种模式与之前两种模式略有不同,前两种模式都是List2中的第一步检测抛出的错误,而这种模式是由DOM更新检测(List2第二步)抛出的错误。例子:plunker。这个应用中父组件在 ngAfterViewInit 生命周期中动态添加子组件,该生命周期发生在当前组件DOM初次更新之后,而添加子组件将会修改DOM结构,那么前后两次DOM中所使用的values值就不同了(前提是子组件带有新的value引用),所以抛出了错误。

可行解决方案

如果你仔细看报错信息的最后一句:

Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook ?

动态创建组件的情况下,解决这个问题最好的方案是改变创建组件时所处的生命周期钩子。比如之前章节中动态创建组件的流程就可以被移到 ngOnInit 中。即使文档中说明了 ViewChildren 只能在 ngAfterViewInit 之后被获取到,但是创建视图时就在填充子组件了,所以能提前获取 ViewChildren

如果你google过这个错误,那么你应该看过一些回答推荐使用异步更新数据和强制增加一个变化监测循环两种方法来解决这个错误。即使我把这两种方法也列出来了,我也更推荐重新设计你的应用而不是使用这两种方法来解决这个问题,我将会在后面的文章给出理由。

异步更新

你应该注意到一件事,不管是变化监测还是第二次的验证digest都是同步执行的。这意味着如果我们在代码中异步更新属性的值,那么在第二次验证循环运行时这些属性是不会被改变的,那么也就不会报错了。让我们来试一下:

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 回调将函数加入当前VM周期其他同步代码被执行完之后:

Promise.resolve(null).then(() => this.parent.name = 'updated name');

Promise.then 并不会被放入macrotask,而是创建一个microtask。microtask队列将在当前周期中所有同步代码被执行完毕之后执行,因此属性的更新会发生在验证步骤之后。想学习更多关于micro和macro task在Angular中的应用可以看这篇文章:I reverse-engineered Zones (zone.js) and here is what I’ve found

EventEmitter 传一个 true 能使事件的emit变为异步:

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();
    }

嗯,一样没有报错,好像可以很开心的运行程序了。其实这里有个问题,当在父组件A中触发新添加的变化监测时,Anuglar同样会为所有的子组件运行一次变化监测,那么父组件可能会被又一次更新。

为什么需要第二次监测循环

Angular强制使用至上而下的单向数据流,在父元素完成变化监测之后不允许内部子组件在第二次变化监测前改变父组件的属性。这能确保第一次变化监测后的组件树是稳定的。如果在监测循环周期里有属性的改变导致依赖这些属性的使用者需要同步更新变化,那么这棵组件树就是不稳定的。上面例子中子组件B依赖父组件的 text 属性,每当属性的值改变,在这些改变被传递到B组件之前这棵组件树都处于不稳定的状态。这同样体现在DOM与属性之间的关系上,DOM作为这些属性的使用者,然后将这些属性渲染到UI界面上。如果某些属性没有同步更新到界面上,用户将会看到错误的界面。

数据流的同步过程发生在文章开头列出的两堆操作中,所以如果你在数据同步过程完成之后再通过子组件修改父组件中的属性会发生什么呢?是的,你留下了一个不稳定的组件树,其中数据变更的顺序将无法预测。大部分时候这将会给用户呈现出一个有错误数据的页面,而且问题的排查将十分困难。

可能你会问了,那为什么不等到组件树稳定之后再进行变化监测呢?答案很简单,组件树可能永远不会稳定下来,一个子组件更新了父组件中的属性,父组件的属性又更新子组件的状态,子组件状态的更新又触发更新父组件的属性...这将是个无限循环。之前我展示了很多组件对属性直接更新或依赖的情况,但实际中的应用对属性的更新和依赖通常是间接,不易排查的。

有趣的是,AngularJS(Angular 1.x)并没有使用单向数据流也能很大程度的保证组件树的稳定。但是我们经常会看到一个臭名昭著的错误 10 $digest() iterations reached. Aborting! 。随便去google一下就能找到大量关于这个错误的问题。

最后一个问题是,为什么第二次循环监测只在开发模式下运行?我猜想这是因为数据层不稳定在框架运行时并不会产生引人关注的错误,毕竟数据在下一次监测循环后就会稳定下来。当然,在开发时期将可能得错误解决总好过在上线后的应用中排查错误。

初次翻译略显生涩,如有错误欢迎指出。

点击这里复制本文地址 以上内容由权冠洲的博客整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

支持Ctrl+Enter提交

联系我们| 本站介绍| 留言建议 | 交换友链 | 域名展示
本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除

权冠洲的博客 © All Rights Reserved.  Copyright quanguanzhou.top All Rights Reserved
苏公网安备 32030302000848号   苏ICP备20033101号-1

联系我们