深入理解Angular2变化监测和ngZone

转载自GitHub JTangming : https://github.com/JTangming/tm/issues/4

Angular应用程序通过组件实例和模板之间进行数据交互,也就是将组件的数据和页面DOM元素关连起来,当数据有变化后,NG2能够监测到这些变化并更新视图,反之亦然,它的数据流向是单项的,通过属性绑定和事件绑定来实现数据的流入和流出,数据从属性绑定流入组件,从事件流出组件,数据的双向绑定就是通过这样来实现的。那么它是如何实现变化检测的呢?

需要进行变化监测的情形

试想一下,在什么样的场景下,angular才需要去更新视图:

  • event,在view中绑定事件来监听用户的操作,如果数据有变更则更新视图;
  • xmlHTTPRequest/webSocket,例如从远端服务拉取对应的数据,这是一个异步的过程;
  • timeout,例如:setTimeoutsetIntervalrequestAnimationFrame都是在某个延时后触发。

以上的共同特征是什么?很明显共同点是它们都是异步的处理,即需要使用异步回调函数,这带给我们的结论就是,不管任何时候的一个异步操作,我们应用程序状态可能已经被改变,这就需要告诉Angular去更新视图。

我们创建一个组件来呈现一个Todo例子,我们可以在模板中这样使用这个组件:

<todo-cmp [model]="myTodo" (complete)="onCompletingTodo(todo)"></todo-cmp>

 

这将告诉Angular不管任何时候myTodo发生改变,Angular必须通过调用视图模型设置的myTodo数据(model setter)来自动的更新todo模板组件。

同样的,数据的流出是通过事件绑定来实现的,如果一个complete事件被触发,它将调用这个onCompletingTodo方法,该方法可能是一个获取后台最新数据的操作,这将需要用后台返回的异步数据与之前的数据参考进行对比来确定是否需要更新视图。

正如上面的例子,Angular2的属性和事件绑定的核心语法是很简单的,我们通过属性绑定实现了数据从父传递给了子,而事件绑定则实现了数据由子到父的传递,这也就是Angular2用来实现数据双向绑定的方法。它实现的是单向流的数据传递,也就是说,你的数据流只能向下流入组件,如果你需要进行数据变化,你可以发射导致变化的事件到顶部,待数据变化处理完成,然后再往下流入组件。那么问题来了,Angular2如何知道数据是否已经处理处理完成,这份新的数据是否有变化,如果数据有变化,那是怎么来通知数据往下流入组件通知组件来改变视图呢?这里我们先理解zone。

关于Zone

Zone实际上是Dart的一种语言特性,其是对Javascript某些设计缺陷的一些补充,简单的可以概述成Zone是一个异步事件拦截器,也就是说Zone能够hook到异步任务的执行上下文,以此来处理一些操作,比如说,在我们每次启动或者完成一个异步的操作、进行堆栈的跟踪处理、某段功能代码进入或者离开zone,我们可以在这些关键的节点重写我们所需处理的方法。

Zone中提供了各类hooks,允许在每一个回调函数的开始和结束时,去执行统一的自定义逻辑,其本身是不做任何事的,相反它是依赖其它的代码,获取到这些代码片段的执行上下文,通过hooks来完成相关的功能。Zone的另一个值得一提的是它必须依赖异步操作,当一个异步操作在执行时,它是有必要去捕获的这个异步操作并在该异步功能开始或者完成时建立对应的callback,然后存储到当前的zone,举个例子,如果一个代码片段在fork的zone中执行,并且这段代码中包含一个setTimeout的异步任务,那么执行到和完成这个setTimeout方法需要包裹一个异步的回调函数,存储到当前zone。

这样是确保每个异步操作之间的相互不受影响,也就是受保护的状态,例如一个页面由业务代码和一些第三方广告代码组成,这两份代码之间是相互独立的,我们需要的是业务代码的异常捕获数据提交到我们自己的后台服务器上,第三方广告代码的异常捕获提交到他们自己的服务器上。当fork了多个zone之后,异步操作将会精准的执行其所在的子zone上面方法。

Zone的一个重要意义在于,我们的功能或者业务代码运行在了fork的一个zone中,我们zone有了对该代码块执行上下文的控制权。其中也提供了一些钩子(hook)来处理我们基本的业务情景需求,大致有:

  • Zone.onZoneCreated:在zone被fork时运行
  • Zone.beforeTask:在执行zone.run包裹的函数之前调用
  • Zone.afterTask:在执行zone.run包裹的函数之后调用
  • Zone.onError:zone.run方法中的Task任务抛出异常时的钩子函数

