[译] 深入分析 Angular 变更检测

6,450 阅读14分钟

相关信息

译者注:本文是作者在 NG-GL(我也不知道是什么) 上的一个演讲,由他本人整理成文字稿。若非必要,某些重要的术语不进行翻译。

分割线下为原文。


目录

  • 什么是变更检测 (Change Dectetion)?
  • 什么引起了变更 (change) ?
  • 发生变更后,谁通知Angular?
  • 变更检测
  • 性能
  • 更聪明的变更检测
  • 不变对象 (Immutable Objects)
  • 减少检测次数 (number of checks)
  • Observables
  • 更多..

什么是变更检测

变更检测的基本任务是获得程序的内部状态并使之在用户界面可见。这个状态可以是任何的对象、数组、基本数据类型,..也就是任意的JavaScript数据结构。

这个状态在用户界面上最终可能成为段落、表格、链接或者按钮,并且特别对于 web 而言,会成为 DOM 。所以基本上我们将数据结构作为输入,并生成 DOM 作为输出并展现给用户。我们把这一过程成为rendering(渲染)

然而,当变更发生在 runtime 的时候,它会变得很奇怪。比如当 DOM 已经渲染完成以后。我们要如何知悉 model 中什么发生了改变,以及更新 DOM 的什么位置? 访问 DOM 树是十分耗时的,所以我们不仅要找到应该更新 DOM 的位置,并且要尽可能少地访问它。

这个问题有许多解决方法。比如其中一个方法是简单地通过发送 http 请求并重新渲染整个页面。另一个方法是 ReactJs 提出的 Virtual Dom 的概念,即检测 DOM 的新状态与旧状态的不同并渲染其不同的地方。

Tero 写了一篇很棒的文章,是关于 Change and its detection in JavaScript frameworks,即不同JavaScript框架之间的变更检测,如果你对于这个问题感兴趣的话我建议你们去看一看。在这篇文章中我会专注于Angular>=2.x的版本。

什么引起了变更(change)?

既然我们知道了变更检测是什么,我们可能会疑惑:到底这样的变更什么时候会发生呢?Angular 什么时候知道它必须更新 view 呢?好吧,我们来看看下面的代码:

@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';
  }
}

如果这是你第一次看Angular组件,你可能得先去看看 如何写一个tabs组件

上面这个组件简单地展示了两个属性,并提供了一个方法,在点击按钮的时候调用这个方法来改变这两个属性。这个按钮被点击的时候就是程序状态已经发生了改变的时候,因为它改变了这个组件的属性。这就是我们需要更新视图 (view) 的时候。

下面是另一个例子:

@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 请求。一旦这个请求返回,这个联系人列表就会被更新。在这个时候,我们的程序状态发生了改变,因而我们需要更新视图。

通过上面两个例子我们可以看出,基本上,程序状态发生改变有三个原因:

  • 事件 - click,submit...
  • XHR - 从服务器获取数据。
  • Timers - setTimeout(), setInterval() 这些全都是异步的。从中我们可以得出一个结论,基本上只要异步操作发生了,我们的程序状态就可能发生改变。这就是 Angular 需要被通知更新 view 的时候了

谁通知 Angular ?

到目前为止,我们已经知道了是什么导致程序状态的改变,但在这个视图必须发生改变的时候,到底是谁来通知 Angular 呢?

Angular 允许我们直接使用原生的 API。没有任何方法需要被调用,Angular 就被通知去更新 DOM 了。这是魔术吗?

如果你有看过我们最近的文章,你会知道是 Zones 做了这一切。事实上,Angular 有着自己的zone,称为NgZone, 我们写过一篇关于它的文章 《Zones in Angular》. 你可能也想要看一下。

简单描述一下就是,Angular源码的某个地方,有一个东西叫做ApplicationRef,它监听NgZonesonTurnDone事件。只要这个事件发生了,它就执行tick()函数,这个函数执行变更检测

// 真实源码的非常简化版本。
class ApplicationRef {

