详解 Angular2 的变更检查原理

相比 Angular1 而言 Angular2 的变更检查机制更加透明,更加可预测。但是在使用 Angular2 开发的过程中依然存在着一些场景(比如性能优化)需要我们更深入的了解在框架的底层究竟发生了什么。

本文包含如下内容:

  • 变化产生于异步操作
  • 为浏览器打补丁
    • Zone.js
    • 补丁是如何工作的
    • Angular2 中 Zone 的应用
      • 自动检查数据变化
      • 可选的变更检查
  • Angular2.x 变更检查工作原理
    • Angular 的默认变更检查如何工作
    • OnPush 模式
  • 总结
  • 参考资料

本文的描述基于 Angular 2.4.9 版本

众所周知,在 Angular1 中我们需要使用 $apply 或者其他框架提供的方法来触发 $digest 循环来进行脏值检查,但是 Angular2 可以自动检查到组件属性的数据变化,并重新渲染视图来更新数据的展示。那么 Angular2 是怎样自动检查到数据变化的呢(比如页面上任意一个文本框的输入或者 Ajax 请求的响应等)。

变化产生于异步操作

在组件初始化之后的一切数据变化均是由某个异步事件产生的,注意,这里是变化仅仅可能由异步事件来产生。因为初始化是一个同步过程,在 Angular2 框架中,组件的初始化对应的就是组件的构造方法被调用,这个构造过程是一个同步的过程。在构造过程之后就只有异步事件才会导致组件中的数据发生变化。这个事件,我们可以理解为鼠标的点击,ajax 请求,Promise,setTimeout 或者 Websocket 等等。

为浏览器打补丁

Zone.js

为了能在这些事件发生的时候及时的检查变化,在 Angular2 应用启动的时候会为许多浏览器的提供的 API 打补丁,使用代理方法来代理浏览器 API 的调用,代理方法不仅会调用监听事件时提供的回调函数,还会执行变更检查以及刷新界面。

这种为浏览器打补丁的工作,是由一个叫做 Zone.js 的库来完成的,Zone.js 是 Angular 团队在开发 Angular2 时实现的一个独立的库。Angular2 框架直接依赖 Zone.js 来实现变更检查。

Zone.js 实际上是一个异步操作的执行上下文,它为一组异步操作提供了一个统一的运行环境,并且为这一组异步过程的生命周期提供了钩子方法,方便在异步事件进行的不同阶段执行一些任务。关于 Zone.js 的详细介绍,可以阅读这篇文章。

补丁是如何工作的

为了探究 Zone.js 是如何帮助 Angular2 及时发现数据变化这个问题,首先我们深入源码看下,Zone.js 是如何为浏览器打补丁的。

源码为 Zone.js v0.6.0 版本

举一个例子,addEventListener 是浏览器提供的用于监听事件的 API,在 Angular 启动的时候将它替换成了一个新的版本,在 zone.js 文件中可以看到这样的代码:

Zone.js patchEventTargetMethods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function patchEventTargetMethods(obj, addFnName, removeFnName, metaCreator) {
if (addFnName === void 0) { addFnName = ADD_EVENT_LISTENER; }
if (removeFnName === void 0) { removeFnName = REMOVE_EVENT_LISTENER; }
if (metaCreator === void 0) { metaCreator = defaultListenerMetaCreator; }
if (obj && obj[addFnName]) {
// 在这里将 addEventListener 和 removeEventListener 分别替换为 makeZoneAwareAddListener 和 makeZoneAwareRemoveListener
patchMethod(obj, addFnName, function () { return makeZoneAwareAddListener(addFnName, removeFnName, true, false, false, metaCreator); });
patchMethod(obj, removeFnName, function () { return makeZoneAwareRemoveListener(removeFnName, true, metaCreator); });
return true;
}
else {
return false;
}
}

