详解 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 | function patchEventTargetMethods(obj, addFnName, removeFnName, metaCreator) { |
Zone.js 通过在 patchEventTargetMethods 方法中代理了两个事件监听 API。在 patchMethod 方法中,现将原来的 addEventListener 方法保存在 __zone_symbol__addEventListener
属性中,并将 addEventListener
替换为 makeZoneAwareAddListener
方法返回的 zoneAwareAddListener
方法。
1 | function patchMethod(target, name, patchFn) { |
下面来看下 makeZoneAwareAddListener
方法都做了些什么
Zone.js makeZoneAwareAddListener
1 | function makeZoneAwareAddListener(addFnName, removeFnName, useCapturingParam, allowDuplicates, isPrepend, metaCreator) { |
在方法返回的 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 为异步事件的处理提供了代理方法,在所有的异步事件被触发的时候都会先经过 Zone 的代理方法,这样一来,凡是在 Zone 内执行的异步事件的执行过程都在 Zone 的掌控之下,Zone 也就可以知道这一组异步事件在什么时候执行完成。而数据的变化是且仅可能是由于异步事件而产生的,那么 Angular 也就可以通过监听 Zone 的生命周期事件来得知什么时候应该进行变更检查了。
Angular2 中 Zone 的应用
自动检查数据变化
在 ng_zone.js 中可以看到:
1 |
|
Zone.js 暴露了一个 Zone 对象 生命周期中各阶段的钩子方法。这里列出了Angular2 所监听的事件,这些方法都会在 Zone 的各个生命周期钩子中被调用。当 NgZone run 之后,Angular 便会实例化一个叫做 ApplicationRef
的类,关键代码如下:
1 | // 只写出了关键的步骤 |
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_App
和 Wrapper_ClassName_App
,根组件还会生成一个 View_ClassNameApp_Host
。 作为应用组件的入口,变更检查也是从这个地方开始的。
View_ClassName_App
类主要做了下面5件事:
- 注入依赖
- 创建组件中的 DOM 元素渲染页面
- 响应绑定的事件
- 利用变更检查器执行变更检查
- 提供 debug 信息
Wrapper_ClassName_App
类主要是提供了组件的生命周期钩子。
一个Angular2的应用是由组件组成的树,每个组件又有自己的变更检查器,于是变更检查器们也组成了一颗变更检查器树。无论当哪一个组件的变更检查被触发时 Angular2 都 会采用深度优先遍历的方式从根节点遍历整个变更检查器树,每个组件中都会包含一个类型为 changeDetectorRef
的变更检查器,在 JIT 模式下,这些变更检查器会被编译成为 View_ClassName_App
中的detectChangesInternal
方法,在这个方法中组件会对自己内部的数据绑定进行检查,调用自己的 ngOnChanges 生命周期方法,如果有子组件的话还会调用子组件的 internalDetectChanges
方法,将检查沿着树枝的方向进行下去,如下图所示:
这个树也可以描述 Angular2 中组件的数据流,数据之所以是从上到下流动的,原因是变更检查也是从上到下的,每时每刻,每个组件中数据都是按照这个方向流动,这与 Angular1.x 不同,Angular2 的单向数据流让程序的行为更加可预测。
在上图中我们可以看到,当我们往文本框中输入文字的时候,马上就会触发组件的变更检查,这时调用了 View_InventoryApp0.detectChangesInternal
方法,在方法中找到设置文本框内容的代码如下:
这时会比较新旧两个值是否相同,jit_checkBinding25
方法是框架编译生成的方法,在运行时找到实际上调用的是 view_utils.checkBinding
方法:throwOnChange
的值是 false,所以这里会使用 looseIdentical
来进行新旧值的比较。这个方法存在于 lang.js
中实现如下所示:
1 | export function looseIdentical(a, b) { |
只是简单比较了引用或者值是否相同,并没有做深度比较。所以数组或者对象等集合类型内部的值发生变化,Angular 并不能检查到。
1 | if (jit_checkBinding24(throwOnChange,self._expr_32,currVal_32)) { |
在上面的图中我们还看到了这样的代码,Angular 在检查变更之后立即更新了视图。
然后当当前组件所有的变更检查执行完成之后,开始检查子组件的变更,然后变更检查器会按照深度优先的规则遍历整个组件树,直到所有节点的变更检查都完成为止。
由于单向数据流的原因,变更检查只需要执行一遍就可以稳定下来,如果在第一次检查中产生副作用使得已经检查过的节点发生了变化,Angular 会抛出异常。
OnPush 模式
在 Default 模式下,每一组异步操作结束之后都会触发对整个组件树的变更检查,在一些场景下,某些组件是不需要每次都被检查的,可以将它们标记为不可变对象,不可变对象给我们提供的保障是对象不会改变,即当其内部的属性发生变化时,我们将会用新的对象来替代旧的对象。它仅仅依赖初始化时的属性,也就是初始化时候属性没有改变(没有改变即没有产生一份新的引用),Angular将跳过对该组件的全部变化监测,直到有属性的引用发生变化为止。如果需要在Angular2中使用不可变对象,我们需要做的就是设置 changeDetection: ChangeDetectionStrategy.OnPush
(如下所示) 启用 OnPush 模式来避免不必要的变更检查以提升程序的性能。
1 | @Component({ |
看一个例子
黄色部分是父组件,灰色的部分是子组件,子组件开启了 OnPush 模式。当我们点击黄色部分的时候,虽然改变了 this.person.name
的值,但是这个变化并不能被框架检测到,也就不能反映在视图上。使用了 OnPush 模式的组件,它的变更检查器将会被关闭,它与它的子节点都无法再检查到父组件带来的变更。
要注意的是,由节点内部产生的变化依然会触发变更检查,还看上面的例子:
点击灰色的部分,也就是被设置为 OnPush 模式的子组件,这时触发了子组件中的 onclick 方法,改变了 this.person.name
的值,由于这个变更是由子组件内部事件导致的,这时将会触发变更检查,视图上的文字也会被更新。
总结
最后来总结一下 Angular 变更检查的整个过程
- Zone.js 为浏览器 API 打补丁
- NgZone 初始化,监听当前 Zone 中的异步事件执行是否完成
- 异步事件执行结束后出发 tick 方法开始变更检查
- 变更检查由根组件开始按照深度优先遍历变更检查器树
- 在每个数据绑定的检查结束之后,立即更新视图
- 在继续检查子组件直到所有组件检查完成
参考资料
Understanding Zones
ANGULAR CHANGE DETECTION EXPLAINED
Zone.js - 暴力之美
setImmediate
Zone.js API
Angular 2 中的编译器与预编译(AoT)优化