变更检测
变更检测:就是通过检测视图与状态之间的变化,在状态发生了变化之后,帮助我们更新视图。这种将视图与数据同步的机制叫做变更检测。
变更检测的触发时机:
Demo01:个计数器组件,点击按钮Count会一直加 1
@Component({
selector: 'app-father',
template: `
·····-* Count:{{ num }}
<button (click)="clickMethod()">Modify</button>
`,
styleUrls: ['./father.component.less'],
})
export class FatherComponent implements OnInit, DoCheck, OnChanges, AfterContentInit, AfterViewInit, AfterContentChecked, AfterViewChecked, OnDestroy {
num:number = 0;
ngOnInit(): void {
console.log('Father ngOnInit');
}
clickMethod(){
this.num++;
}
}
Demo02:一个Todo List的组件,通过Http获取数据后渲染到页面
@Component({
selector: "app-todos",
template: ` <li *ngFor="let item of todos">{{ item.titme }}</li> `,
})
export class TodosComponent implements OnInit {
public todos: TodoItem[] = [];
constructor(private http: HttpClient) {}
ngOnInit() {
this.http.get<TodoItem[]>("/api/todos").subscribe((todos: TodoItem[]) => {
this.todos = todos;
});
}
}
从上述Demo可以看出,在两种情况下触发了变更检测
- 点击事件发生时
- 通过http请求远程数据时
由此可以得出:只要发生了异步操作,Angular就会认为有状态可能发生了变化,然后进行变更检测。 简而言之发生以下事件之一,Angular将会触发变更检测
- 任何浏览器事件(click、keydown等)
- setInterval()和setTimeout()
- Http通过XMLHttpRequest进行请求
Angular如何订阅异步事件执行变更检测?
Angular从订阅异步事件状态到触发变更检测,这中间就要看Zone.js了
Zone.j
Zone.js提供了一种称为区域(Zone)的机制,用于封装和拦截浏览器的异步活动,还提供异步生命周期的钩子和统一的异步错误处理机制。
Zone.js是一种信号机制,Angular 用它来检测应用程序状态何时可能已更改。它捕获异步操作,比如 setTimeout、网络请求和事件侦听器。Angular 会根据来自 Zone.js 的信号安排变更检测
简单理解就是:Angular通过Zone.js创建了一个自己的区域并称之为NgZone,Angular应用中所有的异步操作都运行在这个区域中。
变更检测是如何工作的?
Angular的核心就是组件化,组件的嵌套会使得最终形成一棵组件树。
Angular在生成组件的同时,还会为每一个组件生成一个变更检测器changeDetector,用来记录组件的数据变化状态,由于一个Component会对应一个changeDetector,所以changeDetector同样也是一个树状结构的组织。
在组件中我们可以注入changeDetectorRef来获取组件的changeDetector
@Component({
selector: "app-todos",
...
})
export class TodosComponent{
constructor(cdr: ChangeDetectorRef) {}
}
在创建一个Angular应用后,Angular会同时创建一个ApplicationRef的实例,这个实例代表的就是当前创建的这个Angular应用的实例。ApplicationRef创建的同时,会订阅NgZone中的OnMicrotaskEmpty事件,在所有的微任务完成后调用所有的视图的detectChanges()来执行变更检测。
class ApplicationRef {
// ViewRef 是继承于 ChangeDetectorRef 的
_views: ViewRef[] = [];
constructor(private _zone: NgZone) {
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick();
});
},
});
}
// 执行变化检测
tick() {
for (let view of this._views) {
view.detectChanges();
}
}
}
单向数据流
什么是单项数据流? 变更检测都是从根组件开始,沿着整颗组件树从上到下的执行每个组件的变更检测,在默认情况下,直到最后一个叶子Component组件完成变更检测达到稳定状态。在这个过程中,一旦组件完成变更检测以后,在每一次事件触发变更检测之前,它的子孙组件都不允许去更改父组件的变更检测相关属性状态。
栗子:
@Component({
selector: "app-parent",
template: `
{{ title }}
<app-child></app-child>
`,
})
export class ParentComponent {
title = "我的父组件";
}
@Component({
selector: "app-child",
template: ``,
})
export class ChildComponent implements AfterViewInit {
constructor(private parent: ParentComponent) {}
ngAfterViewInit(): void {
this.parent.title = "被修改的标题";
}
}
出现这个错误是因为违反了单向数据流,ParentComponent完成变更检测达到稳定状态后,ChildComponent又改变了ParentComponent的数据使得ParentComponent需要再次被检查,这是不被推荐的数据处理方式。在开发模式下,Angular会进行二次检查,如果出现上述情况,二次检查会报错:ExpressionChangedAfterItHasBeenCheckedError,在生产环境中则只会执行一次。
并不是所有的生命周期去调用都会报错、
@Component({
selector: "app-child",
template: ``,
})
export class ChildComponent implements OnInit {
constructor(private parent: ParentComponent) {}
ngOnInit(): void {
this.parent.title = "被修改的标题";
}
}
此时代码运行正常,Angular检测执行的顺序:
- 更新所有子组件绑定的属性
- 调用所有子组件生命周期的钩子 OnChanges, OnInit, DoCheck ,AfterContentInit
- 更新当前组件的DOM
- 调用子组件的变换检测
- 调用所有子组件的生命周期钩子 ngAfterViewInit
ngAfterViewInit是变更检测之后执行的,在变更检测后我们更改了父组件的数据,在Angular执行开发模式下的第二次检查时,发现与上一次的值不一致,所以报错,而ngOninit的执行在变更检测之前,所以一切正常。
变更检测的性能
默认情况下,当我们的组件中的某个值发生变化触发了变更检测,那么Angular会从上往下检查所有的组件。 不过Angular对每个组件进行变更检测的速度非常快,因为使用内联缓存,在几毫秒内执行数千次检查,其中内联缓存可生成对VM友好代码。
尽管Angular进行了大量优化,在遇到大型应用,变更检测的性能仍会下降,所以需要用一些其他的方式来优化应用。
变更检测的策略
Angular提供了两种运行变更检测的策略:
DefaultOnPush
Default
默认情况下,Angular使用ChangeDetectionStrategy.Default变更检测策略,每次事件触发变更检测(如用户事件、计时器、XHR、Promise等)时,此默认策略会从上到下检查组件树中的每个组件。这种对组建的依赖关系不做任何假设的保守检查方式称为脏检查,这种策略在应用组件过多时会对我们的应用产生性能的影响。
OnPush
Angular还提供了一种OnPush策略,可以修改组件装饰器的changeDetection来更改变更检测的策略。
@Component({
selector: 'app-demo',
// 设置变化检测的策略
changeDetection: ChangeDetectionStrategy.OnPush,
template: ...
})
export class DemoComponent {
...
}
设置为 OnPush 策略后,Angular 每次触发变化检测后会跳过该组件和该组件的所有子组件变化检测
在OnPush策略下,只有以下这几种情况才会触发组件的变更检测:
- 输入值(@Input)更改
- 当前组件或子组件之一触发了事件
- 手动触发变更检测
- 使用async管道后,observable值发生了变化
1、输入值(@Input)更改
在默认的变更检测中,Angular将在@Input()数据发生更改或修改时执行变更检测,使用该OnPush时,传入@Input的值必须是一个新的引用才会触发变更检测。
JavaScript有两种数据类型,值类型和引用类型,值类型包括:number、string、boolean、null、undefined,引用类型包括:Object、Arrary、Function,值类型每次赋值都会分配新的空间,而引用类型比如Object,直接修改属性是引用是不会发生变化的,只有赋一个新的对象才会改变引用。
2、当前组件或子组件之一触发了事件
如果OnPush组件或其子组件之一触发事件,例如 click,则将触发变化检测(针对组件树中的所有组件)。
需要注意的是在OnPush策略中,以下操作不会触发变化检测:
- setTimeout()
- setInterval()
- Promise.resolve().then()
- this.http.get('...').subscribe()
3、手动触发变更检测
三种手动触发变更检测的方法:
- detectChanges() : 它会触发当前组件和子组件的变化检测
- markForCheck() :它不会触发变化检测,但是会把当前的OnPush组件和所以的父组件为OnPush的组件 标记为需要检测状态,在当前或者下一个变化检测周期进行检测
- ApplicationRef.tick() : 它会根据组件的变化检测策略,触发整个应用程序的更改检测
4、使用async管道
内置的 AsyncPipe 订阅一个 observable 并返回它发出的最新值。
每次发出新值时的内部AsyncPipe调用 markForCheck
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref.markForCheck();
}
}
组件的生命周期
生命周期钩子分类
-
指令与组件共有的钩子
- ngOnChanges
- ngOnInit
- ngDoCheck
- ngOnDestroy
-
组件特有的钩子
- ngAfterContentInit
- ngAfterContentChecked
- ngAfterViewInit
- ngAfterViewChecked
Constructor: 使用简单的值对局部变量进行初始化
ngOnChanges(): 当被绑定的输入属性的值发生变化时调用(父子组件传值的时候会触发)
ngOnInit(): Angular第一次显示数据绑定和设置组件/指令的输入属性后,初始化组件或指令,对该组件进行准备
ngDoCheck(): 该函数在ngOnInit函数之后触发可以做一些自定义的操作,比如查看数据是否改变,在发生Angular无法或不愿意自己检测的变化时作出反应。
ngAfterContentInit(): 把内容投影进组件之后调用,组件渲染完成后触发,第一次ngDoCheck()之后调用,只调用一次。
ngAfterContentChecked(): 每次完成被投影组件内容的变更检测之后调用,组件初始化渲染完成后,做一些自定义操作ngAfterContentInit() 和每次ngDoCheck()之后调用。
ngAfterViewInit(): 初始化完组件视图及其子视图之后调用(dom操作放在这里),组件视图及子视图初始化完成后调用,该函数一般进行dom操作,第一次ngAfterContentChecked()之后调用,只调用一次。
ngAfterViewChecked(): 每次做完组件视图和子视图的变更检测之后调用,ngAfterViewInit函数之后做一些自定义的操作,ngAfterViewInit()和每次ngAfterContentChecked() 之后调用
ngOnDestroy(): 组件销毁时触发
参考文档: