阅读 282

深入 Angular 的 Change Detection 机制

在了解 AngularJS 的 Digest Cycle 机制后,对 Angular 的 Change Detection 机制也产生了好奇。看了许多相关的文章和视频,有了些自己的理解,在此做个总结、记录,并分享一些自己的想法。博文原地址

什么是 Change Detection ?

在应用的开发过程中,state 代表需要显示在应用上的数据。当 state 发生变化时,往往需要一种机制来检测变化的 state 并随之更新对应的界面。这个机制就叫做 Change Detection 机制。

在 WEB 开发中,更新应用界面其实就是对 DOM 树进行修改。由于 DOM 操作是昂贵的,所以一个效率低下的 Change Detection 会让应用的性能变得很差。因此,框架在实现 Change Detection 机制上的高效与否,很大程度上决定了其性能的好坏。

AngularJS 的 Digest Cycle 为什么总是被人诟病?

在谈 Angular 的 Change Detection 机制之前,我们先来看一下他的「前辈」(AngularJS)的 Digest Cycle 总是被人诟病的原因。

Digest Cycle 是 AngularJS Change Detection 机制的核心。AngularJS 会为每一个绑定在 HTML 上的变量创建一个 watcher,这种形式创建的 watcher 我称其为 bind_watcher 。另外,开发者也可以通过 scope.watch( ) 方法手工的为指定的变量创建一个 watcher,我称其为 manul_watcher 。同一个 state 会有多个 bind_watcher 但只会有一个 manul_watcher 。这两种 watcher 都会被存放在 $scope.$$watchers 这个数组中。至于脏查询(Dirty Checking)的实质其实就是遍历某个 scope 及其所有 child_scope 下的 $$watchers,并校验每一个 watcher 是否脏(dirty)了。若脏了,调用对应的监听器(listener),bind_watcher 中的 listener 会修改对应的 DOM 节点,而 manul_watcher 的 listener 则是开发者自定义的一个函数。由于 listener 中可能会修改已经被检测过的 state (即这个 state 对应的 watcher 已经被检测过了),为了尽可能的保证在 Digest Cycle 后所有的 watcher 都是出于「干净」的状态,AngularJS 就不得不进行多次(缺省上限为10次)的 Dirty Checking 。可以结合下图进行理解。

上述便是 Digest Cycle 的基本原理。一个 AngularJS 应用很有可能产生成百上千的 watcher ,这种需要进行多次 Dirty Checking 的机制极其低效,所以 AngularJS 应用的性能总是被人诟病。

题外话:网上常说 AngularJS 的脏查询(Dirty Checking)怎么怎么不好,其实 Dirty Checking 本身并没有什么问题,问题在于 AngularJS 的 Change Detection 是在其特有的 watcher 机制的基础上来实现的,再加上其混乱的数据流,才不得不进行效率极低的 Digest Cycle 。这也是为什么 AngularJS 团队明知 Digest Cycle 是如此的低效,却又无法进行彻底重写的原因,因为不是简单的重写 Digest Cycle 本身就够了,还需要重新考虑 watcher 机制、数据流等一些方面的问题,这也是 Angular 不基于 AngularJS 来重写原因之一。

Angular 是如何实现 Change Detection 机制?

在了解完 AngularJS 的 Digest Cycle 之后,我们根据以下几个问题,逐步的了解 Angular 的 Change Detection 机制。

Q1:Angular 是如何触发 Change Detection 机制的?

在使用 AngularJS 开发应用时,如果我们需要使用定时器,有两种方法:

  • 使用浏览器原生的 setTimeout( ) 方法,但需要注意:如果在定时器中有修改 state ,那么需要调用 $scope.$digest( ) 方法来确保修改后的 state 能够反应在 UI 界面上。

  • 使用 AngularJS 内置的 $timeout 服务,使用它的好处是不需要手工的调用 $scope.$digest( ) 方法来刷新页面。

其实这两种方法本质都是一样的,就是:AngularJS 需要手动调用 $scope.$digest( ) 方法来触发框架的 Change Detection

而对于 Angular ,我先抛出结论:通过 Zone , Angular 能够实现自动的触发 Change Detection 机制。接下来我们就来看看 Angular 是如何实现的。