下面我们通过这样的一个例子来帮助你理解Zone,简单的代码如下:

zone.fork({
    beforeTask: () => {
        console.log('hi, beforeTask in.');
    },
    afterTask: () => {
        console.log('hi, afterTask in.');
    }
}).run(function () {
  zone.inTheZone = true;

  setTimeout(function () {
    console.log('in the zone: ' + !!zone.inTheZone);
  }, 0);
});

console.log('in the zone: ' + !!zone.inTheZone);

 

这段代码按照执行上下文顺序的执行,我们在zone的run函数执行的开始和结束会有对应的hooks,例如要统计这段代码执行所消耗的时间,然而通常情况下,这里的异步处理,比如说是服务端异步返回给我们所需要的数据,或者是一些异步事件更改视图模型的数据等。这样通过beforeTaskafterTask统计到整个代码的耗时。这种情形在zone得到了很好的解决,Zone能够hook到异步任务的执行上下文,在异步事件发生或者结束的时候,允许我们在这样的异步任务节点执行一些分析代码。zone使用也很简单,一旦我们引入zone.js,那我们在全局作用域中可以获取到zone对象。

但是这远远不够的,很多时候我们的应用场景要比这个复杂的多,现在是时候体现zone的暴力美了,zone.js采用猴子补丁(Monkey-patched)的方式将Js中的异步任务都进行了包裹,同样的这使得这些异步任务都将运行在zone的执行上下文中,每一个异步的任务在zone.js都是一个task,除了提供了一些供开发者使用的勾子(hook)函数外,默认情况下zone.js重写了并提供了如下的方法:

  • Zone.setInterval() / Zone.setTimeout()
  • Zone.alert()
  • Zone.prompt()
  • Zone.requestAnimationFrame()
  • Zone.addEventListener()
  • Zone.removeEventListener()

综上所述,我们应该能理解zone.js的应用场景了,即实现了异步task的跟踪分析和错误记录以便更好的进行开发debug等。接下来将回到主题来探讨一下Angular2的数据绑定和zone的关系。

Angular2数据绑定和Zone

在Angular1.x中,默认的选择是双向的数据绑定,你的控制器数据发生变化,或者表单直接操作数据变动等,最终体现在视图中显示数据。

Angular1.x双向数据绑定的问题是,随着你的项目增长,它往往会导致整个应用的级联效应,并很难跟踪你的数据流。除非你使用Angular1.x框架的内置服务和指令,否则我们在model上做数据修改或者数据输出,Angular是无法预知的,当然就不会去更新视图模板中的数据来展示给UI。

好在Angular2框架把zone.js作为依赖,因为zone.js是一个独立的库,可以不依赖于其他库或者框架而单独被使用,因此在Angular2开发的应用中,zone拥有angular应用运行环境的执行上下文,事实证明,zone是能够解决在我们在angular应用中变化监测的问题的。

下面我们来介绍ngZone。实际上,ngZone是基于Zone.js来实现的,Angular2 fork了zone.js,它是zone派生出来的一个子zone,在Angular环境内注册的异步事件都运行在这个子zone上(因为ngZone拥有整个Angular运行环境的执行上下文),并且onTurnStart和onTurnDone事件也会在该子zone的run方法中触发。

在Angular2源码中,有一个ApplicationRef类,其作用是用来监听ngZone中的onTurnDone事件,不论何时只要触发这个事件,那么将会执行一个tick()方法用来告诉Angular去执行变化监测。

// very simplified version of actual source
class ApplicationRef {
  changeDetectorRefs:ChangeDetectorRef[] = [];

  constructor(private zone: NgZone) {
    this.zone.onTurnDone
      .subscribe(() => this.zone.run(() => this.tick());
  }

  tick() {
    this.changeDetectorRefs
      .forEach((ref) => ref.detectChanges());
  }
}

 

Angular2的变化监测

现在我们已经知道了Angular2的变化监测在何时被触发,那它是怎么去做变化监测的呢?实际上在Angular2中,任何的一个Angular2应用都是由大大小小的组件组成的,可以把它看成是一颗线性的组件树,重要的是,每一个组件都有自己的变化检测器。这样的一个图可以帮助你理解这些概念,具体也可以参考关于Angular2变化监测的文章。

正是因为每个组件都拥有它的变化检测器,组成了Angular2应用的一颗组件树,同样的我们也有变化监测树,它也是线性的,数据的流向也是从上到下,因为变化监测在每个组件中的执行也是从根组件开始,从上往下的执行。单向的数据流相对angular1.x的环形数据流来说要更好预测的多,其实我们清楚视图中数据的来源,也就是说这些数据的变化是来自于哪个组件数据变化的结果。我们来举个例子吧:

@Component({
  template: '<v-card [vData]="vData"></v-card>'
})
class VCardApp {
  constructor() {
    this.vData = {
      name: '***',
      email: '****@**.com'
    }
  }

