重新认识ExpressionChangedAfterItHasBeenCheckedError

1,146 阅读6分钟

前言

很多人在做Angular开发的时候,几乎都遇到过ExpressionChangedAfterItHasBeenCheckedError这个问题,通常大家可能不理解为何会产生这个错误,甚至很多开发者认为这是 Angular 框架的一个 bug,因为这个问题只会在开发时出现,线上并不会出现这个问题,其实这是 Angular 的预警机制\color{#13c078}{预警机制},防止由于模型数据(model data)与视图 UI 不一致,导致页面上存在错误或过时的数据展示给用户。接下里,我们深入理解一下这个预警机制~

到底为什么会出现这个报错呢?

其实最主要的还是Angular的变更检测行为\color{#13c078}{变更检测行为},以及Angular 实行的是从上到下的单向数据流\color{#13c078}{单向数据流},当父组件改变值已经被同步后,不允许子组件去更新父组件的属性,这样确保在第一次 digest loop 后,整个组件树是稳定的。

具体的变更检测步骤

一个运行的 Angular 程序其实是一个组件树,在变更检测期间,Angular 会按照以下顺序检查每一个组件:

  • 更新所有子组件/指令的绑定属性
  • 调用所有子组件/指令的 ngOnInit,OnChanges,ngDoCheck
  • 更新当前组件的DOM
  • 为子组件执行变更检测(同上三个步骤)
  • 调用所有子组件/指令的ngAfterViewInit 生命周期函数 在每一次操作后,Angular 会记下执行当前操作所需要的值,并存放在组件视图的 oldValues 属性里,在所有组件的检查更新操作完成后,Angular 并不是马上接着执行上面列表中的操作,而是会开始下一次digest cycle ,即 Angular 会把来自上一次 digest cycle 的值与当前值比较,过程如下:

这些检查只在开发环境\color{#13c078}{开发环境}下执行

  • 检查已经传给子组件用来更新其属性的值,是否与当前将要传入的值相同
  • 检查已经传给当前组件用来更新 DOM 值,是否与当前将要传入的值相同
  • 针对每一个子组件执行相同的检查

常见的导致此错误的情景

属性值突变

属性值突变的罪魁祸首是子组件或指令,一起看一个简单证明示例吧。你可能知道子组件或指令可以注入它们的父组件,假设子组件 B 注入它的父组件 A,然后更新绑定属性 text。我们在子组件 B 的 ngOnInit 生命周期钩子中更新父组件 A 的属性,这是因为 ngOnInit 生命周期钩子会在属性绑定完成后触发,会导致oldValue与curValue不同,所有控制台会出现这个错误提醒~

 // A组件(父组件)
 
 @Component({
 selector: 'my-app',
 template: `
   <span>{{ name }}</span>
   <b-comp [text]="text"></b-comp>
 `,
 styleUrls: ['./app.component.css']
})

export class AComponent {
 name = 'I am A component';
 text = 'A message for the child component';
 }
 
 
 // B组件(子组件)
 
 @Component({
 selector: 'b-comp',
 template: `
   <span>我是B组件</span>
 `
})
export class BComponent {
 @Input() text;

 constructor(private parent: AppComponent) {}

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

截屏2021-08-01 下午4.11.38.png

同步事件广播

当程序设计为子组件抛出一个事件,而父组件监听这个事件,而这个事件会引起父组件属性值发生改变。同时这些属性值又被父组件作为输入属性绑定传给子组件。这也是非直接父组件属性更新,其实也是针对的属性突变导致的~ 同样我们还使用A,B组件稍微还原一下这种场景~

 // A组件(父组件)
 
 @Component({
  selector: 'my-app',
  template: `
    <span>{{ name }}</span>
    <b-comp [text]="text" (change)="update($event)"></b-comp>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, AfterViewInit {
  name = 'I am A component';
  text = 'A message for the child component';
  constructor(private cd: ChangeDetectorRef) {}

  update(value) {
    this.text = value;
  } }
 
 
 
 // B组件(子组件)
 
 @Component({
  selector: 'b-comp',
  template: `
    <span>{{ name }}</span>
  `
})
export class CComponent {
  name = 'I am B component';
  @Input() text;
  @Output() change = new EventEmitter();

  constructor() {}

  ngOnInit() {
    this.change.emit('updated text');
  }
}

截屏2021-08-01 下午4.19.49.png

动态组件实例化

当为父组件在 ngAfterViewInit 生命周期钩子动态添加子组件。因为添加子组件会触发 DOM 修改,并且 ngAfterViewInit 生命周期钩子也是在 DOM 更新后触发的,所以同样会抛出错误。如上,我们依旧使用A,B组件简单的实现一下。

// A组件(父组件)

@Component({
  selector: 'my-app',
  template: `
    <span>{{ name }}</span>
    <ng-container #vc></ng-container>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit {
  @ViewChild('vc', {read: ViewContainerRef}) vc;
  name = 'I am A component';

  constructor(private r: ComponentFactoryResolver) {}

  ngAfterViewInit() {
      const f = this.r.resolveComponentFactory(BComponent);
      this.vc.createComponent(f);
  }


// B组件(子组件)

@Component({
  selector: 'b-comp',
  template: `
    <span>{{ name }}</span>
  `
})
export class DComponent {
  name = 'I am B component';
}

截屏2021-08-01 下午4.29.00.png

共享服务

但我们在程序中有一个共享的服务,子组件修改了共享服务的某个属性值,响应式地导致父组件的属性值发生改变。我把它称为非直接父组件属性更新,同理也会如上例子所示,报相应的警告错误。

如何去解决这个报错呢?

异步更新

因为Angular的变更检测和核查循环(verification digests)都是同步的,这意味着如果我们在核查循环(verification loop)运行时去异步更新属性值,不会导致错误,相关更改如下:

  // B组件中
  @Component({
  selector: 'b-comp',
  template: `
    <span>我是B组件</span>
  `
})
export class BComponent {
  @Input() text;

  constructor(private parent: AppComponent) {}

  ngOnInit() {
  // 第一种方法
    setTimeout(() => {
      this.parent.text = 'updated text';
    });
    
    // 第二种方法
    Promise.resolve(null).then(() => {
      this.parent.text = 'updated text';
    });
  }
}
  

强制变更检测

另一种解决方案是在第一次变更检测和核查循环阶段之间,再一次迫使 Angular 执行父组件 A 的变更检测。最佳时期是在 ngAfterViewInit 钩子里去触发父组件 A 的变更检测,因为这个父组件的钩子函数会在所有子组件已经执行完它们自己的变更检测后被触发,而恰恰是子组件做它们自己的变更检测时可能会改变父组件属性值.

// A组件
export class AppComponent implements AfterViewInit {
  name = 'I am A component';
  text = 'A message for the child component';
  constructor(private cd: ChangeDetectorRef) {}

  ngAfterViewInit() {
    this.cd.detectChanges();
  }
 
}

但是这个方法并不是很好,如果我们为父组件 A 触发变更检测,Angular 仍然会触发它的所有子组件变更检测,这可能重新会导致父组件属性值发生改变。

结论

其实针对上述的两种解决方案,都不是特别推荐使用,我们在开发中遇到这个问题,更好的解决方案是重新设计您的应用程序,避免此类报错的发生,因为Angular通过抛出错误“表达式在检查后已更改”(仅在开发模式下)来保护我们免于构建难以长期维护的程序。虽然乍一看有点意外,但这个错误非常有帮助,同时Angular 实行的是从上到下的单向数据流,当父组件改变值已经被同步后,不允许子组件去更新父组件的属性,这样确保在第一次 digest loop 后,整个组件树是稳定的。如果属性值发生改变,那么依赖于这些属性的消费者(即子组件)就需要同步,这会导致组件树不稳定。在我们的示例中,子组件 B 依赖于父组件的 text 属性,每当 text 属性改变时,除非它已经被传给 B 组件,否则整个组件树是不稳定的。对于父组件 A 中的 DOM 模板也同样道理,它是 A 模型中属性的消费者,并在 UI 中渲染出这些数据,如果这些属性没有被及时同步,那么用户将会在页面上看到错误的数据信息。