什么是 Change Detection?深度解析 Angular 变更检测机制
在应用开发过程中,state(状态)直接决定了界面上呈现的数据内容。当 state 发生改变时,必须有一套机制能够察觉这一变化,并同步更新对应的界面。这套机制,就是我们常说的 Change Detection(变更检测)。
放在 Web 开发的语境下,更新界面本质上就是修改 DOM 树。由于 DOM 操作的开销非常巨大,如果你的变更检测机制效率低下,应用的性能就会急剧下降。因此,一个框架的变更检测实现是否高效,往往直接决定了它的性能天花板究竟能有多高。
Change Detection 的实现原理
Angular 能够精准感知组件数据何时发生变化,并自动重新渲染视图。但问题来了:在类似点击按钮这种极为底层的事件发生后,它是如何实现“自动”触发的呢?
答案就是 Zone。通过 Zone.js,Angular 能够自动触发变更检测流程。
Zone 到底是什么?通俗地讲,它是一段“执行上下文”,或者说执行环境。与我们常见的浏览器执行环境不同,在这个环境里执行的所有异步任务都被称为 Task,而 Zone 为这些 Task 提供了丰富的钩子(hook)。借助这些钩子,开发者可以非常方便地“监控”环境中的所有异步任务。
顺便提一句:Angular 十分推崇使用可观察对象(Observable)。如果你的应用完全基于 Observable 来构建,实际上可以替代 Zone 去追踪调用栈,而且性能通常比使用 Zone 还要更好一些。
// Angular 从 v5.0.0-beta.8 开始可以通过配置规避 Zone
import { platformBrowser } from '@angular/platform-browser';
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory, { ngZone: 'noop' });
覆盖浏览器默认机制
Angular 在启动时,会重写许多浏览器底层 API。比如addEventListener,这个函数用于注册所有浏览器事件,包括点击处理。Angular 会将其替换为功能类似的全新版本:
// 这是 addEventListener 的新版本
function addEventListener(eventName, callback) {
// 先调用真正的 addEventListener
callRealAddEventListener(eventName, function() {
// 首先执行原始的回调
callback(...);
// 然后运行 Angular 特有的逻辑
var changed = angular.runChangeDetection();
if (changed) {
angular.reRenderUIPart();
}
});
}
这样一来,新的addEventListener就在任何事件处理程序的基础上额外做了不少工作:它不仅调用了注册的回调,还让 Angular 有机会去执行一遍变更检测、更新 UI。
支持浏览器异步 API
被“修补”以支持变更检测的常用浏览器机制,主要包括:
- 所有浏览器事件(单击、鼠标悬停、按键等)
setTimeout()和setInterval()- Ajax HTTP 请求
实际上,Zone.js 修补了更多浏览器 API,比如 Websockets,目的就是透明地触发 Angular 变更检测。
但这种机制也存在一个局限:如果因为某些原因,某个异步浏览器 API 不被 Zone.js 支持,那么变更检测就不会被触发。IndexedDB 的回调就是一个典型的例子。
默认的变更检测机制是如何工作的?
每个 Angular 组件都拥有自己的变更检测器,这些检测器在应用启动时就已经创建好了。来看一个例子:
@Component({
selector: 'todo-item',
template: `{{todo.owner.firstname}} - {{todo.description}}
- completed: {{todo.completed}}`
})
export class TodoItem {
@Input()
todo:Todo;
@Output()
toggle = new EventEmitter
这个组件接收一个 Todo 对象作为输入,并在 todo 状态被切换时发出一个事件。
export class Todo {
constructor(public id: number,
public description: string,
public completed: boolean,
public owner: Owner) {
}
}
可以看到,Todo 有一个属性owner,它本身也是一个对象,包含firstname和lastname两个属性。
变更检测器长什么样?
其实,我们可以在运行时一睹变化检测器的真容!方法很简单,在 Todo 类里添加一些代码,在访问某个属性时触发断点即可。
断点命中后,遍历一下堆栈跟踪,就能看到变化检测的样貌:

这个方法乍一看可能有点怪异,所有变量名都很古怪。但仔细研究就会发现,它所做的事情其实非常简单:对于模板中使用的每个表达式,把该表达式中属性的当前值与它之前的值做一个比较。
如果前后的值不一致,就把isChanged设置为true。简单来说,它就是通过一个叫looseNotIdentical()的方法来比较数值。
嵌套对象owner怎么处理?
在变更检测器代码里,可以看到owner这个嵌套对象的属性也在被检查差异。但只有模板中用到的 firstname 被比较了,lastname 没有被比较,因为组件template里根本没有使用它。同样,Todo 顶层的 id 属性也因为同样的原因被跳过了。
因此,我们可以非常确定地说:
默认情况下,Angular 的 Change Detection,就是靠检查模板表达式的值是否有变化来工作的。
还能得出另一个结论:
默认情况下,Angular 不会对对象做深度比较来检测变化,它只关心模板实际使用到的那些属性。
为什么默认的变更检测要这样设计?
Angular 的一个核心目标,就是让框架更透明、更易于上手。框架用户不必费劲地调试框架或深究内部机制,也能高效地使用它。
试想一下,如果 Angular 的默认变更检测机制是基于组件输入的引用比较,而不是现在这套机制,会是什么情况?哪怕只是做一个简单的 TODO 应用,开发者也要非常小心地创建新的 Todo 对象,而不是直接更新属性,开发体验会变得非常别扭。
OnPush 变化检测策略
如果觉得默认模式影响了性能,也可以自定义 Angular 的变更检测。只需要将组件的变更检测策略改为OnPush即可:
@Component({
selector: 'todo-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ...
})
export class TodoList {
...
}
现在在应用里添加两个按钮:一个按钮直接修改列表的第一项,另一个按钮向列表里添加一个 Todo。代码大致如下:
@Component({
selector: 'app',
template: `
`
})
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;
}
}
来看看这两个按钮的行为有何不同:
- 第一个按钮“切换第一项”不起作用!因为
toggleFirst()方法是直接修改列表里的一个元素。TodoList根本检测不到这种变化,因为它收到的输入引用todos根本没有改变。 - 第二个按钮是有效的!注意,
addTodo()方法先创建了 todo 列表的副本,然后把新项添加到副本中,最后将 todos 成员变量替换成这个复制后的列表。这就会触发变更检测,因为组件发现自己输入的引用发生了变化:它拿到了一个新列表! - 在第二个按钮里,直接修改 todos 列表是行不通的!我们真的需要一个新的列表。
OnPush 只是靠引用比较输入吗?
并非如此。
当使用 OnPush 检测器时,框架会在 OnPush 组件的任何输入属性发生变化、组件触发事件、或者绑定的 Observable 触发事件时进行检查。
虽然OnPush能带来更好的性能,但如果与可变对象一起使用,它的使用成本会非常高,很容易引入一些难以排查和复现的 bug。当然,也有办法能让OnPush用得更加顺手。
用 Immutable.js 简化 Angular 应用的构建
如果我们只用不可变对象和不可变列表来构建应用,那么就能在OnPush模式下放心地到处使用,不必担心遇到变更检测错误。因为对于不可变对象来说,修改数据的唯一方法就是创建一个新的不可变对象,然后替换掉之前的对象。使用不可变对象,可以保证:
- 新的不可变对象一定会触发
OnPush的变更检测 - 不会因为忘记创建对象的新副本而意外引入 bug,因为修改数据的唯一方式就是创建新对象
实现不可变的一个好选择是 Immutable.js 这个库。它提供了不可变对象(Map)和不可变列表等构建应用的基本元素。
避免变更检测循环:生产与开发模式
Angular 变更检测的一个重要特性是,它不像 AngularJS 那样,而是强制采用单向数据流:当控制器上的数据更新时,变更检测运行并更新视图。
在 Angular 中如何触发变更检测循环?
一种方法是使用生命周期回调。比如,在 TodoList 组件里,我们可以触发对另一个组件的回调来修改某个绑定:
ngAfterViewChecked() {
if (this.callback && this.clicked) {
console.log("changing status ...");
this.callback(Math.random());
}
}
控制台就会显示一个错误消息:
EXCEPTION: Expression '{{message}} in App@3:20' has changed after it was checked
这个错误消息只在开发模式下才会被抛出。如果启用了生产模式呢?生产模式下,错误不会被抛出,问题也就被掩盖了。
所以在开发阶段最好始终使用开发模式,这样能避免问题。当然,这种保障是以 Angular 总是运行两次变更检测为代价的(第二次就是为了检测这种情况)。而在生产模式下,变更检测只运行一次。
打开/关闭变化检测,并手动触发它
在某些特殊场景下,我们确实想关闭变更检测。想象一下,有大量数据通过 websocket 从后端源源不断地推送过来。你可能只想每 5 秒更新一下 UI 的某个部分。这时候,可以先把变更检测器注入到组件里:
constructor(private ref: ChangeDetectorRef) {
ref.detach();
setInterval(() => {
this.ref.detectChanges();
}, 5000);
}
看到没,我们只是分离了变化检测器,这就相当于关掉了变化检测。然后每 5 秒通过调用detectChanges()来手动触发一次。
好了,现在快速总结一下:Angular 变更检测是什么、它是怎么工作的、主要有哪些类型。
概括
Angular 变更检测是一个内置的框架功能,它的作用是确保组件数据与 HTML 模板视图之间能够自动同步。
它的工作方式是,通过检测常见的浏览器事件(比如鼠标点击、HTTP 请求和其他类型的事件),来判断每个组件的视图是否需要更新。
变更检测主要有两种类型:
- 默认变更检测:Angular 通过比较事件发生前后所有模板表达式值的差异,来决定是否更新视图。这种方式会应用到组件树的所有组件上。
- OnPush 变更检测:它通过检测是否通过组件输入或者使用异步管道的 Observable 向组件推送了新的数据来工作。
Angular 的默认变更检测机制,实际上与 AngularJS 非常相似:都是在浏览器事件发生前后比较模板表达式的值,看有没有变化。而且它对所有组件都这么做。但两者也有一些重要区别:
一方面,Angular 没有变化检测循环,也没有 AngularJS 里那个著名的“摘要循环”。这样一来,我们只需要看模板和控制器,就能推理出每个组件的行为。
另一个区别是,因为变更检测器的构建方式,Angular 检测组件变化的机制要快得多。
最后,与 AngularJS 不同,Angular 的变更检测机制是可以定制的。
