一文带你速通Angular的变更检测

105 阅读7分钟

前言

在Angular开发过程中,想必你一定遇到过该种错误

image.png

Expression has changed after it was checked是在Angular中经典的变更检测的错误,而这句话的意思是这些更改在检测之后发生了变化。接下来我们聊一聊Angular中变更检测。
复现错误代码

// 父组件
@Component({
  selector: 'app-parent',
  template: `<app-child [data]="value"></app-child>`
})
export class ParentComponent implements AfterViewInit {
  value = 'Initial';

  ngAfterViewInit() {
    // 在视图初始化后修改绑定属性
    this.value = 'Modified after check';
  }
}

// 子组件
@Component({
  selector: 'app-child',
  template: `{{ data }}`
})
export class ChildComponent {
  @Input() data: string;
}

什么是变更检测?

变更检测是框架用于同步组件数据状态与视图的核心机制。其核心目标是自动检测数据变化并更新 DOM,确保视图与底层数据保持一致性,开发者无需手动操作 DOM。也就是在MVVM框架中数据驱动视图的核心。

如何触发变更检测?

用户交互触发(点击事件)

点击按钮后自动触发变更检测,更新按钮显示的文本。

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<button (click)="updateText()">{{ displayText }}</button>`
})
export class AppComponent {
  displayText = '初始文本';

  updateText() {
    this.displayText = '更新后的文本'; 
  }
}

异步操作触发(定时器)

通过 setTimeout 模拟异步操作,当定时器回调完成,自动执行变更检测更新 DOM。

import { Component } from '@angular/core';

@Component({
  template: `<div>{{ asyncData }}</div>`
})
export class AppComponent {
  asyncData = '加载中';

  constructor() {
    setTimeout(() => {
      this.asyncData = '数据已加载'; // 2秒后自动触发变更检测
    }, 2000);
  }
}

HTTP 请求完成触发

当 HTTP 请求返回数据后,Angular 会自动触发变更检测以更新视图。

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-user',
  template: `<div>{{ userData | json }}</div>`
})
export class UserComponent {
  userData: any;

  constructor(private http: HttpClient) {
    // HTTP请求完成后自动触发变更检测
    this.http.get('xxxxxxxxx').subscribe(data => {
      this.userData = data;  // 数据更新后视图自动刷新
    });
  }
}

EventEmitter 事件触发

通过EventEmitter发射事件时,若父组件监听到事件并修改数据,会触发变更检测。
子组件

import { Component, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-child',
  template: `<button (click)="sendMessage()">发送消息</button>`
})
export class ChildComponent {
  @Output() messageEvent = new EventEmitter<string>();

  sendMessage() {
    this.messageEvent.emit('新消息');  // 触发事件发射
  }
}

父组件

@Component({
  selector: 'app-parent',
  template: `
    <app-child (messageEvent)="onMessageReceived($event)"></app-child>
    <div>{{ receivedMessage }}</div>
  `
})
export class ParentComponent {
  receivedMessage: string = '';

  onMessageReceived(message: string) {
    this.receivedMessage = message;  // 数据更新后视图自动刷新
  }
}

Angular中的变更检测策略

在Angular中存在两种变更检测策略,DefaultOnPush,默认情况下使用的为Default策略。

Default

以一段简单的代码为例
父组件

@Component({
  selector: 'app-root',
  template: `
    <h2>父组件计数器: {{ counter }}</h2>
    <button (click)="increment()">增加计数器</button>
    <app-child [childCounter]="counter"></app-child>
  `
})
export class AppComponent {
  counter = 0;
  increment() {
    this.counter++;
  }
}

子组件

@Component({
  selector: 'app-child',
  template: `<h3>子组件接收的计数器: {{ childCounter }}</h3>`
})
export class ChildComponent {
  @Input() childCounter: number;
}

两者的示意图如下

image.png

当用户点击父组件的按钮时,触发increment()方法更新counter值,开始执行变更检测
从根组件开始,按以下顺序执行变更检测

  1. 检测父组件:检查AppComponent的所有绑定(如{{ counter }}),发现counter值变化,更新 DOM。
  2. 检测子组件:遍历到子组件ChildComponent,检查输入属性childCounter的绑定值是否变化。若变化,更新子组件 DOM。
  3. 整个组件树检测完毕后,视图与数据状态完全同步。

但在实际场景中根组件下面往往会挂载多个父组件,父组件下面又会挂载子组件,可能如下图所示

image.png

当组件树中任一组件触发变更检测时,Default 策略会检查整个组件树,从根组件开始递归遍历,检测到对应的变更后更新DOM,达到数据与视图的一致
从这一点可以看到一个Default的一个缺点
即使只有组件A发生了变化,组件B、C、D都没有发生变化,仍然会从根组件开始遍历全部组件,倘若这个组件树的层次很深,节点又很多,使用Default策略就会有不必要的性能浪费了。于是我们迎来了OnPush策略

OnPush

在@Compontent下使用changeDetection来启用OnPush策略

@Component({
    selector: 'app-A',
      // 设置变化检测的策略
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
  export class AComponent {    ...
  }

场景一:父组件A开启OnPush策略,父组件B触发了变更检测

image.png

执行流程

  1. 父组件 B 触发变更检测(例如用户事件、异步操作完成),此时 Angular 会从根组件开始启动变更检测流程。
  2. 检测到父组件A,父组件 A 启用OnPush策略,但此次触发源来自父组件 B。因此,父组件 A 及其子树(A-1、A-2)会被跳过,不执行任何检测逻辑
  3. 检测到父组件B,由于父组件B采用的仍然是Default的策略,将对B、B-1、B-2都进行检测。

从这里可以得到两点信息

  1. 倘若开启OnPush策略的组件没有触发变更检测,那么会直接跳过
  2. 父组件开启的OnPush策略会影响到其子组件

场景二:父组件A开启OnPush策略,父组件A触发了变更检测

image.png

再说场景二之前,我们先明确哪些行为可以让设置了OnPush的组件发生变更检测

  1. 输入属性引用变更
// 父组件
@Component({
  template: `<child [data]="parentData"></child>`
})
export class ParentComponent {
  parentData = { value: 'initial' };

  updateData() {
    this.parentData = { ...this.parentData, value: 'updated' }; // 创建新对象
  }
}

// 子组件(OnPush策略)
@Component({
  selector: 'child',
  template: `{{ data.value }}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
  @Input() data: { value: string };
}
  • 父组件通过updateData方法生成新的parentData对象(引用变更)。
  • 子组件因@Input属性引用变化,触发其变更检测,更新视图显示updated
  1. 内部事件触发