在所有的 web 应用中,有以下三种场景需要触发 Change Detection:

  • Event - 浏览器的一系列原生事件
  • XHR - XMLHttpRequest
  • Timers - setTimeout( ) 、setInterval( )

它们共同的特点就是:都是异步。而 Zone 是什么呢?简而言之,Zone 是一个执行上下文(execution context),可以理解为一个执行环境。与常见的浏览器执行环境不同,在这个环节中执行的所有异步任务都被称为 Task ,Zone 为这些 Task 提供了一堆的钩子(hook),使得开发者可以很轻松的「监控」环境中所有的异步任务。

在 Angular 中,框架会生成一个 zone ,大部分的代码都在这个 zone 中执行,如此一来,Angualr 就可以监控所有的异步任务。举个例子:假如组件 A 中有一个 setTimeout( ) 方法,一旦其回调函数被执行,就会触发 zone 的 onInvoke 钩子,然后在这个钩子中去触发 Change Detection 机制。这也就实现了「自动」触发 Change Detection 的效果。

注:ZoneJS 作为一个独立的库,在其他方面还有许多的用途,我后续会写一篇单独讲 ZoneJS 的问题。

题外话:由于 Angular 极力的推崇使用可观察对象(Observable),如果完全的基于 Observable 来开发应用,可以代替 Zone 来实现追踪调用栈的功能,且性能还比使用 Zone 会稍好一些。Angular 在 v5.0.0-beta.8 起可以通过配置不使用 Zone ,配置如下:

import { platformBrowser } from '@angular/platform-browser'
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory, { ngZone: 'noop' })
复制代码

Q2:Angular 中还需要进行多次的脏查询吗?他是怎么解决的?

组件化是现在前端发展的主要趋势之一,每一个页面都是由一个个组件组成的,这些组件构成一颗组件树。在常规情况下,Angular 依旧使用的 Dirty Checking,也就是被大家说厌了的脏查询。Angular 会从根组件开始,逐一检测每一个组件。与 AngularJS 复杂的数据流不同,Angular Change Detection 是基于组件树的单向数据流来实现的。组件树+单项数据流使得 Angular 在检测每一个组件时不需要考虑当前组件会修改父组件的数据而不得不像 AngularJS 那样进行多次的 Dirty Checking 。

结论1:Angular 的组件树+单向数据流解决了 AngularJS 中需要多次 Dirty Checking 的问题,即只需一次 Dirty Checking 就能够完成 Change Detection 。

值的一提的是,在开发模式下,Angular 会有意的进行两次 Dirty Chacking(所以在开发的应用的时候感觉应用的性能不是很好)。其目的是提示开发者:你开发的代码违背了单向数据流的策略。举个例子:当检测某个组件时,会触发组件的 DoCheck 钩子,如果开发者在这个钩子中通过某种方式(比如 Observable)修改了父组件的状态,这种做法是不符合单向数据流的(因为父组件已经被检测过),虽然在开发模式下对应的 UI 界面也会正常的被修改,但是 Angualr 会打印出错误,提示开发者这种行为是不被允许的。具体可以参考示例代码:angular-unidirection-error-demo,可以打开控制看看对应的错误。

Q3:Angular Change Detection 快在哪里?

Victor Savkin(Angular 的核心开发成员之一)在 2015 的演讲当中有提到如下的一个公式:

CHANGE DETECTION TIME = C * N

C - time to check a bind

N - number of binding

翻译成中文就:完成一次 Change Detection 所需要花费的时间 =(约等于) 检测一个组件变化所需的时间 * 绑定 state 的个数

为了提高 Change Detection 的速度,我们只需降低 C 或 N 即可。接下来我们来看看 Angular 是如何降低 C 的,即如何减少检测单个变化所需的时间的(后面我还会谈及在某些情况下,为了更高的性能,可以通过降低 N 来实现性能的提供。)。

不管是 AngularJS 还是 Angular 的 Change Detection ,它们都需要通过检测器(Detector)来检测差异。在 AngularJS 中,其检测器的伪码类似如下:

// Detector1
while (scope) {
  var getter = watcher.get;
  var oldValue = op.last;
  var newValue = getter(scope);
  
  if (oldValue !== newValue) {
    op.last = newValue;
    var fn = watcher.listener;
    fn(oldValue, newValue);
  }
  scope = scope.next;
}
复制代码