  changeDetectorRefs:ChangeDetectorRef[] = [];

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

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

变更检测

很棒,我们现在已经知道了什么时候变更检测会被触发 (triggered),但它是怎么执行的呢?Well,我们需要注意到的第一件事情是,在 Angular 中,每个组件都有它自己的变更检测器 (change detector)

这是很明显的,因为这让我们可以单独的控制每个组件的变更检测何时发生以及如何执行。我们后面再细说这一点。

我们假设组件树的某处发生了一个事件,可能是一个按钮被点击。接下来会发生什么?我们刚刚知道了, zones 执行给定的 handler (事件处理函数) 并且在执行完成后通知 Angular,接着 Angular 执行变更检测。

既然每个组件都有它自己的变更检测器,并且一个 Angular 应用包含着一个组件树,那么逻辑上我们也有一个变更检测器树 (change detector tree)。 这棵树也可以被看成是一个有向图,该有向图的数据总是从顶端流向低端。

数据总是由顶端流向底端的原因在于,对于每一个组件,变更检测总是从顶端开始执行,每次都是从根组件开始。这非常棒,因为单向的数据流相较于循环的数据流更容易预测。我们永远知道视图中使用的数据从哪里来,因为它只能源于它所在的组件。

另一个有趣的观察是,在单通道中变更检测会更加稳定。这意味着,如果当我们第一次运行完变更检测后,只要其中一个组件导致了任何的副作用,Angular 就会抛出一个错误。

性能

默认的,在事件发生的时候,即使我们每次都检测每个组件,Angular 也是非常快的,它会在几毫秒内执行成千上万次的检测。这主要是因为Angular 生成了对虚拟机友好的代码 (VM friendly code),

这是啥意思?实际上,当我们说每个组件都有它自己的变更检测器的时候,并不是真的说在 Angular 有这样一个普遍的东西 (genetic thing ) 负责每一个组件的变更检测。

这样做的原因在于,它(变更检测器)必须被编写成动态的,这样它才能够检测所有的组件,不管这个组件的模型结构是怎样的。而 VMs 不喜欢这种动态代码,因为 VMs 不能优化它们。当一个对象的结构不总是相同的时候,它通常被称作多态的( polymorphic )。

Angular 对于每个组件都在 runtime 生成变更检测器类,而这些变更检测器类是单态的,因为他们确切地知道这个组件的模型是怎样的。VMs 可以完美地优化这些代码,这使得它执行得非常快。好消息是,我们并不需要管那么多,因为 Angular 自动地做了这些工作。

可以看看 Victor Savkin 关于Change Detection Reinvented 的演讲,你可以得到更深入的解释。

更聪明的变更检测

我们知道,一旦事件 (event) 发生,Angular 必须每次都检测所有的组件,因为应用的状态可能发生了改变。但如果我们让 Angular 仅对应用中状态发生改变的那部分执行变更检检测,岂不是美滋滋?

是的,这很美滋滋,并且我们可以做到。只要通过下面几种数据结构——ImmutablesObservables. 如果我们恰好使用了这些数据结构并且我们告诉了 Angular,那么变更检测就会快很多很多。这么棒的吗,那具体要怎么做?

理解易变性( Mutability )

为了理解不可变的数据结构(immutable data structures)为什么、以及如何 有助于更快的变更检测,我们需要理解易变性到底是什么。假设我们有下面的组件:

@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';
  }
}

VCardApp 使用<v-card>作为子组件,该子组件有一个输入属性vData,我们将VCardApp的属性vData传入子组件。vData是一个包含两个属性的对象。另外还有一个changeData()方法,这个方法改变vDataname。 这里没有什么特别的魔法。

这里的重要部分在于changeData()通过改变它的name属性改变了vData,尽管那个属性会被改变,但是vData的引用是没有变的。

假设一些 event 导致了changeData()被执行,变更检测会怎么执行呢?首先,vData.name 被改变了,然后它被传入了<v-card>. <v-card>的变更检测器开始检测传进来的vData是否未发生改变,答案是 yes,没有改变。因为这个对象的引用没有被改变。然而,它的 name 属性被改变了,所以即便如此 Angular 仍会为那个对象(vData)执行变更检测。

由于在 JavaScript 中对象默认是易变的 (multable)(除了基本数据类型),每次当 event 发生的时候 Angular 必须保守地对于每个组件都跑一次变更检测,

这时候, 不可变数据结构可以派上用场了。

不可变对象 (Immutable Objects)

不可变对象保证了这个对象是不能改变的。这意味着如果我们使用着不可变对象,同时试图改变这个对象,那我们总是会得到一个新的引用,因为原来那个对象是不可变的。

减少检测的次数

当输入属性没有发生改变的时候,Angular 会跳过整个子树的变更检测。我们刚刚说了,"改变"意味着 "新的引用"。如果我们在 Angular 程序中使用不可变对象,我们只需要做的就是告诉 Angular,如果输入没有发生改变,这个组件就可以跳过变更检测。

我们通过研究<v-card>来看看它是怎么工作的:

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

