相关信息
- 原文链接: Angular Change Detection Explained
- 原文作者: Pascal Precht
- 译者: 嘉文
译者注:本文是作者在 NG-GL(我也不知道是什么) 上的一个演讲,由他本人整理成文字稿。若非必要,某些重要的术语不进行翻译。
分割线下为原文。
目录
- 什么是变更检测 (Change Dectetion)?
- 什么引起了变更 (change) ?
- 发生变更后,谁通知Angular?
- 变更检测
- 性能
- 更聪明的变更检测
- 不变对象 (Immutable Objects)
- 减少检测次数 (number of checks)
- Observables
- 更多..
什么是变更检测
变更检测的基本任务是获得程序的内部状态并使之在用户界面可见。这个状态可以是任何的对象、数组、基本数据类型,..也就是任意的JavaScript数据结构。
这个状态在用户界面上最终可能成为段落、表格、链接或者按钮,并且特别对于 web 而言,会成为 DOM 。所以基本上我们将数据结构作为输入,并生成 DOM 作为输出并展现给用户。我们把这一过程成为rendering(渲染)
这个问题有许多解决方法。比如其中一个方法是简单地通过发送 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,它监听NgZones的onTurnDone事件。只要这个事件发生了,它就执行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 就会抛出一个错误。
性能
默认的,在事件发生的时候,即使我们每次都检测每个组件,Angular 也是非常快的,它会在几毫秒内执行成千上万次的检测。这主要是因为Angular 生成了对虚拟机友好的代码 (VM friendly code),
这是啥意思?实际上,当我们说每个组件都有它自己的变更检测器的时候,并不是真的说在 Angular 有这样一个普遍的东西 (genetic thing ) 负责每一个组件的变更检测。
这样做的原因在于,它(变更检测器)必须被编写成动态的,这样它才能够检测所有的组件,不管这个组件的模型结构是怎样的。而 VMs 不喜欢这种动态代码,因为 VMs 不能优化它们。当一个对象的结构不总是相同的时候,它通常被称作多态的( polymorphic )。
Angular 对于每个组件都在 runtime 生成变更检测器类,而这些变更检测器类是单态的,因为他们确切地知道这个组件的模型是怎样的。VMs 可以完美地优化这些代码,这使得它执行得非常快。好消息是,我们并不需要管那么多,因为 Angular 自动地做了这些工作。
可以看看 Victor Savkin 关于Change Detection Reinvented 的演讲,你可以得到更深入的解释。
更聪明的变更检测
我们知道,一旦事件 (event) 发生,Angular 必须每次都检测所有的组件,因为应用的状态可能发生了改变。但如果我们让 Angular 仅对应用中状态发生改变的那部分执行变更检检测,岂不是美滋滋?
是的,这很美滋滋,并且我们可以做到。只要通过下面几种数据结构——Immutables 和 Observables. 如果我们恰好使用了这些数据结构并且我们告诉了 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()方法,这个方法改变vData的 name。 这里没有什么特别的魔法。
这里的重要部分在于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)。当事件发生的时候,没有变更检测会执行。
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 的整个变更检测机制有了更加深入的理解,在此也给大家推荐一下(可能需要科学上网)。
- Change Detection Reinvented Victor Savkin 这个视频讲的内容与本文差不多,相互对照着看理解更加深入。
- Change And Its Detection In JavaScript Frameworks 这篇文章讲了不同JS框架之间实现变更检测的不同,只是一个概览,也可以看看。值得注意的是这里对于 Angular 讲的是 AngularJs ,即 Angular1.x 的版本,Angular versition >= 2 是不一样的,具体的不同请了解 zooms.(看下面)
- Understanding zones 介绍了zone.js.
- Zones in Angular 介绍了 Angular 是如何使用 zone.js (通过扩展zone.js) 来实现变更检测的。
另外笔者发现一个有趣的现象,外国人写 blog 喜欢到处引用别人的博客 或者演讲,你看这一篇引用了另一篇,另一篇又引用了别的,这就形成了一棵树,那对于这棵树要进行 BFS 还是 DFS 呢?(即看到引用里面先去看引用的文章,还是先把当前文章看完再去看文中引用的)我的建议是如果文中提到这篇文章是基于那篇引用文的,那当然必须先看了,否则的话还是先把当前文章看完。