// 子组件(OnPush策略)
@Component({
  selector: 'counter',
  template: `
    <button (click)="increment()">Increment</button>
    {{ count }}
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  count = 0;

  increment() {
    this.count++; // 修改内部状态
  }
}
  • 用户点击按钮触发click事件,Angular 自动执行变更检测,更新视图显示最新的count值。
  1. 手动标记检测
// 组件(OnPush策略)
@Component({
  template: `{{ dynamicValue }}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManualComponent {
  dynamicValue = 'initial';

  constructor(private cdr: ChangeDetectorRef) {}

  updateWithoutRefChange() {
    this.dynamicValue = 'updated'; // 直接修改属性值
    this.cdr.detectChanges();      // 强制立即检测
    // 或 this.cdr.markForCheck(); // 标记待检测(下次周期执行)
  }
}
  • 使用detectChanges或者markForCheck进行变更检测。

回头开头当父组件A满足上述的条件时,整个变更检测的流程如下

  1. 从根组件开始,然后到父组件 A 开始,按深度优先顺序向下检测其所有子组件(包括 A-1、A-2),无论子组件是否使用OnPush策略。
  2. 然后再检测父组件B以及下面的子组件。

关于ChangeDetectorRef

回到开头中关于Expression has changed after it was checked的错误,解决方式大多数是使用ChangeDetectorRefdetectChanges进行强制变更检测。
作用手动控制组件的变更检测,允许开发者优化性能,减少不必要的检测周期,或在特定场景下强制触发更新。
下面介绍一下它的几个相关方法

detectChanges

作用:手动触发当前组件及其子组件的变更检测,适用于需要立即更新视图的场景。
在之前的错误例子中使用detectChanges便可解决

// 父组件
@Component({
  selector: 'app-parent',
  template: `<app-child [data]="value"></app-child>`
})
export class ParentComponent implements AfterViewInit {
  value = 'Initial';
  // 注入ChangeDetectorRef
  constructor(private cdr: ChangeDetectorRef) {}
  ngAfterViewInit() {
    // 在视图初始化后修改绑定属性
    this.value = 'Modified after check';
    this.cdr.detectChanges();//强制更新
  }
}

// 子组件
@Component({
  selector: 'app-child',
  template: `{{ data }}`
})
export class ChildComponent {
  @Input() data: string;
}

markForCheck

作用:标记当前组件及其父组件需要在下一次变更检测周期中被检查。主要与 OnPush 变更检测策略配合使用。

image.png

尽管父组件 A 是OnPush策略,但因其被标记为待检测,所以再下一轮变更检测周期中,Angular 从根组件开始,按深度优先顺序遍历组件树。

detach

作用:将当前组件的变更检测从全局检测周期中分离。

  1. 分离后,组件不会自动更新视图,需手动调用 detectChanges
  2. 用于性能优化,避免不必要检测(如高频事件、静态组件)。

reattach

作用:将组件重新附加到全局变更检测周期。恢复自动检测,与 detach() 配合使用。

总结

本文介绍了Angular的变更检测以及变更检测的策略(DefaultOnPush)以及如何进行手动控制组件的变更检测(ChangeDetectorRef),在日常开发中变更检测是最常见的概念之一,希望能帮到大家针对这块知识有所加深理解。