  changeData() {
    this.vData.name = '*****';
  }
}

 

Angular2在整个运行期间都会为每一个组件创建监测类,用来监测每个组件在每个运行周期是否有异步操作发生。当变化监测被执行时会发生什么呢?假象一下changeData()方法在一个异步的操作之后被执行,那么vData.name被改变,然后被传递到<v-card [vData]="vData"></v-card>的变化检测器来和之前的数据对比是否有改变,如果和参照数据对比有变动的话,Angular将更新视图。

因为在JavaScript语言中不提供给我们对象的变化通知,所以Angular必须保守的要对每一个组件的每一次运行结果执行变化检测,但其实很多组件的输入属性是没有变化的,没必要对这样的组件来一次变化监测,如何减少不必要的监测,我们有两种方式去实现。

Immutable Objects

不可变对象(Immutable Objects)给我们提供的保障是对象不会改变,即当其内部的属性发生变化时,相对旧有的对象,我们将会保存另一份新的参照。它仅仅依赖输入的属性,也就是当输入属性没有变动(没有变动即没有产生一份新的参照),Angular将跳过对该组件的全部变化监测,直到有属性变化为止。如果需要在Angular2中使用不可变对象,我们需要做的就是设置changeDetection: ChangeDetectionStrategy.OnPush,如下的例子:

@Component({
  template: `
    <h2>{{vData.name}}</h2>
    <span>{{vData.email}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class VCardCmp {
  @Input() vData;
}

 

例子中,VCardCmp仅仅依赖它的输入属性,同时我们也设定了变化监测策略为OnPush来告诉Angular如果属性属性没有任何变化的话,则跳过该组件的变化监测。

Observables

和不可变对象类似,但却又和不可变对象不同,它们有相关变化的时候不会提供一份新的参照,可观测对象在输入属性发生变化的时候来触发一个事件来更新组件视图,同样的,我们也是添加OnPush来跳过子组件树的监测器,我们给这样的一个例子来帮你加深理解:

@Component({
  template: '{{counter}}',
  changeDetection: ChangeDetectionStrategy.OnPush
})
class CartBadgeCmp {

  @Input() addItemStream:Observable<any>;
  counter = 0;

  ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // application state changed
    })
  }
}

 

该组件是模拟的当用户触发一个事件后增加counter这样一个场景,确切的讲,CartBadgeCmp设置了一个插值counter和一个输入属性addItemStream,当有异步操作需要更新counter的时候,将会触发一个事件流,但是输入属性addItemStream作为参考对象将不会更改,意味着该组件树的变化监测将不会发生。那怎么办?我们将怎么来通知Angular某区块有改变呢?Angular2的变化监测总是从组件树的头到尾来执行,我们其实需要的就是在整个组件树的某个发生改变的地方来做出相应即可,Angular是不知道那一块目录有改变的,但是我们知道,我们可以通过依赖注入给组件来引入一个ChangeDetectorRef,这个方法正是我们所需要的,它能标记整颗组件树的目录直到下一次变化监测的执行,代码示例如下:

class CartBadgeCmp {
    constructor(private cd: ChangeDetectorRef) {}

    @Input() addItemStream:Observable<any>;
    counter = 0;

    ngOnInit() {
        this.addItemStream.subscribe(() => {
            this.counter++; // application state changed
            this.cd.markForCheck(); // marks path
        })
    }
}

 

当这个可监测的addItemStream触发一个事件,该事件处理句柄将会从根路径到这个已经改变的addItemStream组件来处理监测,一旦变化监测跑遍整个监测路径,它将会存储OnPush状态到整个组件树。这样做的好处是,变化监测系统将会走遍整棵树,你可以利用他们来监测树在局部是否有真正的改变,以此来做出相应的改变。

 

posted @ 2017-03-10 15:12  Benson.Cai  阅读(2289)  评论(0编辑  收藏  举报