Zone.js 通过在 patchEventTargetMethods 方法中代理了两个事件监听 API。在 patchMethod 方法中,现将原来的 addEventListener 方法保存在 __zone_symbol__addEventListener 属性中,并将 addEventListener 替换为 makeZoneAwareAddListener 方法返回的 zoneAwareAddListener 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function patchMethod(target, name, patchFn) {
var proto = target;
// 省略了一些代码
(...)
// 获取带前缀的方法名
var delegateName = zoneSymbol(name);
var delegate;
// 检查是否已经 patch 过
if (proto && !(delegate = proto[delegateName])) {
delegate = proto[delegateName] = proto[name];
// 获取代理方法
var patchDelegate_1 = patchFn(delegate, delegateName, name);
// 将代理方法赋给对象的 addEventListener 属性
proto[name] = function () {
return patchDelegate_1(this, arguments);
};
// 将原来的方法实现作为属性添加到 proto[name] 上面
attachOriginToPatched(proto[name], delegate);
}
// 返回原方法
return delegate;
}

下面来看下 makeZoneAwareAddListener 方法都做了些什么

Zone.js makeZoneAwareAddListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function makeZoneAwareAddListener(addFnName, removeFnName, useCapturingParam, allowDuplicates, isPrepend, metaCreator) {
// 省略了一些代码
(...)
// 调度事件的方法
function scheduleEventListener(eventTask) {
var meta = eventTask.data;
attachRegisteredEvent(meta.target, eventTask, isPrepend);
return meta.invokeAddFunc(addFnSymbol, eventTask);
}
// 取消事件监听的方法
function cancelEventListener(eventTask) {
var meta = eventTask.data;
findExistingRegisteredTask(meta.target, eventTask.invoke, meta.eventName, meta.useCapturing, true);
return meta.invokeRemoveFunc(removeFnSymbol, eventTask);
}
// self 是被监听的对象, args 是监听的事件,包括事件名称和 callback
return function zoneAwareAddListener(self, args) {
// 根据事件的名称和回调方法创建封装 ZoneTask 的 data 对象
var data = metaCreator(self, args);
// 省略了一些代码
(...)
// 获取当前的 Zone
var zone = Zone.current;
var source = data.target.constructor['name'] + '.' + addFnName + ':' + data.eventName;
// 创建 ZoneTask 并开始调度这个 Task
zone.scheduleEventTask(source, delegate, data, scheduleEventListener, cancelEventListener);
};
}

在方法返回的 zoneAwareAddListener 方法中我们会看到这一句代码

1
zone.scheduleEventTask(source, delegate, data, scheduleEventListener, cancelEventListener)

它会创建 ZoneTask 并调用 scheduleEventListener 方法。这个方法中又调用了 invokeAddFunc 方法,方法中有如下的语句:

1
this.target[addFnSymbol](this.eventName, delegate.invoke, this.useCapturing);

target 是被监听的对象,target[addFnSymbol] 是浏览器提供的原始的 addEventListener 方法,在这里,Zone.js 才真正地将 Task 的 invoke 方法与事件绑定在一起。当事件被触发时候,便会调用 Task.invoke 在 invoke 中响应事件执行操作。

当事件发生的时候,就会直接调用 invoke 方法来执行用户提供的 callback 以及其他的操作。

至此也就完成了给 addEventListener 打补丁的工作,Zone.js 在加载的时候便会为浏览器上几乎全部的异步 api 打补丁,流程如下图所示:

Zone.js line 1965-1987|center

经过以上的探究,我们发现 Zone 为异步事件的处理提供了代理方法,在所有的异步事件被触发的时候都会先经过 Zone 的代理方法,这样一来,凡是在 Zone 内执行的异步事件的执行过程都在 Zone 的掌控之下,Zone 也就可以知道这一组异步事件在什么时候执行完成。而数据的变化是且仅可能是由于异步事件而产生的,那么 Angular 也就可以通过监听 Zone 的生命周期事件来得知什么时候应该进行变更检查了。

Angular2 中 Zone 的应用

自动检查数据变化

在 ng_zone.js 中可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

NgZone.prototype.onEnter = function () {
this._nesting++;
if (this._isStable) {
this._isStable = false;
this._onUnstable.emit(null);
}
};

NgZone.prototype.onLeave = function () {
this._nesting--;
this.checkStable();
};

