详解Angular中的变更检测(八)- 另一个常见错误

206 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情

在上一篇文章中,我们介绍了一个常见问题,其实上,在日常开发工作中要比例子中的问题复杂很多。我们再来看一个父子组件常见的问题。

error二

父组件

@Component({
  selector: "app-parent",
  template: `
    {{ count }}
    <app-child></app-child>
  `, 
})
export class ParentComponent {
  count = 0
}

子组件

@Component({
  selector: "app-child",
  template: ``, 
})
export class ChildComponent {
  constructor(private parent: ParentComponent) {}

  ngOnInit(): void {
    this.parent.count++;
  }
}

报错: ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.

这个错误和上一个例子中的错误一样,上一个例子我们知道在 ngAfterViewInit 中不能改组件的状态。 这个例子中,在子组件的ngOnInit中也不能改变其父元素的状态。

这是为什么呢?

因为,在上面的例子中,ParentComponent 完成变化检测达到稳定状态后(完成了变更检测),ChildComponent 又改变了 ParentComponent 的数据。所以,再次检查数据的时候,发现ParentComponent 数据不一致,就报错了。

那如果我想在子组件中改变父组件的数据,应该怎么办呢?其实在实际场景中,也不会在ngOnInit中去改变父组件的值。

把上面代码改一下。

父组件

@Component({
  selector: "app-parent",
  template: `
    {{ count }}
    <app-child [actionSubject$]="actionSubject$"></app-child>
  `, 
})
export class ParentComponent {
  count = 0;
  actionSubject$: Subject<number> = new Subject();

  ngOnInit(){
    this.actionSubject$.subscribe((data: number) => {
        this.count += data;
    });
  }
}

子组件

@Component({
  selector: "app-child",
  template: `<button (click)="changeClick()">Change</button>`, 
})
export class ChildComponent {
  @Input() actionSubject$!: Subject<any>;
  
  constructor(private parent: ParentComponent) {}

  ngOnInit(): void {
    
  }
  
  changeClick() {
    // this.parent.count++;
    this.actionSubject$.next(1);
  }
}

那为什么在onOninit中改变父组件数据会报错呢?这需要了解下变更检测的执行顺序。

变更检测执行顺序

  1. 更新组件绑定的属性;
  2. 调用组件生命周期的钩子 ngOnChanges, ngOnInit, ngDoCheck
  3. 完成组件变更检测,更新组件的DOM;
  4. 调用子组件的 ngOnChanges, ngOnInit, ngDoCheck, ngAfterViewInit
  5. ngAfterViewInit之前完成子组件变更检测,更新组件的DOM;
  6. 调用组件的生命周期钩子 ngAfterViewInit

根据上面,我们知道 ngAfterViewInit是在变更检测之后执行的,在父组件执行变化检测后我们更改了父组件的数据,在父组件执行完ngAfterViewInit后,Angular执行开发模式下的第二次检查时,发现与上一次的值不一致,所以报错。

AngularJS采用的是双向数据流,错综复杂的数据流使得它不得不多次检查,使得数据最终趋向稳定。

例子

父组件

@Component({
  selector: "app-parent",
  template: `
    {{count}}
    <app-child [options]="options"></app-child>
  `, 
})
export class ParentComponent {
  count = 0;
  options: any = { name: 'Tom' };

  ngOnInit(){
    
  }
}

子组件

@Component({
  selector: "app-child",
  template: `<h1>{{options.name}}</h1>`, 
})
export class ChildComponent {
  @Input() options: any;
  
  constructor(private parent: ParentComponent) {}

  ngOnInit(): void {
    this.options.name = `Jerry`;
  }
}

上面代码有什么问题吗?

我们发现它并不会报错,但是违反了单项数据流原则,不建议这么去写。那问题来了,它为什么不会报错?好好思考下哦。

@Component({
  selector: "app-parent",
  template: `
    {{options.name}}
    <app-child [options]="options"></app-child>
  `, 
})
export class ParentComponent {
  count = 0;
  options: any = { name: 'Tom' };

  ngOnInit(){
    
  }
}

一定要注意,参与变更检测的组件状态(也就是在视图中被引用过),才会被checkNoChanges到。

总结

每次触发变化检测,都会从根组件开始,沿着整棵组件树从上到下的执行每个组件的变更检测,默认情况下,直到最后一个叶子 Component 组件完成变更检测达到稳定状态。在这个过程中,一但父组件完成变更检测以后,在下一次事件触发变更检测之前,它的子孙组件都不允许去更改父组件的变化检测相关属性状态的。

是不是现在已经非常清楚这个问题产生的原因了吧。