在使用Angular进行开发中,我们常用到Angular中的绑定——模型到视图的输入绑定、视图到模型的输出绑定以及视图与模型的双向绑定。而这些绑定的值之所以能在视图与模型之间保持同步,正是得益于Angular中的变化检测。
变化检测是什么?
简单来说变化检测就是Angular用来检测视图与模型之间绑定的值是否发生了改变,当检测到模型中绑定的值发生改变时,则同步到视图上,反之,当检测到视图上绑定的值发生改变时,则回调对应的绑定函数。
什么情况下会引起变化检测?
变化检测的关键在于如何最小粒度地检测到绑定的值是否发生了改变,那么在什么情况下会导致这些绑定的值发生变化呢?我们可以看一下我们常用的几种场景:
@Component({selector: 'my-app',template: `{ {name}}
`})export class MyApp {name='Tom';changeName(){this.name='Jerry'}}
我们在视图上通过插值表达式绑定了MyApp中的name属性,当点击按钮时,改变了name属性的值,这时就导致了绑定的值发生了变化。再来看另外一种场景:
@Component({selector: 'my-app',template: `{ {name}}
`,styleUrls: ['./app.component.css']})export class MyApp implements OnInit{name='Tom';constructor(private http:Http){}ngOnInit(){this.http.get('/contacts').map(res=>res.json).subscribe(name=>this.name=name);}}
我们在MyApp这个组件的生命周期钩子函数里向服务器端发送了一个Ajax请求,当这个请求返回结果时,同样会改变当前视图上绑定的name属性的值。类似的,我们还可能设定一些定时任务,这些定时任务也可能会改变与视图绑定的值:
@Component({selector: 'my-app',template: `{ {name}}
`,styleUrls: ['./app.component.css']})export class MyApp implements OnInit{name='Tom';constructor(private http:Http){}ngOnInit(){setTimeout(()=>{this.name='Jerry'},1000);}}
其实,我们不难发现上述三种情况都有一个共同点,即这些导致绑定值发生改变的事件都是异步发生的。如果这些异步的事件在发生时能够通知到Angular框架,那么Angular框架就能及时的检测到变化。
那么Angular框架如何才能获知到这些异步事件的发生呢?我们不妨来看一看异步事件在JavaScript中执行的过程:
左边表示将要运行的代码,这里的stack表示JavaScript的运行栈,而webApi则是浏览器中提供的一些JavaScript的API,TaskQueue表示JavaScript中任务队列,因为JavaScript是单线程的,异步任务在任务队列中执行。
当上述代码在JavaScript中执行时,首先func1 进入运行栈,func1执行完毕后,setTimeout进入运行栈,执行setTimeout过程中将回调函数cb 加入到任务队列,然后setTimeout出栈,接着执行func2函数,func2函数执行完毕时,运行栈为空,接着任务队列中cb 进入运行栈得到执行。可以看出异步任务首先会进入任务队列,当运行栈中的同步任务都执行完毕时,异步任务进入运行栈得到执行。如果这些异步的任务执行前与执行后能提供一些钩子函数,通过这些钩子函数,Angular便能获知异步任务的执行。
事实上,Angular正是使用Zonejs(它描述JavaScript执行过程的上下文,可以在异步任务之间进行持久性传递,它类似于Java中的TLS)来做到的。Zonejs通过猴子补丁的方式,对webApi中的一些异步任务的API在运行时进行替换,替换后的API提供了一些钩子函数。
变化检测是个什么样的过程?
通过上面的介绍,我们大致明白了变化检测是如何被触发的,那么Angular中的变化检测是如何执行的呢?首先我们需要知道的是,对于每一个组件,都有一个对应的变化检测器;即每一个Component都对应有一个changeDetector,我们可以在Component中通过依赖注入来获取到changeDetector。而我们的多个Component是一个树状结构的组织,由于一个Component对应一个changeDetector,那么changeDetector之间同样是一个树状结构的组织。最后我们需要记住的一点是,每次变化检测都是从树根开始的。
枯燥无味的理论到此结束,下面通过一些例子来直观的感受一下。
Main.component.ts :import {Component} from '@angular/core';import {Actor} from './actor.model';@Component({selector: 'main',template: `MovieApp
{
{ slogan}}`})export class MainComponent {slogan: string = 'Just movie information';title: string = 'Terminator 1';actor: Actor = new Actor('Arnold', 'Schwarzenegger');changeActorProperties() {this.actor.firstName = 'Nicholas';this.actor.lastName = 'Cage';}changeActorObject() {this.actor = new Actor('Bruce', 'Willis');}}Movie.component.ts:import {Component, Input} from '@angular/core';import {Actor} from './actor.model';@Component({selector: 'movie',styles: ['div{border: 1px solid black}'],template: ` `})export class MovieComponent {@Input() title: string;@Input() actor: Actor;}{ { title}}
{ {actor.firstName}} { {actor.lastName}}
上述代码中,MainComponent通过<movie></movie> 标签嵌入了MovieComponent,从树状结构上来说,MainComponent是MovieComponent的根节点,而MovieComponent是MainComponent的叶子节点。当我们点击MainComponent的第一个button时,会回调到changeActorProperties方法,然后会触发变化检测的执行。首先变化检测从MainComponent开始:
-
检测slogan 值是否发生了变化:没有发生变化
-
检测 title 值是否发生了变化:没有发生变化
-
检测 actor 值是否发生了变化:没有发生变化
你可能对于 actor的检测结果感到疑惑,不是明明改变了actor的属性值吗?实质上在对actor检测时只检测actor 本身的引用值是否发生了改变,改变actor的属性值并未改变actor 本身的引用,因此是没有发生变化。而当我们点击MainComponent的第二个button ,重新new了一个 actor ,这时变化检测才会检测到 actor发生了改变。
然后变化检测进入到叶子节点MovieComponent:
-
检测title 值是否发生了改变:没有发生变化
-
检测actor.firstName 是否发生了变化:发生了改变
-
检测actor.lastName 是否发生了改变:发生了改变
因为MovieComponent再也没有了叶子节点,所以变化检测将更新DOM,同步视图与模型之间的变化。
看到这里你可能会想,这机制未免也有点太简单粗暴了吧,假如我的应用中有成百上千个Component,随便一个Component 触发了检测,那么都需要从根节点到叶子节点重新检测一遍。别着急,Angular 的开发团队已经考虑到了这个问题,上述的检测机制只是一种默认的检测机制,Angular还提供一种OnPush的检测机制,还是同样的例子,下面看一下OnPush检测机制是咋样的:
Main.component.ts :import {Component, ChangeDetectionStrategy} from '@angular/core';import {Actor} from './actor.model';@Component({selector: 'main',template: `MovieApp
{
{ slogan}}`,changeDetection:ChangeDetectionStrategy.OnPush})export class MainComponent {slogan: string = 'Just movie information';title: string = 'Terminator 1';actor: Actor = new Actor('Arnold', 'Schwarzenegger');changeActorProperties() {this.actor.firstName = 'Nicholas';this.actor.lastName = 'Cage';}changeActorObject() {this.actor = new Actor('Bruce', 'Willis');}}
与上面的代码相比,只在@Component中添加了:
changeDetection:ChangeDetectionStrategy.OnPush
即将检测机制设置为OnPush。同样的,当我们点击第一个button时,将会发生如下变化检测:
-
检测slogan 值是否发生了变化:没有发生变化
-
检测 title 值是否发生了变化:没有发生变化
-
检测 actor 值是否发生了变化:没有发生变化
好,变化检测到此结束,不会再进入到 MovieComponent 中了。这正是OnPush与Default之间的差别:当检测到与子组件输入绑定的值没有发生改变时,变化检测就不会深入到子组件中去。那么当我们点击MainComponent中的第二个按钮时,由于改变了actor本身而不是它的属性值,那么就会检测到actor的变化,进而继续进入到MovieComponent 进行变化检测。所以,当你使用了OnPush检测机制时,在修改一个绑定值的属性时,要确保同时修改到了绑定值本身的引用。但是每次需要改变属性值的时候去new一个新的对象会使得代码很难看,并且有时候你难以保证你一定记得这么做,恩,immutable.js 你值得拥有!
另外一个问题就是,当我们使用OnPush并且输入绑定的是一个Observable对象时,怎么才能检测到当订阅的事件发生时引起的绑定的值的发生了改变呢?比如下面这个组件:
import {Component, Input, ChangeDetectionStrategy} from '@angular/core';import {Observable} from "rxjs";@Component({selector: 'my-count',template: `{ {count}}`,changeDetection:ChangeDetectionStrategy.OnPush})export class CountComponent {@Input() addItemStream: Observable;count=0;ngOnInit(){this.addItemStream.subscribe(()=>{this.count++;});}}
输入绑定 addItemStream 是一个Observable对象,Observable对象本身是不会变化的,只有当订阅的事件到达时,才会去修改count的值。如果使用OnPush 那么检测就不会进入到CountComponent。解决的办法很简单,只需在修改count的值后做一个标记(markForCheck),那么变化检测就会沿着CountComponent所在的树枝进行变化检测。
import {Component, Input, ChangeDetectionStrategy, ChangeDetectorRef} from '@angular/core';import {Observable} from "rxjs";@Component({selector: 'my-count',template: `{ {count}}`,changeDetection:ChangeDetectionStrategy.OnPush})export class CountComponent {@Input() addItemStream: Observable;count=0;constructor(private cd:ChangeDetectorRef){}ngOnInit(){this.addItemStream.subscribe(()=>{this.count++;this.cd.markForCheck();});}}
总结
总结来说,Angular中变化检测器是树型结构的组织,与组件树结构相对应,默认情况下,当一个组件引发了变化检测时,检测是从树根开始一直检测到树节点。当你设置某个组件的检测策略是 OnPush 时,如果该组件的输入绑定没有发生变化时,那么检测就不会进入到该组件。当组件树变的很庞大时,常用这种办法来提高应用的性能。