NgZone.prototype.setHasMicrotask = function (hasMicrotasks) {
this._hasPendingMicrotasks = hasMicrotasks;
this.checkStable();
};

NgZone.prototype.setHasMacrotask = function (hasMacrotasks) { this._hasPendingMacrotasks = hasMacrotasks; };

NgZone.prototype.triggerError = function (error) { this._onErrorEvents.emit(error); };

Zone.js 暴露了一个 Zone 对象 生命周期中各阶段的钩子方法。这里列出了Angular2 所监听的事件,这些方法都会在 Zone 的各个生命周期钩子中被调用。当 NgZone run 之后,Angular 便会实例化一个叫做 ApplicationRef 的类,关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 只写出了关键的步骤
class ApplicationRef {
_views:Array = [];
constructor(private zone: NgZone) {
this.zone.onMicrotaskEmpty.subscribe(() => this.zone.run(() => this.tick());
}
tick() {
this._runningTick = true;
this._views.forEach(function (view) {
return view.ref.detectChanges();
});
}
}

onMicrotaskEmpty 事件会在当前 Zone 中的异步过程都已经完成的时候触发,当监听到这个事件后就去遍历 View 并且调用每个 view 的 detectChanges 方法来进行变更检查。

基于以上原理,在 Angular2 中我们只要使用标准的浏览器 API 发起异步过程就可以在合适的时机触发变更检查。这避免了像 Angular1.x 中那样需要调用框架提供的方法来触发 $digest 的问题,与此同时也移除了开发者对 $scope 对象的感知。

可选的变更检查

并不是所有的异步操作都有必要触发变更检查,比如我们点了一下鼠标,但这个动作并不会引发数据变化,这时我们是不希望变更检查被触发的。由于 Angular2 应用运行在 NgZone 之中,所有在 NgZone 之中的异步操作都会通知框架进行变更检查。针对这个情况,NgZone 提供了一个 runOutsideAngular 方法,只要让我们的方法在 NgZone 之外运行,就不会触发 Angular2 的变更检查了。

这里的代码演示了这个特性。

当代码不在 NgZone 中运行的时候,异步事件是不会触发变更检查的,所以即使数值更新了,但是界面的显示仍然不变。

Angular2.x 变更检查工作原理

上面的的内容解释了 Angular2 的变更检查机制是如何被触发的,那么 Angular2 的变更检查机制是如何检查变更的呢?

以下内容基于 Angular2.4.9

Angular 的默认变更检查如何工作

非 AOT 模式下 Angular2 将在运行时利用 JIT 机制创建组件的包装类,框架将会为每个组件生成相应的包装类,也就是说,对于每一个组件来讲,Angular 会为其生成至少两个类型 View_ClassName_AppWrapper_ClassName_App,根组件还会生成一个 View_ClassNameApp_Host。 作为应用组件的入口,变更检查也是从这个地方开始的。

View_ClassName_App 类主要做了下面5件事:

  1. 注入依赖
  2. 创建组件中的 DOM 元素渲染页面
  3. 响应绑定的事件
  4. 利用变更检查器执行变更检查
  5. 提供 debug 信息

Wrapper_ClassName_App 类主要是提供了组件的生命周期钩子。

一个Angular2的应用是由组件组成的树,每个组件又有自己的变更检查器,于是变更检查器们也组成了一颗变更检查器树。无论当哪一个组件的变更检查被触发时 Angular2 都 会采用深度优先遍历的方式从根节点遍历整个变更检查器树,每个组件中都会包含一个类型为 changeDetectorRef 的变更检查器,在 JIT 模式下,这些变更检查器会被编译成为 View_ClassName_App 中的
detectChangesInternal 方法,在这个方法中组件会对自己内部的数据绑定进行检查,调用自己的 ngOnChanges 生命周期方法,如果有子组件的话还会调用子组件的 internalDetectChanges 方法,将检查沿着树枝的方向进行下去,如下图所示:

Alt text | center

这个树也可以描述 Angular2 中组件的数据流,数据之所以是从上到下流动的,原因是变更检查也是从上到下的,每时每刻,每个组件中数据都是按照这个方向流动,这与 Angular1.x 不同,Angular2 的单向数据流让程序的行为更加可预测。

Alt text|center

在上图中我们可以看到,当我们往文本框中输入文字的时候,马上就会触发组件的变更检查,这时调用了 View_InventoryApp0.detectChangesInternal 方法,在方法中找到设置文本框内容的代码如下:
Alt text|center
这时会比较新旧两个值是否相同,jit_checkBinding25 方法是框架编译生成的方法,在运行时找到实际上调用的是 view_utils.checkBinding 方法:
Alt text|center
throwOnChange 的值是 false,所以这里会使用 looseIdentical 来进行新旧值的比较。这个方法存在于 lang.js 中实现如下所示:

1
2
3
export function looseIdentical(a, b) {
return a === b || typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b);
}