这段代码很好理解,没什么问题。但是,Angular 觉得这样的代码显然还不够快,VM(虚拟机) 并不喜欢这样「动态」的代码。你可能会问了:VM 会喜欢什么样的代码?我告诉你,VM 喜欢「单纯」的代码,比如:

// Detector2
// 组件A的html模板:<h1>{{data.title}}</h1>
// 组件A对应的 detector
class A_ChangeDetector {
  detectChanges() {
    var data = obj.data;
    var title = data.title;
    if (title !== this.last) {
      this.last = title;
      // 更新对应的 DOM 节点
      this.node.innerHTML = title;
    } 		  	
  }
}
复制代码

对比上述的两个 Detector 会发现:

Detector1 是一个通用的一个检测器,而 Detector2 是一个针对组件A的检测器。Detector2 的代码更加的简单,或者说是更加的「单纯」。

这也就解释了为什么 VM 会喜欢 Detector2 的实现了。所以可以得出的结论是:

Detector2 运行的会比 Detector1 更快。

谈到这里,你可能会有一个和我最初一样,产生一个问题:我承认 Detector2 比 Detector1 会稍微快一点,但是快的程度应该是微乎其微,就算采用 Detector2 的检测方法,优化的程度应该也不怎么明显吧?

这就涉及到一个「数量级」的问题了。诺是单纯的检测某一个 state ,Detector2 比 Detector1 两个检测器的检测速度肉眼根本无法判断它们之间的快慢。 但是随着需要被检测的 state 的数量不断的增加,Detector2 细微的优化就会被不断的放大,采用 Detector2 的应用的性能也就比采用 Detector1 的性能高出很多。

不错,Angular 就是使用类似 Detector2 的检测器。Angular 在 rumtime(如果是 AOP 编译,则是在 Compile-time 的时候) 的时候会为每一个组件创建一个对应的 Change Detector ,由于这些 Detector 就和上述的 Detector2 一样是:VM friendly code(VM 更加喜欢的代码) ,所以在某个数量级上检测的性能忽悠很大的提升,后面我会写一个 Demo 来直观的感受各个框架性能的差异。

至此,大致讲完了 Angular Change Detection 常规的实现机制。接下来谈谈:Angular 还可以更快?

Angular 还可以更快?

在一些场景中,我们会异常的关注应用的性能,比如移动端开发,比如大面积的画面渲染。接下来,我们来谈谈:Angular 其实还可以更快

还是前面讲的那个公式:CHANGE DETECTION TIME = C * N 。如果需要提高 Change Detection 的效率,我们还可以减少 N ,即通过减少需要被检测的 state 的数量来提高检测效率。

在某些场景下,作为开发者,我们其实是能够知道哪些组件是可以不用检测的。Angular 就提供了这种能力:告诉Angular 那些组件在什么情况下是不需要检测的,其中就涉及到了一个概念:Immnutable Object 。

什么是 Immutable Object ?

我们平常开发当中所使用的对象基本都是 Mutable Object ,好处是能够很大程度的节省内存(因为都是在同一个对象上进行修改)。但也有以下两个缺点:

  1. 遇到如下代码时,非常的尴尬:
export function touchAndPrint(touchFn) {
  var data = { key: 'value' };
  touchFn(data);
  console.log(data.key); // ?
}
复制代码

如上,如果 touchFn( ) 这个方法是别的同事写的,我会根本知不道最后输出 data.key 是什么,因为 data 是一个 Mutable Object ,在 touchFn( ) 中可能会被修改。可以看出 Mutable Object 有一种不可预测的性质,会对开发过程产生不必要的一些困扰,这也是为什么函数式编程变的越来越受欢迎的原因。

  1. 比较两个对象的值是否相等时,需要深度遍历。

由于是 Mutable Object 可以在同一个对象上修改某个值,所以通过 === 比较符并不能判断两个对象中的每一个值是否相等。所以我们通常会对对象进行深度遍历,逐一比较每一个值。如果只是一些简单的对象那也还好,但如果频繁的比较复杂的对象,可能就会影响应用的性能了。

