原文:https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/
Angular中的修改检测机制比Angular 1更加透明合理。但是仍然存在很多场合(如进行性能优化的时候)需要我们真正理解这背后发生了什么。所以让我们从如下几个话题来深入探究一下修改检测机制:
- 修改检测机制是如何实现的?
- Angular中的修改检测器长什么样?我能看到它吗?
- 默认的修改检测机制是怎样的?
- 开启/关闭修改检测机制,并手动触发它;
- 避免修改检测循环:生产模式 vs 开发模式;
-
OnPush
修改检测模式究竟做了什么? - 使用Immutable.js简化Angular应用的构建;
- 总结
如果您想了解更多关于OnPush
修改检测的内容,可以参看这篇文章:Angular OnPush
修改检测和组件设计——常见陷阱的避免。
修改检测机制是如何实现的?
Angular会检测到组件数据的修改,并在之后自动重渲染受这些修改所影响的视图。但是它是如何在按钮点击这种在页面随处可能发生的低级别事件后检测到修改的呢?
要理解修改检测是如何实现的,我们首先就得意识到Javascript被设计为整个运行时都是可重写的。只要你想,String
或Number
中的方法都可以覆盖掉。
覆盖浏览器的默认机制
Angular会在启动之初增强一些低级别浏览器API,例如addEventListener
,这是一个用于注册所有浏览器事件(包括点击事件)处理器的浏览器函数。Angular会使用自己的版本替换掉addEventLiestener
,就像这样:
// 这是新版本的addEventListener
function addEventListener(eventName, callback) {
// 调用真正的addEventListener
callRealAddEventListener(eventName, function() {
// 首先调用原本的回调函数
callback(...);
// 之后调用Angular指定的功能
var changed = angular2.runChangeDetection();
if (changed) {
angular2.reRenderUIPart();
}
});
}
新替换的addEventListener
为所有事件处理器增加了新的功能:除了调用注册在上边的回调函数外,还给了Angular执行修改检测并更新UI的机会。
低级别运行时增强做了什么?
对浏览器API的低级别增强是通过Angular引用的名为Zone.js的库实现的。弄明白“zone”是什么是很重要的。
zone不过是一个包含了多个Javascript VM执行回合的执行上下文。这是一种我们可以用来给浏览器添加额外功能的通用机制。Angular在内部使用区域来触发修改检测。此外它也可以用来作应用剖析,或者保持运行于多个VM回合间的长堆栈追踪。
浏览器异步API的支持
这些浏览器经常使用的机制会被增强,从而提供对修改检测的支持:
- 所有的浏览器事件(click、mouseover、keyup等);
-
setTimeout()
和setInterval()
; - Ajax请求
事实上,Zone.js还会增强其他一些浏览器API使之显式触发Angular修改检测,例如Websocket。参考Zone.js的测试说明可以看到当前支持的所有API。
该机制的一个缺陷是如果由于某种原因某个异步浏览器API没有被Zone.js支持,那么修改检测就不会触发。例如,IndexedDB的回调。
我们已经知道了修改检测是如何被触发的,但触发后它究竟做了什么呢?
修改检测树
每个Angular组件都有相关联的修改检测器,该监测器是在应用启动时创建的。例如,我们假设有一个TodoItem
组件:
@Component({
selector: 'todo-item',
template: '<span class="todo noselect" (click)="onToggle()">{{todo.owner.firstname}} - {{todo.description}} - completed: {{todo.completed}}</span>'
})
export class TodoItem {
@Input()
todo:Todo;
@Output()
toggle = new EventEmitter<Object>();
onToggle() {
this.toggle.emit(this.todo);
}
}
该组件将接收一个Todo
对象作为输入,并会在其完成状态属性发生变化时发射事件。为了让这个实例更有趣,这个Todo
类包含一个嵌套对象:
export class Todo {
constructor(public id: number,
public description: string,
public completed: boolean,
public owner: Owner) {
}
}
可以看到,待办事项有一个owner
属性,其本身拥有两个属性:姓和名。
待办事项的修改检测器长什么样?
我们可以真切地看到这个修改检测器到底长什么样!我们只需要在Todo
类中添加一些代码,使之在某个属性被访问时触发一个断点。
当断点命中后,我们可以浏览堆栈追踪并看到修改检测的操作:
不要担心,你永远不需要debug这些代码!在这之中也没有任何魔法,这不过是程序在启动时构建出来的普通Javascript方法。但它做了什么呢?
默认的修改检测机制是怎么工作的呢?
这个方法和这些直接命名的变量们在一开始看起来可能会十分陌生。但深入挖掘的话,我们就会注意到它做的事情非常简单:它会比较在模板中每个表达式用到的属性的现值和前值。
如果属性值和之前不同,它就会将isChanged
设为true
。我们基本上接近真相了!它会通过一个名为looseNotIdentical()
的方法进行值的比较,这其实就是一个对NaN
场景拥有特殊逻辑的===
比较方法(参考这里)。
那么对于嵌套的owner
对象呢?
我们可以看到在修改检测的代码中也包含了对嵌套对象owner
的修改检测。但只有名字属性参与了比较,姓氏属性则没有。
这是因为在组件模板中并没有使用到姓氏!同理,Todo
类中的顶级属性id
也没有进行比较。
基于这些,我们可以放心地说:
默认情况下,Angular修改检测机制是通过检查模板表达式中的值是否发生了变化来工作的。所有的组件中都会这么做。
我们同样也可以作出如下推断:
默认情况下,Angular不会对对象进行深度比较,它只会比较模板中使用到的属性。
为什么默认的修改检测机制是这样的?
更加透明且易用是Angular的重要目标之一,所以用户不必对框架进行太深度的调试,也不必太过关注其内部原理。从而提高框架的开发效率。
如果您熟悉Angular 1,回想一下$digest()
和$apply()
以及所有使用或不使用它们时的那些陷阱。Angular的主要目标之一就是避免它们。
为什么不比较引用呢?
现状是Javascript的对象是可变的,并且Angular希望对此提供开箱即用的支持。
想象一下如果Angular默认的修改检测机制是基于组件输入的引用进行比较的话会怎样呢?即使是像TODO这样简单的应用也会变得难以构建:开发者不得不十分小心地创建新的Todo
对象,而不是简单地修改属性值。
但接下来我们就会看到,如果确实需要的话,我么也可以自定义Angular的修改检测机制。
性能如何?
注意待办事项列表组件的修改检测器是显式引用todo
属性的。
还有一种实现方式是动态地在组件属性间遍历,这可以使代码更具通用性,而不用每个组件的修改检测代码都是独立的。使用这种方式的话我们不必在启动时为每个组件创建修改检测器!何不使用这种方式呢?
虚拟机内部的速览
所有的一切都是基于Javascript虚拟机来工作的。动态比较属性,尽管编写出来的代码更加通用,但是轻易不能被Javascript VM的just-in-time编译器优化。
和使用独立代码的修改检测器不同,这种方式会明确地访问组件的所有输入属性。而独立代码更接近我们手动编写的代码,并且更容易被虚拟机转换为本地代码。
结论是,独立代码生成的显式修改检测机制非常快(比Angular 1快得多)、可预知且易于推导。
但是,如果我们还是遇到了性能问题,该如何对修改检测进行优化呢?
OnPush
修改检测模式
如果待办列表变得非常大,我们会设置让TodoList
组件仅在修改了待办列表的引用值时更新自己。可以通过修改组件的修改检测策略为OnPush
实现:
@Component({
selector: 'todo-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ...
})
export class TodoList {
...
}
现在让我们给应用添加一对按钮:一个通过直接修改列表中的首个元素来更改完成状态,另一个会在列表中添加一个待办事项。代码如下:
@Component({
selector: 'app',
template: `<div>
<todo-list [todos]="todos"></todo-list>
</div>
<button (click)="toggleFirst()">切换首个元素</button>
<button (click)="addTodo()">添加待办事项</button>`
})
export class App {
todos:Array = initialData;
constructor() {
}
toggleFirst() {
this.todos[0].completed = ! this.todos[0].completed;
}
addTodo() {
let newTodos = this.todos.slice(0);
newTodos.push( new Todo(1, "TODO 4",
false, new Owner("John", "Doe")));
this.todos = newTodos;
}
}
我们来看看这两个按钮做了什么:
- 第一个按钮“切换首个元素”不起作用!这是因为
toogleFirst()
方法直接修改了列表中的某个元素。因为输入属性todos
引用本身没有变化,所以TodoList
无法检测到这个修改。 - 第二个按钮起作用!注意
addTodo()
方法会创建一个待办事项列表的拷贝,将新的事项添加在新的拷贝中,并在最后将成员变量待办事项列表替换为这个拷贝的列表。因为组件检测到了输入属性的引用发生了变化——变成了新的列表,所以修改检测触发了。 - 在第二个按钮中,如果直接修改当前的待办事件列表的话,就不起作用了!必须创建一个新的列表。
OnPush
真的仅仅是比较输入引用吗?
如果尝试在某个待办事项上点击,你会发现它依然可以正确工作,这和我们刚刚的结论不符!即使你将TodoItem
切换为OnPush
也一样。这是因为OnPush
不仅仅会检查组件的输入,如果一个组件发射了事件,那么修改检测也会被触发。
引用Victor Savkin在他的博客中的说法:
使用
OnPush
检测器的时候,框架会在其输入属性发生更改时、组件发射事件时或Observable
发射事件时对这个OnPush
组件进行检查。
尽管可以带来更好的性能,但是在使用可变对象时使用OnPush
会带来很高的复杂度成本。这可能会导致很难推导和复现的bug。但又一种办法可以使OnPush
可用。
使用Immutable.js简化Angualr应用的构建
如果只使用不可变对象和不可变数组来构建应用,那么我们就可以显式地在任何地方使用OnPush
而不必担心跌倒在修改检测bug的风险中。这是因为在使用了不可变对象后,修改数据的唯一方式就是创建一个新的不可变对象并替代之前的对象。通过不可变对象,我们有了如下保障:
- 新的不可变对象总是会触发
OnPush
修改检测; - 因为修改数据的唯一方式就是创建新的对象,所以忘记创建对象的新拷贝时不用担心偶然引发bug。
要想过渡到不可变模式,一个好的选择是使用Immutable.js库。这个库为构建应用提供了如不可变对象(Map)和不可变列表这样的不可变的原始类型。
这个库也可以类型安全地使用,参考之前的文章里的实例。
避免修改检测循环:生产模式 vs 开发模式
Angular修改检测的重要特征之一是它不像Angular 1,后者实现了一种双向数据流,当控制器类中的数据更新时,会执行修改检测并更新视图。
尽管视图的更新本身不会触发进一步的修改,但之后的其他修改却会触发更多的视图更新,所以Angular 1引入了消化循环。
如何在Angular中触发修改检测循环?
生命周期回调函数是触发修改检测循环的一种手段。例如在TodoList
组件中我们可以调用其他组件的回调从而修改某个绑定:
ngAfterViewChecked() {
if (this.callback && this.clicked) {
console.log("正在修改状态…");
this.callback(Math.random());
}
}
控制台中会显示一条错误消息:
EXCEPTION: Expression '{{message}} in App@3:20' has changed after it was checked
只有在开发模式下运行Angular时才会抛出这条错误信息。在生产模式下又会怎样呢?
enableProdMode();
@NgModule({
declarations: [App],
imports: [BrowserModule],
bootstrap: [App]
})
export class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在生产模式下,异常不再抛出,该问题将无法检测到。
修改检测问题会频繁发生吗?
我们确实需要避免触发修改检测循环。以防万一,只要我们总是在开发阶段使用开发模式,就可以避免这个问题。
这种保证是以Angular总是两次运行变化检测为代价的,第二次检测的目的就是为了避免此类场景。在生产模式下,修改检测则只会执行一次。
开启/关闭修改检测,并手动触发
有时候我们希望关闭修改检测,比如这样的场景:大量数据通过websocket服务端蜂拥而至,而我们则希望只要每5秒钟触发一次具体UI局部更新就好。为了实现这种效果,我们先给组件注入修改检测器:
constructor(private ref: ChangeDetectorRef) {
ref.detach();
setInterval(() => {
this.ref.detectChanges();
}, 5000);
}
如你所见,我们分离了修改检测器,这会导致修改检测功能的关闭。之后我们通过每5秒钟调用一次detectChanges()
方法来手动触发它。
总结
Angular默认的修改检测机制同Angular 1很类似:它会在浏览器事件之前和之后比较模板表达式中的值从而确定是否存在修改。所有的组件都会执行。但是也有一些重要的不同点:
第一点是不存在修改检测循环(在Angular 1中称为消化循环)。这使得仅通过查看模板和控制器就可以推断出每个组件。
另一点不同之处,因为修改检测器的构建方式不同,组件的修改检测机制比之前快得多。
最后,和Angular 1不同, 修改检测机制是可以自定义的。
关于修改检测我们真的要了解这么多吗?
对于95%的应用场景,可以信誓旦旦地说,Angular的修改检测都能良好的工作,并且关于它我们并不需要了解得太多。但是弄明白修改检测是如何工作的依然有用,有如下原因:
- 首先它可以帮助我们弄明白一些在开发时可能会遇到的异常信息,如修改检测循环;
- 有助于我们阅读异常堆栈追踪,那些突然蹦出来的
zone.afterTurnDone()
看起来终于不那么头痛了; - 在性能短缺(不过你真的确定不在那些巨型数据表格上使用分页机制吗?)的场景下,理解修改检测可以帮助我们进行性能优化。
通过下文给出的参考资料,可以获取更多关于Angular修改检测的知识。
如果您喜欢这篇文章,我们邀请您订阅Angular大学。
我们的YouTube频道
订阅我们的YouTube频道可以获取我们课程的免费早期预览。下面是一个一小时示例课:
Free Angular for Beginners Cours
参考资料
Angular中的修改检测 by Victor Savkin(@victorsavkin)
Zones Ng-Conf-2014演讲 by Brain Ford (@briantford)
Ng-Nl Change Detection Explained 演讲 by Pascal Precht (@PascalPrecht) - 暂无链接