Angular 中的变化检测机制

Angular 中的变化检测机制

今天在群里看到一个讨论,是关于 Angular 的变化检查机制,依稀记得在 AngularJS 当中是使用的脏检查机制,而在 Angular 2.x+ 之后的版本当中依然采用的是脏检查机制,不过使用的是进行优化过的版本,为了探明到底有啥区别,就打算抽点时间,研究研究新版本的脏检查机制,顺便记录记录,就当加深点印象了

为保持区别,文中所提到的 Angular 均为 2.x+ 的版本,而 AngularJS 则代表 1.x+ 的版本,不过本文当中的 Angular 大部分相关内容还是以 2.x+ 版本为主

之前在探讨 Angular 中的 ExpressionChangedAfterItHasBeenCheckedError 这个错误的时候也涉及到了一些 Angular 的变化检查机制的内容,所以今天就一起来深入的研究一下 Angular 当中的变化检查机制

什么是变化检测

一句话概括就是『一种更改检测机制,用于遍历组件树,检查每个组件的变化,并在组件属性发生变化的时候触发 DOM 的更新』,变化检测(脏检查)的基本任务是获取程序内部状态的变化,并使其在用户界面上以某种方式可见,这种状态的变化可以来自于 JavaScript 的任何数据结构,最终呈现为用户界面中的段落、表单、链接或者按钮等 DOM 对象

然而在程序运行时发生变化情况比较复杂,我们需要确定模型中发生什么变化,以及什么地方需要更新 DOM 节点,毕竟操作 DOM 树十分昂贵,所以我们不仅需要找出待更新的地方,还需要保持操作数尽可能小,关于更多循环脏值检测可以见 The Bad Parts

数据的变化

那么问题来了,既然是变化检测,那么数据在何时会变化,又是哪些因素会引起数据变化呢?基本上应用程序状态的改变可以由三类活动引起

  • 用户输入操作,比如点击,表单提交等
  • 请求服务端数据
  • 定时事件,比如 setTimeoutsetInterval

这几点有一个共同点,就是它们都是异步的,也就是说,所有的异步操作是可能导致数据变化的根源因素,所以每当执行一些异步操作时,我们的应用程序状态可能发生改变,而这时则需要去更新视图

通知变化

在数据进行变化了之后,在 Angular 中又是谁来通知数据即将变化的呢?在 AngularJS 当中使用了观察者和监听器的概念,一个观察者是一个用来返回一个被监听的对象的值的函数,一般是由 $scope.$apply() 或者 $scope.$digest 来进行触发,而在 Angular 当中则接入了 NgZone,由它来监听 Angular 所有的异步事件,Angular 在启动时会重写(通过 Zone.js)部分底层浏览器 API,比如下面的 addEventListener

1
2
3
4
5
6
7
8
9
10
11
12
13
// this is the new version of addEventListener
function addEventListener(eventName, callback) {
// call the real addEventListener
callRealAddEventListener(eventName, function () {
// first call the original callback
callback()
// and then run Angular-specific functionality
var changed = angular2.runChangeDetection()
if (changed) {
angular2.reRenderUIPart()
}
})
}

而在 Angular 当中常见的有两种方式来触发变化检测,一种方法是基于组件的生命周期钩子

1
2
3
4
5
6
ngAfterViewChecked() {
if (this.callback && this.clicked) {
console.log('changing status ...')
this.callback(Math.random())
}
}

在开发模式下运行 Angular 会在控制台中得到一条错误日志,生产模式下则不会抛出,另一种方法是手动控制变化检测的打开或者关闭,并手动触发

1
2
3
4
5
6
constructor(private ref: ChangeDetectorRef) {
ref.detach()
setInterval(() => {
this.ref.detectChanges()
}, 5000)
}

改善的脏检查

同样是循环脏值检测,虽然 Angular 并没有类似于 AngularJS 的观察者的概念,但是跟踪数据模型属性变化的函数还是存在,它们只跟踪数据模型中的变化,而不像 AngularJS 中跟踪所有的内容,Angular 的核心是组件化,组件的嵌套会使得最终形成一棵组件树,Angular 的变化检测可以分组件进行,每个组件都有对应的变化检测器 ChangeDetector,可想而知这些变化检测器也会构成一棵树,如下图所示

另外,Angular 的数据流是自顶向下,从父组件向子组件的的单向流动,变化监测树与之相呼应,单项数据量保证变化监测的高效性和可预测性,尽管检查了父组件之后,子组件可能会改变父组件的数据使得父组件需要再次被检查,这是不被推荐的数据处理方式

在开发模式下,Angular 会进行二次检查,如果出现上述情况,二次检查就会产生如文章开头部分所提到的 ExpressionChangedAfterItHasBeenCheckedError 错误,而在生产环境中,脏检查只会执行一次,相比之下,AngularJS 采用的是双向数据流,错综复杂的数据流使得它不得不多次检查,使得数据最终趋向稳定