回到我们要讲的 Immutable Object ,也就是「无法被修改的对象」。Immutable Object 的优缺点和 Mutable Object 正好相反,缺点是如果每一次对对象的某个属性赋值都产生一个新的对象,这显然会占据大量的内存。但是显示有些第三方库如:Immutable.js,它通过一些特殊的机制解决了这个问题。现在 Immutable Object 在许多框架中都有被使用到,特别是 React 。

Immutable Object 概念在 Angular Change Detection 中的使用

常规的 Change Detection 都是从跟组件开始进行脏查询,如下图:

作为开发者,我们有时是能够知道并不需要检测所有的组件,我们只想检测部分组件,如下图:

是的,Angular 提供了这种能力,其原理就是基于 Immutable Object 来实现的。

Angular 可以通过以下代码来告诉对应的检测器:如果组件的 state 是一个对象,那么你不需要检测对象里每一个值得变化情况(因为这个对象是 Immutable Object 类型),你只需简单的判断它和旧值是否绝对相等,即判断:newObjec === oldObject 既可。如果没有检测到组件的变化,那就没有必要检测其子组件树的变化了,因为开发者说了:如果我没变,我的子组件是不会变的。

@component({
  selector: 'xxx',
  templateUrl: 'xxx',
  changeDetection: ChangeDetectionStrategy.OnPush
})
复制代码

希望上述的描述你能够大致的理解设置 ChangeDetectionStrategy.OnPush 的作用。接着讲讲何时可以使用 ChangeDetectionStrategy.OnPush 。

每次 Change Detection 之所以是从根组件开始从上至下进行检测,是因为任何一个组件都可以通过某个服务来间接的修改任意组件的 state(注意这和单向数据流并不矛盾,Angular 中的单向数据流指的是某一个具体的 state 只能从上往下流动,父组件能把 state 传给子组件,而子组件只可以将某个事件透传给父组件,其并不可以传递具体的 state 给父组件。)。

小结一下:Anuglar 提供了一种方式来优化 Change Detection 的性能表现,这种方式是基于 Immutable Object 并通过检测某个组件是否 Change 来决定检测器是否继续检测其所有子树的方式来尽可能的减少需要被检测的组件数量,以达到提高检测的效率的目的。

谈谈 ChangeDetectionRef 中各个方法的作用

在优化 Angular Change Detection 的过程中,常常会使用到 ChangeDetectionRef 中提供的方法,下面我通过图文并茂的方式逐一解释其中的每一个方法。

  • ChangeDetectionRef.markForCheck( )

该方法可以理解为:在执行这个方法后的第一次 Change Detection 中,忽视当前组件或是父组件中 ChangeDetectionStrategy.OnPush 对所在 Change Detection 的影响。

  • ChangeDetection.detectChanges( )

该方法会从当前组件开始触发一次 Change Detection 。

  • ChangeDetection.checkNoChanges( )

该方法会从当前组件开始触发一次 Change Detection ,如果有检测某个组件 Change ,抛出异常并停止检测。

  • ChangeDetectionRef.detach( ) 和 ChangeDetectionRef.reattach( )

开发者可以通过这两个方法手工的将所在组件与其子组件分离或是重新连接。

举一例子,直观的感受优化的力量

为了能够更加直观的感受对 Angular Change Detection 优化后的表现,我写了一个项目,比较了 AngularJS Anuglar、React、Vue 四种框架在某一特定场景的性能,其中 Anuglar 有两个版本,分别是 normal 版本和 faster 版本,另外两个框架都是 normal 版本。而这个项目描述的场景是:加载一个日历,在渲染日历的过程需要不断的向服务端(用 setTimeout 取代了 http 请求)获取数据。项目地址:ChangeDetectionCompare

提前声明:这里的性能比较不是为了说明 Angular 比另外两个框架都话,关于这点,我后面会稍微谈谈自己个人的想法,此次比较只是单纯的感受对 Angular Change Detection 优化后的表现。

以下是各个版本在我电脑上完成渲染所花费的时间(都是在生产环境)。

这里的数据都是执行三次,取速度最快的一次,数据仅供参考。

至于具体的优化原理,结合上述对 Angular Change Detection 的解释,再看看项目的代码,相信你能够看懂。

相关链接

评论