在Angular中,从子组件更新父组件的值会导致ExpressionChangedAfterItHasBeenCheckedError错误。

19

我有两个组件:ParentComponent > ChildComponent 和一个服务,例如 TitleService

ParentComponent 看起来像这样:

export class ParentComponent implements OnInit, OnDestroy {

  title: string;


  private titleSubscription: Subscription;


  constructor (private titleService: TitleService) {
  }


  ngOnInit (): void {

    // Watching for title change.
    this.titleSubscription = this.titleService.onTitleChange()
      .subscribe(title => this.title = title)
    ;

  }

  ngOnDestroy (): void {
    if (this.titleSubscription) {
      this.titleSubscription.unsubscribe();
    }
  }    

}

ChildComponent 的代码如下:

export class ChildComponent implements OnInit {

  constructor (
    private route: ActivatedRoute,
    private titleService: TitleService
  ) {
  }


  ngOnInit (): void {

    // Updating title.
    this.titleService.setTitle(this.route.snapshot.data.title);

  }

}
这个想法非常简单: ParentController 在屏幕上显示标题。为了始终呈现实际的标题,它订阅TitleService并侦听事件。当标题更改时,事件发生,并更新标题。
ChildComponent加载时,它从路由器获取数据(动态解析),并告诉TitleService使用新值更新标题。
问题在于此解决方案会导致以下错误: Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'undefined'. Current value: 'Dynamic Title'. 看起来该值在变更检测轮次中已更新。
我需要重新排列代码以获得更好的实现,还是必须在某个地方启动另一个变更检测轮次?
- 我可以将setTitle()onTitleChange()调用移动到相应的构造函数中,但我已经读过,在构造函数逻辑中进行任何“繁重”的工作(除了初始化本地属性)被认为是不好的做法。 - 此外,标题应该由子组件确定,因此无法从中提取此逻辑。
更新
我已经实施了一个最小示例,以更好地说明这个问题。您可以在GitHub存储库中找到它。
经过彻底的调查,发现仅在ParentComponent中使用*ngIf="title"时才会出现问题。
<p>Parent Component</p>

<p>Title: "<span *ngIf="title">{{ title }}</span>"</p>

<hr>

<app-child></app-child>

你尝试在ParentComponent中使用ngAfterViewInit而不是ngOnInit了吗? - Unsinkable Sam
你能展示一下你的路由配置吗? - Max Koretskyi
还有组件模板吗? - Max Koretskyi
@Maximus,你能解释一下为什么需要路由和模板吗?我不确定这样做是否有助于解决问题。即使不使用路由器,问题也可以重现,而模板非常简单明了。 - Slava Fomin II
@SlavaFominII,我刚试了一下v4.2的无路由复现,一切都正常运行。请点此查看。你能否放一个Plunker上来? - Max Koretskyi
感谢您抽出时间帮助@Maximus。在您的建议下,我做出了一些重要的发现。我已经更新了问题以进行解释。 - Slava Fomin II
4个回答

20

这篇文章详细解释了ExpressionChangedAfterItHasBeenCheckedError错误的原因和行为。

你的问题有两个可能的解决方案:

1)将 app-child 放在 ngIf 的前面:

<app-child></app-child>
<p>Title: "<span *ngIf="title">{{ title }}</span>"</p>

2) 使用异步事件:

export class TitleService {
  private titleChangeEmitter = new EventEmitter<string>(true);
                                                       ^^^^^^--------------
经过彻底的调查,问题只在使用 *ngIf="title" 时出现。
您所描述的问题并不特定于 ngIf,并且可以通过实现依赖于父输入的自定义指令来轻松再现,该输入在传递给指令后在变更检测期间进行同步更新。
@Directive({
  selector: '[aDir]'
})
export class ADirective {
  @Input() aDir;

------------

<div [aDir]="title"></div>
<app-child></app-child> <----------- will throw ExpressionChangedAfterItHasBeenCheckedError

要了解为什么会发生这种情况,实际上需要对Angular内部特定于变更检测和组件/指令表示的机制有很好的理解。您可以从以下文章开始:

尽管在这个答案中不可能详细解释每一个细节,以下是高层次的解释。在脏检查周期期间,Angular对子指令执行某些操作。其中一个操作是更新输入并在子指令/组件上调用ngOnInit声明周期钩子。重要的是,这些操作按照严格的顺序执行:

  1. 更新输入
  2. 调用ngOnInit

现在您有以下层次结构:

parent-component
    ng-if
    child-component

在执行上述操作时,Angular遵循这种层次结构。因此,请假设当前Angular检查parent-component:

  1. 更新ng-if中的title输入绑定,将其设置为初始值undefined
  2. ng-if调用ngOnInit
  3. child-component不需要进行任何更新
  4. 调用child-componentngOnInit,它会在parent-component上将标题更改为Dynamic Title

因此,当Angular在ng-if上更新属性时,我们最终会面临这样一种情况:title=undefined,但是当变更检测完成后,我们在parent-component上拥有title=Dynamic Title。现在,Angular运行第二个摘要以验证是否没有更改。但是,当它将先前摘要中传递给ng-if的值与当前值进行比较时,它检测到了一个更改并引发错误。

更改parent-component模板中ng-ifchild-component的顺序将导致在Angular更新ng-if属性之前更新parent-component中的属性。


感谢您进行了彻底的调查,这确实有所帮助。我从未考虑过模板内部组件的实际顺序,这很有道理。然而,在模板中更改组件的顺序并不是解决此问题的好方法。还有其他什么我可以做吗?谢谢! - Slava Fomin II
您可以异步设置标题,这将解决问题,这里有类似的问题 - Max Koretskyi
ServiceTitle中的EventEmitter异步化确实有助于缓解这个问题。再次感谢。你能把这个解决方案加到答案里吗?我会很乐意接受它。这是我的标题服务代码:https://gist.github.com/slavafomin/fd94cda8d63c1091e97e22ff6b7336cf - Slava Fomin II

6

您可以尝试使用ChangeDetectorRef,这样Angular就会手动通知有关更改,错误就不会被抛出。
ParentComponent中更改title后,调用ChangeDetectorRefdetectChanges方法。

export class ParentComponent implements OnInit, OnDestroy {

  title: string;
  private titleSubscription: Subscription;

  constructor(private titleService: TitleService, private changeDetector: ChangeDetectorRef) {
  }

  public ngOnInit() {
    this.titleSubscription = this.titleService.onTitleChange()
      .subscribe(title => this.title = title);

    this.changeDetector.detectChanges();
  }

  public ngOnDestroy(): void {
    if (this.titleSubscription
    ) {
      this.titleSubscription.unsubscribe();
    }
  }

}

1
在某些情况下,“快速”解决方案是使用setTimeout()。您需要考虑所有警告(请参见其他人引用的文章),但对于简单的情况,比如只设置标题,这是最简单的方法。
ngOnInit (): void {

  // Watching for title change.
  this.titleSubscription = this.titleService.onTitleChange().subscribe(title => {

    setTimeout(() => { this.title = title; }, 0);

 });
}

如果您还不熟悉所有的变化检测复杂性,可以将此作为最后的解决方案。然后在您理解更多后,再以其他方式进行修复 :)


0
在我的情况下,我正在改变我的数据状态--这个答案需要您阅读AngularInDepth.com关于digest周期的解释--在HTML级别内,我所要做的就是改变我处理数据的方式,像这样:
<div>{{event.subjects.pop()}}</div>

进入

<div>{{event.subjects[0]}}</div>

总结:我使用了正常的方式获取我的数据而不改变其状态,从而避免了异常,而不是弹出(pop)——弹出会返回并删除数组的最后一个元素,从而改变我的数据状态。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接