17

Angular 2.x+ 脏检查机制理解

目前几种主流的前端框架都已经实现双向绑定特性,但实现的方法各有不同:

  • 发布者-订阅者模式(backbone.js)
  • 脏值检查(angular.js)
  • 数据劫持 + 发布者-订阅者模式(vue.js)

下面我们就来了解一下 ng2.x+ 的版本中的脏检查机制是如何运行的。

什么是变化检测

变化检测(脏检查)的基本任务是获取程序内部状态的变化,并使其在用户界面上以某种方式可见,这种状态的变化可以来自于 JavaScript 的任何数据结构,最终呈现为用户界面中的段落、表单、链接或者按钮等 DOM 对象。我们把输入数据结构并生成 DOM 结构显示给用户的过程叫作渲染

然而在程序运行时发生变化情况比较复杂,我们需要确定模型中发生什么变化,以及什么地方需要更新 DOM 节点。操作 DOM 树十分昂贵,所以我们不仅需要找出待更新的地方,还需要保持操作数尽可能小。这可能通过许多不同的方式来解决:比如我们可以简单的发起 http 请求并重新渲染整个页面,或者可以区分 DOM 树的新旧状态并只重新渲染二者不同的部分(ReactJS 虚拟 DOM 的解决方案)。

什么会引起变化

什么时候会产生变化?
Angular 如何确知更新视图的时机?
@Component({
  template: `
    <h1>{{firstname}} {{lastname}}</h1>
    <button (click)="changeName()">Change name</button>
  `
})

class MyApp {
  firstname:string = 'Pascal';
  lastname:string = 'Precht';

  changeName() {
    this.firstname = 'Brad';
    this.lastname = 'Green';
  }
}
上面的组件只是显示两个属性,并提供一个方法来改变他们(点击模板中的按钮),点击这个特定按钮的时刻即是应用程序状态发生改变的时刻,因为它改变组件的属性,这也是我们想要更新视图的时刻。
@Component()
class ContactsApp implements OnInit{
  contacts:Contact[] = [];

  constructor(private http: Http) {}

  ngOnInit() {
    this.http.get('/contacts')
      .map(res => res.json())
      .subscribe(contacts => this.contacts = contacts);
  }
}
这个组件拥有一个联系人列表,当它初始化时发送一个 http 请求,一旦这个请求返回列表就会被更新,此时我们的应用程序状态发生改变,并需要更新视图。

基本上应用程序状态的改变可以由三类活动引起:

  1. 事件 - click, submit, ...
  2. XHR - 从远程服务器获取数据
  3. 定时器 - setTimeout, setInterval
上述活动都是异步的,因此我们可以得出结论:每当执行一些异步操作时,我们的应用程序状态可能发生改变,这时则需要 Angular 更新视图。

Angular 在启动时会重写(通过 Zone.js)部分底层浏览器 APIs 比如 addEventListener

// this is the new version of addEventListener
function addEventListener(eventName, callback) {
     // call the real addEventListener
     callRealAddEventListener(eventName, function() {
        // first call the original callback
        callback(...);     
        // and then run Angular-specific functionality
        var changed = angular2.runChangeDetection();
         if (changed) {
             angular2.reRenderUIPart();
         }
     });
}

谁通知 Angular 更新视图

Zone 负责通知 Angular 进行视图更新,Angular 封装有 NgZone,简单来说,通过 Angular 的部分源码我们可以知道有一个叫作 ApplicationRef 的东西负责监听 NgZone 中的 onTurnDone 事件,每当该事件触发时,它就执行 trick 方法进行变化检测的基本工作。

// 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());
  }
}

变化检测

首先我们需要注意的是在 Angular 中每个组件都有自己的变化检测器,这使得我们可以对每个组件分别控制如何以及何时进行变化检测。

Change Detection.jpg-99.3kB

由于每个组件都有其自己的变化检测器,即一个 Angular 应用程序由一个组件树组成,所以逻辑结果就是我们也有一个变化检测器树,这棵树也可以看作是一个有向图,数据总是从上到下流动。

数据从上到下的原因是因为变化检测也总是从上到下对每一个单独的组件进行,每一次从根组件开始,单向数据流比循环脏检查更可预测,我们总是可以知道视图中使用的数据来自哪里。

Change Detection 2.jpg-131.4kB

我们假设在组件树的某个地方触发一个事件,比如一个按钮被点击,zones 会进行事件的处理并通知 Angular,然后变化检测依次向下传递。

如何触发变化检测

一种方法是基于组件的生命周期钩子:
ngAfterViewChecked() {
    if (this.callback && this.clicked) {
        console.log("changing status ...");
        this.callback(Math.random());
    }
}
在开发模式下运行 Angular 会在控制台中得到一条错误日志,生产模式下则不会抛出。
EXCEPTION: Expression '{{message}} in App@3:20' has changed after it was checked
另一种方法是手动控制变化检测的打开/关闭,并手动触发:
constructor(private ref: ChangeDetectorRef) {
    ref.detach();
    setInterval(() => {
      this.ref.detectChanges();
    }, 5000);
  }

改善的脏检查

Angular 2.x+ 的数据流是自顶向下,从父组件向子组件的的单向流动,变化监测树与之相呼应,单项数据量保证变化监测的高效性和可预测性。检查父组件后,子组件可能会改变父组件中的数据使得父组件需要被再次检查,这是不被推荐的数据处理方式,并且在开发模式下这种情况会抛出异常 ExpressionChangedAfterItHasBeenCheckedError,在生产模式下不会报错但是脏检查仅会执行一次。

相比之下 1.x 的版本采用双向数据流,为了使得数据最终趋向稳定不得不多次检查错综复杂的数据流,性能提升就此可见一斑。

性能

默认情况下,即使每次发生事件都需要检查每个组件,Angular 速度仍然非常快,它可以在几毫秒内执行数十万次检查,这主要是由于 Angular 可以生成 VM 友好的代码。

更优的变化检测

Angular 每次都要检查每个组件,因为事件发生的原因也许是应用程序状态已经改变,但是如果我们能够告诉 Angular 只对那些改变状态的应用程序部分运行变化检测,那不是很好吗?

事实证明,有些数据结构可以给我们什么时候发生变化的一些保证 - ImmutablesObservables

理解不可变

比如我们拥有一个组件 VCardApp 使用 v-card 作为子组件,其具有一个输入属性 vData,并且我们可以使用 changeData 方法改变 vData 对象的 name 属性(并不会改变该对象的引用)。
@Component({
  template: '<v-card [vData]="vData"></v-card>'
})

class VCardApp {
  constructor() {
    this.vData = {
      name: 'Christoph Burgdorf',
      email: 'christoph@thoughtram.io'
    }
  }

  changeData() {
    this.vData.name = 'Pascal Precht';
  }
}
当某些事件导致 changeData 执行时,vData.name 发生改变并传递至 v-card 中,v-card 组件的变化检测器检查给定的数据新 vData 是否与以前一样,在数据引用未变但是其参数改变的情况下,Angular 也需要对该数据进行变化监测。

这就是 immutable 数据结构发挥作用的地方。

[
How I optimized Minesweeper using Angular 2 and Immutable.js to make it insanely fast](https://www.jvandemo.com/how-...

不可变对象

Immutable 为我们提供不可变的对象:这意味着如果我们使用不可变的对象,并且想要对这样的对象进行更改,我们会得到一个新的引用(保证原始对象不变)。
var vData = someAPIForImmutables.create({
              name: 'Pascal Precht'
            });

var vData2 = vData.set('name', 'Christoph Burgdorf');

vData === vData2 // false
上述伪代码即演示不可变对象的含义,其中 someAPIForImmutables 可以是我们想要用于不可变数据结构的任何 API。

OnPush 策略减少检测次数

当输入属性不变时,Angular可以跳过整个变更检测子树。如果我们在 Angular 应用程序中使用不可变对象,我们所需要做的就是告诉 Angular 组件可以跳过变化检测,如果它的输入没有改变的话。
@Component({
  template: `
    <h2>{{vData.name}}</h2>
    <span>{{vData.email}}</span>
  `
})

class VCardCmp {
  @Input() vData;
}
正如我们所看到的,VCardCmp 只依赖于它的输入属性,我们可以告诉 Angular 跳过这个组件的子树的变化检测,如果它的输入没有改变,通过设置变化检测策略 OnPush 是这样的:
@Component({
  template: `
    <h2>{{vData.name}}</h2>
    <span>{{vData.email}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})

class VCardCmp {
  @Input() vData;
}

Change Detection 3.png-123.2kB

Angular OnPush Change Detection and Component Design - Avoid Common Pitfalls

Observables

与不可变的对象不同,当进行更改时 Observables 不会给我们提供新的引用,而是发射我们可以订阅的事件来对他们做出反应。
@Component({
  template: '{{counter}}',
  changeDetection: ChangeDetectionStrategy.OnPush
})

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

  ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // application state changed
    })
  }
}
比如我们用购物车建立一个电子商务应用程序:每当用户将产品放入购物车时,我们需要在用户界面中显示一个小计数器,以便用户可以看到购物车中的产品数量。

该组件有一个 counter 属性和一个输入属性 addItemStream,当产品被添加到购物车时,这是一个被触发的事件流。另外,我们设置了变化检测策略 OnPush,只有当组件的输入属性发生变化时,变化检测才会执行。

如前所述,引用 addItemStream 永远不会改变,所以组件的子树从不执行变更检测。

Change Detection 4.jpg-89.6kB

当整个树被设置成 OnPush 后,我们如何通知 Angular 需要对这个组件进行变化检测呢?正如我们所知,变化检测总是从上到下执行的,所以我们需要的是一种可以检测树的整个路径到发生变化的组件的变化的方法。

我们可以通过依赖注入访问组件的 ChangeDetectorRef,这个注入来自一个叫做 markForCheck 的 API,它标记从组件到根的路径,以便下次更改检测的运行。

constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // application state changed
      this.cd.markForCheck(); // marks path
    })
  }
}
下面是在可观察事件被触发后,变化检测开始前。

Change Detection 5.jpg-87.5kB

现在当执行更改检测时,它将从上到下进行。

Change Detection 6.jpg-103.8kB

并且一旦更改检测运行结束,它将恢复 OnPush 整个树的状态。

TAKING ADVANTAGE OF OBSERVABLES IN ANGULAR

ANGULAR CHANGE DETECTION EXPLAINED
How does Angular Change Detection Really Work ?
Change And Its Detection In JavaScript Frameworks


10081677wc
272 声望14 粉丝