1.什么是变更检测?
变更检测就是Angular检测视图与数据模型之间绑定的值是否发生了改变,当检测到模型中绑定的值发生改变时,就把数据同步到视图上。
2.何时执行变更检测?
我们先看下面这个例子
import {Component, OnInit} from '@angular/core';
import {HttpClient} from '@angular/common/http';
@Component({
selector: 'app-check-test',
templateUrl: './check-test.component.html',
styleUrls: ['./check-test.component.css']
})
export class CheckTestComponent implements OnInit {
content: string = '究竟是谁老是触发检测?';
constructor(private http:HttpClient) {
}
ngOnInit() {
setTimeout(()=>{
this.changeContent();
},3000)
}
changeContent() {
this.content = '又触发了,id是:'+Math.random();
}
xmlRequest(){
this.http.get('../../assets/testcheck.json').subscribe(
(res)=>{
this.changeContent();
}
)
}
}
通过以上例子我们可以总结出来,在异步事件发生的时候可能会使数据模型发生变化。可是angular是如何检测到异步事件发生了呢?这还要说起zone.js。
- 事件:click, mouseover, keyup ...
- 定时器:setInterval、setTimeout
- Http请求:xmlHttpRequest
3.zone.js
官方定义zone.js是javascript的线程本地存储技术,猛地一听感觉好高大上,其实zone.js就是一种用来拦截和跟踪异步工作,为JavaScript提供执行上下文的插件。
那么它是如何感知到异步事件呢,其实方法相当简单粗暴,zone.js采用一种叫做猴子补丁 (Monkey-patched)的方式,将JavaScript中的异步任务都进行了包装,这使得这些异步任务都能运行在Zone(一个全局的对象,用来配置有关如何拦截和跟踪异步回调的规则)的执行上下文中,每个异步任务在 Zone 中都是一个任务(Task),除了提供了一些供开发者使用的钩子外,默认情况下Zone重写了以下方法:
- setInterval、clearInterval、setTimeout、clearTimeout
- alert、prompt、confirm
- requestAnimationFrame、cancelAnimationFrame
- addEventListener、removeEventListener
zone.js部分源码
var set = 'set';
var clear = 'clear';
var blockingMethods = ['alert', 'prompt', 'confirm'];
var _global = typeof window === 'object' && window ||
typeof self === 'object' && self || global;
patchTimer(_global, set, clear, 'Timeout');
patchTimer(_global, set, clear, 'Interval');
patchTimer(_global, set, clear, 'Immediate');
patchTimer(_global, 'request', 'cancel', 'AnimationFrame');
patchTimer(_global, 'mozRequest', 'mozCancel', 'AnimationFrame');
patchTimer(_global, 'webkitRequest', 'webkitCancel', 'AnimationFrame');
通过打印window对象我们可以发现zone.js对异步方法进行了封装,非异步方法并没有处理。
zone.js本身比较庞大复杂,这里不做深入研究,对它的原理感兴趣的可以看一下这篇文章-zone.js。我们这里主要是了解它是怎么配合Angular工作的即可。
在 Angular 源码中,有一个 ApplicationRef 类,其作用是当异步事件结束的时候由 onMicrotaskEmpty执行一个 tick 方法 提示 Angular 执行变更检测及更新视图。
interface ApplicationRef {
get componentTypes: Type<any>[]
get components: ComponentRef<any>[]
get isStable: Observable<boolean>
get viewCount
bootstrap<C>(componentOrFactory: ComponentFactory<C> | Type<C>, rootSelectorOrNode?: string | any): ComponentRef<C>
tick(): void
attachView(viewRef: ViewRef): void
detachView(viewRef: ViewRef): void
}
调用tick方法。其中this._zone 是NgZone 的一个实例, NgZone 是对zone.js的一个简单封装。
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => { this.tick();});
}
});
tick函数对所有附在 ApplicationRef上的视图进行脏检查。
tick() {
if (this._runningTick) {
throw new Error('ApplicationRef.tick is called recursively');
}
const /** @type {?} */ scope = ApplicationRef._tickScope();
try {
this._runningTick = true;
this._views.forEach((view) => view.detectChanges());
if (this._enforceNoNewChanges) {
this._views.forEach((view) => view.checkNoChanges());
}
}
catch (/** @type {?} */ e) {
// Attention: Don't rethrow as it could cancel subscriptions to Observables!
this._zone.runOutsideAngular(() => this._exceptionHandler.handleError(e));
}
finally {
this._runningTick = false;
wtfLeave(scope);
}
}
4.Angular检测机制
Ok,我们现在已经知道Angular怎么监听异步事件了,那么当监测到异步事件后是怎么判断是否需要更新视图呢?其实比较简单,Angular通过脏检查来判断是否需要更新视图。脏检查其实就是存储所有变量的值,每当可能有变量发生变化需要检查时,就将所有变量的旧值跟新值进行比较,不相等就说明检测到变化,需要更新对应视图。当然,实际情况肯定不是这么简单,Angular会通过自己的算法来对数据进行检查,对算法感兴趣的可以参考这篇文章-Angular的脏检查算法。
Angular 应用是一个响应系统,首次检测时会检查所有的组件,其他的变化检测则总是从根组件到子组件这样一个从上到下的顺序开始执行,它是一棵线性的有向树,任何数据都是从顶部往底部流动,即单向数据流。怎么证明呢?看这个例子
app.component.html
<h1>变更检测</h1>
<input type="button" value="改名" (click)="changeName()">
<app-rank-parent [parentName]="grandpaName"></app-rank-parent>
<app-refer [justRefer]="refertitle"></app-refer>
app.component.ts
import {Component, Input, OnChanges, OnInit} from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnChanges {
@Input() grandpaName: string = '赵四';
refertitle:string = '仅仅是个参考组件';
constructor() {
}
ngOnInit() {
console.log('开始变更检测啦!');
}
ngOnChanges(changes) {
console.dir(changes);
}
changeName() {
this.grandpaName = '尼古拉斯·赵四';
this.refertitle = '你看Onpush策略下面Input属性还有效,气不气人';
}
}
rank-parent.component.html
<app-rank-children [childName]="parentName"></app-rank-children>
rank-parent.component.ts
import {Component, Input, OnChanges, OnInit} from '@angular/core';
@Component({
selector: 'app-rank-parent',
templateUrl: './rank-parent.component.html',
styleUrls: ['./rank-parent.component.css']
})
export class RankParentComponent implements OnInit, OnChanges {
@Input()parentName;
constructor() {}
ngOnChanges(changes) {
console.dir(changes);
}
ngOnInit() {}
}
rank-children.component.html
<p>
儿子姓名是:{{childName}}三代
</p>
rank-children.component.ts
import {Component, Input, OnChanges, OnInit} from '@angular/core';
@Component({
selector: 'app-rank-children',
templateUrl: './rank-children.component.html',
styleUrls: ['./rank-children.component.css']
})
export class RankChildrenComponent implements OnInit,OnChanges {
@Input() childName;
constructor() { }
ngOnChanges(changes) {
console.dir(changes);
}
ngOnInit() {
}
}
运行以后我们会得到如下结果,可以看到首次检测时检查了所有组件,包括ReferComponent,检测从上到下逐个检测。点击改名按钮后再次检测时则只检测有变化的那一侧组件(RankParentComponent,RankChildrenComponent)。其中我们可以观察到,虽然在AppComponent中输入属性也发生了变化并且也更新了视图,但是ngOnChanges钩子却没有检测到变化,注意这是一个坑。
那么什么是单向数据流呢?其实简单理解就是angular检测到数据变化到更新完视图的过程中数据是不应该被改变的,如果我们在这期间更改了数据,Angular便会抛出一个错误,举个例子,我们在RankChildrenComponent的ngAfterViewChecked钩子函数中更改childName的值,在控制台会看到如下错误。
import {AfterViewChecked, Component, Input, OnChanges, OnInit} from '@angular/core';
@Component({
selector: 'app-rank-children',
templateUrl: './rank-children.component.html',
styleUrls: ['./rank-children.component.css']
})
export class RankChildrenComponent implements OnInit,OnChanges,AfterViewChecked {
@Input() childName;
constructor() { }
ngOnChanges(changes) {
console.dir(changes);
}
ngOnInit() {
}
ngAfterViewChecked(){
this.childName = "我要试着改变一下"
}
}
如果必须要更改这个属性的值,能不能做呢?答案是可以的。结合刚次提到的单向数据流,如果我们把这次数据变更放到下一轮Angular变更检测中,就能解决这个问题了。怎么做呢?刻意异步一下就行了。是不是很神奇?
ngAfterViewChecked(){
setTimeout(()=>{
this.childName = "我要试着改变一下"
},0)
}
至于angular为什么要采用单向数据流,其实也很好理解,最主要的就是防止数据模型和视图不统一,同时也可以提高渲染的性能。
4.如何优化检测性能
讲了这么多,所以到底有什么用呢?其实在 Angular 中,每一个组件都都它自己的检测器(detector),用于负责检查其自身模板上绑定的变量。所以每一个组件都可以独立地决定是否进行脏检查。默认情况下,变化检测系统将会走遍整棵树(defalut策略),但我们可以使用OnPush变化检测策略,利用 ChangeDetectorRef实例提供的方法,来实现局部的变化检测,最终提高系统的整体性能。
来,举个例子。在ReferComponent中,我们设个定时器2秒以后更新一个非输入属性的值,在默认策略时,可以发现2秒以后视图中的值发生了改变,但是当我们把策略改为Onpush时,除了在AppComponent点击按钮改变输入属性justRefer外,其他属性改变不会引起视图更新,ReferComponent组件的检测也被略过。我们可以这么总结:OnPush 策略下,若输入属性没有发生变化,组件的变化检测将会被跳过。
refer.component.ts
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit} from '@angular/core';
@Component({
selector: 'app-refer',
templateUrl: './refer.component.html',
styleUrls: ['./refer.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferComponent implements OnInit, OnChanges {
@Input() justRefer;
notInput: any = {
tip: ''
};
originData: any = {
tip: '这不是输入属性'
};
constructor() {
}
ngOnInit() {
setTimeout(() => {
this.notInput = this.originData;
}, 2000);
}
ngOnChanges(changes) {
console.dir(changes);
}
}
可是我就是要更改非输入属性怎么办呢?别急,Angular早就为你想好了。在Angular中,有这么一个class:ChangeDetectorRef ,它是组件的变化检测器的引用,我们可以在组件中的通过依赖注入的方式来获取该对象,来手动控制组件的变化检测行为。它提供了以下方法供我们调用
class ChangeDetectorRef {
markForCheck(): void
detach(): void
detectChanges(): void
checkNoChanges(): void
reattach(): void
}
- markForCheck() - 在组件的 metadata 中如果设置了 changeDetection: ChangeDetectionStrategy.OnPush 条件,那么变化检测不会再次执行,除非手动调用该方法。
- detach() - 从变化检测树中分离变化检测器,该组件的变化检测器将不再执行变化检测,除非手动调用 reattach() 方法。
- reattach() - 重新添加已分离的变化检测器,使得该组件及其子组件都能执行变化检测
- detectChanges() - 从该组件到各个子组件执行一次变化检测
现在我们来试试解决刚才那个问题,我们对ReferComponent做如下改造。
constructor(private changeRef:ChangeDetectorRef) {
}
ngOnInit() {
setTimeout(() => {
this.notInput = this.originData;
this.changeRef.markForCheck();
}, 2000);
}
ok,现在看到在Onpush策略下手动修改非输入属性的值,视图也可以及时更新了。其他的几个方法也都大同小异,感兴趣的可以逐个试试。