可以看到,VCardCmp只取决于输入属性。很好。如果它的所有输入属性都没有变化的话,我们可以让 Angular 跳过对于这颗子树的变更检测了,只要设置变更检测策略为OnPush就可以了

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

这就大功告成了!你可以试着想象一棵更大的组件树,只要我们使用了不可变对象,就可以跳过整棵子树(的变更检测)。

Jurgen Van Moere 写了一篇 深度文章 ,关于他如何使用 Angular 和 Immutablejs 写了一个贼快的扫雷。推荐你看看。

Observables

正如前文所说,当变更发生的时候 Observables 也给了我们一个保证。不像不可变对象,当变更发生的时候。Observables 不提供给我们新的引用。 取而代之的是,他们触发事件,并且让我们注册监听 (subscribe) 这些事件来对这些事件做出反应。

所以,如果我们使用Observables 并且 想要使用OnPush来跳过对子树的变更检测,但是这些对象的引用永远不会改变,我们该怎么办呢?事实上,对于某些事件,Angular 有一个非常聪明的方法来使得组件树上的这条路被检测,而这个方法正是我们需要的。

为了理解这是什么意思,我们看看下面这个组件:

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

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

  ngOnInit() {
    this.addItemStream.subscribe(() => {
      this.counter++; // 程序状态改变
    })
  }
}

假设我们正在写一个有购物车的网上商城。用户将商品放入购物车时,我们希望有一个小计时器出现在我们的页面上,这样一来用户可以知道购物车中的商品数目。

CartBadgeCmp就是做这样一件事。它有一个counter作为输入属性,这个counter是一个事件流,它会在某个商品被加入购物车时被 fired。

我不会在这篇文章中对 Observables 的工作原理进行太多细节描述,你可以先看看这篇文章 《Taking advantage of Observables in Angular》

除此之外,我们设置了变更检测策略为OnPush,因而变更检测不会总是执行,而是仅当组件的输入属性发生改变时执行。

然而,如前文提到的,addItemStreem永远也不会发生改变,所以变更检测永远不会在这个组件的子树中发生。这是不对的,因为组件在生命周期钩子 ngOnInit 中注册了这个事件流,并对 counter 递增了。这是一个程序状态的改变,并且我们希望它反应到我们的视图中,对吧?

下图是我们的变更检测树可能的样子(我们已经将所有组件设置为OnPush)。当事件发生的时候,没有变更检测会执行。

那么对于这个变更,我们要如何通知 Angular 呢?我们要如何告知 Angular, 即使整棵树都被设置成了OnPush,对于这个组件变更检测依然需要执行呢?

别担心,Angular 帮我们考虑了这一点。如前所述,变更检测总是自顶向下执行。那么我们需要的只是一个探测 (detect) 自根组件变更发生的那个组件的整条路径而已。Angular无法知道是哪一条,但我们知道。

我们可以通过依赖注入使用一个组件的ChangeDetectorRef,通过它你可以使用一个叫做markForCheck()的API。这个做的事情正好是我们需要的! 它标记了从当前组件到根组件的整条路径,当下一次变更检测发生的时候,就会检测到他们。 我们把它注入到我们的组件:

constructor(private cd: ChangeDetectorRef) {}

然后告诉Angular,标记整条路径,从这个组件到根组件都需要被checked:

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

Boom, 大功告成!下图就是当 observable 事件发生之后组件树的样子:

现在,当变更检测执行的时候,

是不是很酷?一旦变更检测结束,它就会恢复为整棵树恢复OnPush状态。

更多

事实上,还有很多API没有被这篇文章提及,就交给你自己去深入研究啦。

这个项目 中还有一些demos可以玩玩,你可以在你自己的电脑跑一下。

希望这篇文章会让你对 immutable data structures 以及 Observable如何让我们的 Angular 应用运行的更加快 有一个更清晰的认识。


译者注

译者在翻译完过程中将文中的部分链接也看了一下,对整个 Angular 的整个变更检测机制有了更加深入的理解,在此也给大家推荐一下(可能需要科学上网)。

另外笔者发现一个有趣的现象,外国人写 blog 喜欢到处引用别人的博客 或者演讲,你看这一篇引用了另一篇,另一篇又引用了别的,这就形成了一棵树,那对于这棵树要进行 BFS 还是 DFS 呢?(即看到引用里面先去看引用的文章,还是先把当前文章看完再去看文中引用的)我的建议是如果文中提到这篇文章是基于那篇引用文的,那当然必须先看了,否则的话还是先把当前文章看完。