只是简单比较了引用或者值是否相同,并没有做深度比较。所以数组或者对象等集合类型内部的值发生变化,Angular 并不能检查到。

1
2
3
4
if (jit_checkBinding24(throwOnChange,self._expr_32,currVal_32)) {
self.renderer.setText(self._text_5,currVal_32);
self._expr_32 = currVal_32;
}

在上面的图中我们还看到了这样的代码,Angular 在检查变更之后立即更新了视图。

Alt text|center
然后当当前组件所有的变更检查执行完成之后,开始检查子组件的变更,然后变更检查器会按照深度优先的规则遍历整个组件树,直到所有节点的变更检查都完成为止。

由于单向数据流的原因,变更检查只需要执行一遍就可以稳定下来,如果在第一次检查中产生副作用使得已经检查过的节点发生了变化,Angular 会抛出异常。

OnPush 模式

在 Default 模式下,每一组异步操作结束之后都会触发对整个组件树的变更检查,在一些场景下,某些组件是不需要每次都被检查的,可以将它们标记为不可变对象,不可变对象给我们提供的保障是对象不会改变,即当其内部的属性发生变化时,我们将会用新的对象来替代旧的对象。它仅仅依赖初始化时的属性,也就是初始化时候属性没有改变(没有改变即没有产生一份新的引用),Angular将跳过对该组件的全部变化监测,直到有属性的引用发生变化为止。如果需要在Angular2中使用不可变对象,我们需要做的就是设置 changeDetection: ChangeDetectionStrategy.OnPush(如下所示) 启用 OnPush 模式来避免不必要的变更检查以提升程序的性能。

1
2
3
4
5
6
7
@Component({
// ...
changeDetection: ChangeDetectionStrategy.OnPush
}
export class InventoryApp {

}

看一个例子

Alt text|center
黄色部分是父组件,灰色的部分是子组件,子组件开启了 OnPush 模式。当我们点击黄色部分的时候,虽然改变了 this.person.name 的值,但是这个变化并不能被框架检测到,也就不能反映在视图上。使用了 OnPush 模式的组件,它的变更检查器将会被关闭,它与它的子节点都无法再检查到父组件带来的变更。

要注意的是,由节点内部产生的变化依然会触发变更检查,还看上面的例子:
Alt text|center
点击灰色的部分,也就是被设置为 OnPush 模式的子组件,这时触发了子组件中的 onclick 方法,改变了 this.person.name 的值,由于这个变更是由子组件内部事件导致的,这时将会触发变更检查,视图上的文字也会被更新。

总结

最后来总结一下 Angular 变更检查的整个过程

  1. Zone.js 为浏览器 API 打补丁
  2. NgZone 初始化,监听当前 Zone 中的异步事件执行是否完成
  3. 异步事件执行结束后出发 tick 方法开始变更检查
  4. 变更检查由根组件开始按照深度优先遍历变更检查器树
  5. 在每个数据绑定的检查结束之后,立即更新视图
  6. 在继续检查子组件直到所有组件检查完成

changeDetect@1-14|center

参考资料

Understanding Zones
ANGULAR CHANGE DETECTION EXPLAINED
Zone.js - 暴力之美
setImmediate
Zone.js API
Angular 2 中的编译器与预编译(AoT)优化