但是在理论上,数据可能永远不稳定,而 AngularJS 给出的策略是,脏检查超过 10 次,就认为程序有问题,不再进行检查,这是因为 $digest 循环的上限是 10 次(至于原因,可以参考 angular-digest-loop

Angular 中的变化检测

首先我们需要注意的是在 Angular 中每个组件都有自己的变化检测器,这使得我们可以对每个组件分别控制如何以及何时进行变化检测

由于每个组件都有其自己的变化检测器,即一个 Angular 应用程序由一个组件树组成,所以逻辑结果就是我们也有一个变化检测器树,这棵树也可以看作是一个流向图,而数据总是从上到下流动,数据从上到下的原因是因为变化检测也总是从上到下对每一个单独的组件进行,每一次从根组件开始,单向数据流比循环脏检查更可预测,我们总是可以知道视图中使用的数据来自哪里

我们假设在组件树的某个地方触发一个事件,比如一个按钮被点击,NgZone 会进行事件的处理并通知 Angular,然后变化检测依次向下传递,另外,Angular 还提供了定制变化检测策略的能力

1
2
3
4
export enum ChangeDetectionStrategy { 
OnPush, // 表示变化检测对象的状态为 `CheckOnce`
Default, // 表示变化检测对象的状态为 `CheckAlways`
}

从上面的 ChangeDetectionStrategy 可以看到,Angular 有两种变化检测策略,DefaultAngular 默认的变化检测策略,也就是之前提到的脏检查(只要有值发生变化,就全部检查,但是是经过优化后的单向数据流检查),但是也可以根据使用场景来设置更加高效的变化检测方式 onPush,就是只有当输入数据的引用发生变化或者有事件触发时,组件才进行变化检测(比如纯展示使用的 UI 组件就比较适用于这个策略)

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
template: `
<h2>{{vData.name}}</h2>
<span>{{vData.email}}</span>
`,
// 设置该组件的变化检测策略为 onPush
changeDetection: ChangeDetectionStrategy.OnPush
})

class VCardCmp {
@Input() vData
}

比如上面这个例子,当 vData 的属性值发生变化的时候,这个组件不会发生变化检测,只有当 vData 重新赋值的时候才会发生变化检测,当组件中的输入对象是不变量时,可采用 onPush 变化检测策略,减少变化检测的频率

换个角度来说,为了更加智能地执行变化检测,可以在只接受输入的子组件中采用 onPush 策略,当输入属性不变时,Angular 可以跳过整个变更检测子树,如果我们在 Angular 应用程序中使用不可变对象,我们所需要做的就是告诉 Angular 组件可以跳过变化检测(如果它的输入没有改变的话),正如上面的例子所示,VCardCmp 只依赖于它的输入属性,我们可以告诉 Angular 跳过这个组件的子树的变化检测

更优的变化检测

Angular 每次都要检查每个组件,因为事件发生的原因也许是应用程序状态已经改变,但是如果我们能够告诉 Angular 只对那些改变状态的应用程序部分运行变化检测,那不是很好吗?事实证明,有些数据结构可以给我们什么时候发生变化的一些保证,那就是 ImmutablesObservables

Immutables

比如我们拥有一个组件 VCardApp 使用 v-card 作为子组件,其具有一个输入属性 vData,并且我们可以使用 changeData 方法改变 vData 对象的 name 属性(并不会改变该对象的引用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component({
template: '<v-card [vData]="vData"></v-card>'
})

class VCardApp {
constructor() {
this.vData = {
name: 'zhangsan',
email: 'zhangsan@mail.com'
}
}

changeData() {
this.vData.name = 'lisi'
}
}

当某些事件导致 changeData 执行时,vData.name 发生改变并传递至 v-card 中,v-card 组件的变化检测器检查给定的数据新 vData 是否与以前一样,在数据引用未变但是其参数改变的情况下,Angular 也需要对该数据进行变化监测

这就是 immutable 数据结构发挥作用的地方,Immutable 为我们提供不可变的对象,这意味着如果我们使用不可变的对象,并且想要对这样的对象进行更改,我们会得到一个新的引用(保证原始对象不变)

1
2
3
4
5
6
7
var vData = Immutables.create({
name: 'lisi'
})

var vData2 = vData.set('name', 'zhangsan')

vData === vData2 // false

Observables

与不可变的对象不同,当进行更改时 Observables 不会给我们提供新的引用,而是发射我们可以订阅的事件来对他们做出反应,比如下面这个示例,一个购物车示例,每当用户将产品放入购物车时,我们需要在用户界面中显示一个小计数器,以便用户可以看到购物车中的产品数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component({
template: '{{counter}}',
changeDetection: ChangeDetectionStrategy.OnPush
})

class CartBadgeCmp {
@Input() addItemStream: Observable<any>
counter = 0

ngOnInit() {
this.addItemStream.subscribe(() => {
this.counter++ // application state changed
})
}
}

该组件有一个 counter 属性和一个输入属性 addItemStream,当产品被添加到购物车时,这是一个被触发的事件流,另外,我们设置了变化检测策略为 OnPush,只有当组件的输入属性发生变化时,变化检测才会执行,如前所述,引用 addItemStream 永远不会改变,所以组件的子树从不执行变更检测

当整个树被设置成 OnPush 后,我们如何通知 Angular 需要对这个组件进行变化检测呢?正如我们所知,变化检测总是从上到下执行的,所以我们需要的是一种可以检测树的整个路径到发生变化的组件的变化的方法,我们可以通过依赖注入访问组件的 ChangeDetectorRef,这个注入来自一个叫做 markForCheckAPI,它标记从组件到根的路径,以便下次更改检测的运行

1
2
3
4
5
6
7
8
constructor(private cd: ChangeDetectorRef) { }

ngOnInit() {
this.addItemStream.subscribe(() => {
this.counter++ // application state changed
this.cd.markForCheck() // marks path
})
}

下面是在可观察事件被触发后,变化检测开始前

现在当执行更改检测时,它将从上到下进行

并且一旦更改检测运行结束,它将恢复 OnPush 整个树的状态

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×