浅谈 Angular 变更检测

990 阅读10分钟

这篇文章来自我们团队的徐姣同学,她为我们介绍了Angular 变更检测简介、哪些行为会引起Angular变更检测、谁在帮忙做变更检测、变更检测的执行顺序、Angular 变更检测性能优化

本文旨在学习梳理 Angular 整个变更检测的流程,也希望通过这篇文章,大家对Angular变更检测的机制也更加的了解。

一、Angular 变更检测简介

1.1 Angular变更检测概念

WEB应用大多数是人机交互的,需要检测到应用的状态变化并及时将变化反射到用户 UI 界面上,这就是变更检测机制的主要工作。

Angular 需要提供的一个重要功能就是要能保证程序的内部状态UI状态的同步性。而这个同步功能就是Angular的变更检测的机制来维持的。

简单的说,就是Angular的变更检测器去遍历组件树并访问组件属性新值,将其与组件属性旧值对比,决定是否更新视图。

1.2 Angular变更检测树

组件实例化的时候,会自动根据用户选择的变更检测策略去生成一个变更检测器,也就是说当组件树生成的时候,逻辑上相应的变更检测树(cd树)也随之生成。

  • 每一个组件都有一个变更检测器。Angualr会为每一个组件生成一个变化监测器changeDetector ,记录组件的变化状态。

默认情况下,每次Change Detector被触发,CD树会被遍历一遍,决定对应组件是否需要更新视图。

1_1674871115046.png

二、哪些行为会引起Angular变更检测

从Angular变更检测机制的目的可以知道,凡是可能会引起视图变化的操作,angular变更检测器都会去遍历CD树。经过经验总结,大家发现所有的视图变更都来自于以下几种行为:

  • 浏览器事件:click、onmouseover、keyup等
  • 定时器:setTimeout、setInterval等
  • 异步API: ajax、fetch、promise.then等

所以默认情况下,发生了以上行为,angular变更检测器都会保守的去遍历CD树以确保视图的正确渲染。

但是当angular应用足够复杂时,CD树也会非常的庞大,变更检测是一个非常高频的操作,默认的全量检查的策略非常耗费性能。Angular提供了另外一种onPush的变更策略(除了开启onPush策略,还有其他优化手段), 也称为按需的变更策略,当angular变更检测器遍历时,会跳过 开启onPush变更策略的CD节点(除非内部主动将其标注为要检测)。在OnPush策略下引起组件的变更检查的行为有以下几种:

  • 输入属性发生变化时
  • DOM事件触发时(即使这个事件是个空的函数或者操作的不是@Input属性,也仍然会导致变更检测的发生)(其他异步事件更换数据都不会触发变更检测
  • 手动触发变更检测

三、谁在帮忙做变更检测

文章第二章节梳理了哪些行为会引起我们的变更检测,那么在angular中是谁在监听这些行为,最终去执行变更检测的呢?

答案是:applicationRef 监听ngZone中拦截的异步事件,执行tick函数,最终决定是否更新视图。

3.1 ngZone 简介

Zone.js 用于封装和拦截浏览器中的异步活动、它还提供 异步生命周期的钩子 和 统一的异步错误处理机制。

我们知道引起变更检测的三个类型事件(浏览器事件、XHR事件、Timer事件)的共同点就是他们都是异步事件。

我们需要从这些事件中识别中真正会引起视图变化的事件。ngZone就是angular团队基于zone.js拓展出来的,为我们提供了统一处理和标志这些事件的能力,且做了部分性能优化(对于一些频繁的操作,可以不去触发变更检测)。

3.2 applicationRef 是什么

官网的描述是:applicationRef 是对页面上运行的angular应用程序的一个引用。

我们在创建了一个Angular 应用后,Angular 会同时创建一个 ApplicationRef 的实例。

  • ApplicationRef 创建的同时,会订阅 ngZone 中的 onMicrotaskEmpty事件,在所有的微任务完成后,遍历并调用所有的视图的detectChanges()来执行变更检测(减少遍历次数)。

     this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({
        next: () => {
            this._zone.run(() => {
                this.tick(); // 遍历并调用所有的视图viewRef的detectChanges()来执行变更检测
            });
        }
    });
    
  • 加载组件时

    private _loadComponent(componentRef: ComponentRef<any>): void {
     ...其他代码
     this.tick();
     ...其他代码
    }
    

3.3 tick方法

applicationRef 中有个比较重要的方法,tick方法,调用此方法可以显式处理更改检测及其副作用。 在开发模式下,tick() 还执行第二个变化检测周期以确保没有检测到进一步的变化。

如果在第二个周期中发现了额外的变化,在一次简单的变更检测中我们无法判断是否会有副作用,在这种情况下,Angular 会抛出一个错误(ExpressionChangedAfterItHasBeenCheckedError)。因为 Angular 应用程序只能有一个变化检测通过,在此期间必须完成所有更改检测。

 tick(): void {
    // 如果是开发态,且应用被销毁,给出报错提示:ApplicationRef 实例已经被销毁
    NG_DEV_MODE && this.warnIfDestroyed();
    // 如果当前tick方法已经被调用,给出报错提示:ApplicationRef.tick 方法被递归调用了
    if (this._runningTick) {
      throw new RuntimeError(
          RuntimeErrorCode.RECURSIVE_APPLICATION_REF_TICK,
          ngDevMode && 'ApplicationRef.tick is called recursively');
    }

    try {
      this._runningTick = true;
      // 对所有视图进行变更检测
      for (let view of this._views) {
        /**实际执行的是detectChangesInternal方法**/
        view.detectChanges();
      }
      // 如果是开发态
      if (typeof ngDevMode === 'undefined' || ngDevMode) {
        // 再重新对视图执行一遍变更检测
        for (let view of this._views) {
          /**
          * checkNoChanges 作用: 检查变化检测器及其子对象,如果检测到任何变化则抛出错误。
          * 这用于开发模式以验证运行的更改检测没有引入其他变化。
          */
          view.checkNoChanges();
        }
      }
    } catch (e) {
      // 注意:不要重新抛出,因为它可能会取消对 Observables 的订阅!
      this._zone.runOutsideAngular(() => this._exceptionHandler.handleError(e));
    } finally {
      this._runningTick = false;
    }
  }

四、变更检测的执行顺序

4.1组件变更检测顺序

2_1674897425261.png

4.1.1 默认情况下Angular变更检测顺序如下:

  • 检测父组件:

    • 更新子组件的输入绑定
    • 回调子组件的onDoCheck钩子(当检测父组件时调用了子组件的ngDoCheck)
    • 更新父组件DOM
  • 检测子组件

    • 更新孙组件的输入绑定
    • 调用孙自己的onDoCheck钩子
    • 更新子组件的DOM
  • 检测孙组件

    • 更新孙组件的DOM

4.1.2 子组件开启onPush策略Angular变更检测顺序如下:

  • 检测父组件

    • 更新子组件的输入绑定

    • 回调子组件的onDoCheck钩子(当检测父组件时调用了子组件的ngDoCheck)

    • 更新父组件DOM

    • 如果绑定属性改变了 -- > 检测子组件

      • 更新孙组件输入绑定

      • 调用孙组件的onDoCheck钩子

      • 更新子组件的DOM

      • 检测孙组件

        • 更新孙组件的DOM

从上面的流程中可以看到,开启子组件的onPush策略后,如果子组件绑定属性没有改变,那么子组件的变更检查不会进行(UI没有重新渲染),但是它的onDoCheck钩子仍然被调用了。

// 子组件开启onPush模式,父组件修改子组件输入
constructor() {
    setTimeout(() => {
        this.userInfo1.name = 'xujiao1'; // 引用没有发生改变
        console.log('父组件通过setTimeout修改@Input属性');
    }, 3000);
}

3_1674897506684.png

// 子组件开启onPush模式,父组件修改子组件输入
  constructor() {
    setTimeout(() => {
      // 引入发生改变,触发了子组件的变更检查
      this.userInfo1 = {
        name: 'xujiao2',
        age: 33,
        gender: 'female',
      };
      console.log('父组件通过setTimeout修改@Input属性');
    }, 3000);
  }

4_1674897542512.png

4.2 ngDoCheck 和 ngOnChanges 的区别

ngDoCheck 和 ngOnChanges 功能很类似,都会监听组件的一些数据变化。其主要区别如下:

4.2.1 ngDoCheck

从上面的总结可以知道,ngDoCheck 和组件是否执行了变更检测没有强相关的关系。如果组件开启了onPush策略,其绑定的输入属性没有发生改变,那么变更检测时会忽略这个组件以及其子组件,但是该组件的ngDoCheck方法仍然被调用。

  • 作用:onPush策略开启后,输入绑定的对象会被当做不可变对象Immutables。如上面案例中的userInfo1。修改其name属性并不会引起变更检查(引用没有发生改变)。在这种情况下,我又希望子组件能够识别到,这个时候可以在子组件里调用一下ngDoCheck, 手动标注成需要检测的。更详细的内容可以阅读:indepth.dev/posts/1131/…

    ngDoCheck() {
        // check for object mutation
        if (this.name !== this.userInfo.name) {
            this.cd.markForCheck();
        }
    }
    

4.2.2 ngOnChanges

输入属性(@Input)发生变化时调用该生命周期方法。ngOnChanges触发一定会伴随ngDoCheck触发,但是ngDoCheck不一定会伴随ngOnChanges的触发。

ngDoCheck的触发频率非常高,通常ngOnChanges里面的变化数据才是我们关心的数据,所以一般不建议同时实现ngDoCheck和ngOnChanges。作为开发者,大多数情况下只需要实现ngOnChanges即可。

五、Angular 变更检测 性能优化

我们知道默认情况下,angular变更检测是个非常高频的操作,如果Angular应用足够复杂,那么逻辑上对应的CD树也非常的复杂。每次变更检测需要全量的遍历整颗CD树,是非常耗费性能的。那么我们可以通过尽可能减少遍历次数来优化变更检查的性能。

5.1 开启onPush策略

前面已做了一些描述。开启了onPush策略后,告诉变更检测器,该组件 & 子组件在变更检查时不需要被检查。开启onPush策略后,以下两个概念需要注意一下:

  • 不可变对象(Immutable):开启onPush策略后,会把组件输入属性当前不可变对象来比较(只比较引用,不深度比较值)。

  • async pipe:开启onPush策略后,当前输入的属性是一个流时,其引用始终不变,我们如果需要实时获取到流中最新的数据,可以使用异步管道。异步管道实际上 不是比较新旧的值,而是把每一次管道中流出来的值都当做最新的值,哪怕和之前的没有区别。async pipe实际上就是每次更新值的时候手动将组件标注成需要变更检测,其核心代码如下:

    @Pipe({
      name: 'async',
      pure: false,
      standalone: true,
    })
    export class AsyncPipe implements OnDestroy, PipeTransform {
      transform(obj){
        if (!this._obj) {
          if (obj) { this._subscribe(obj);}
          return this._latestValue;
        }
        if (obj !== this._obj) {
          this._dispose();
          return this.transform(obj);
        }
        return this._latestValue;
      }
    
      private _updateLatestValue(async: any, value: Object): void {
        if (async === this._obj) {
          this._latestValue = value;
          // 手动标注成需要变更检测
          this._ref!.markForCheck();
        }
      }
    }
    

5.3 利用ngZone

我们知道引起变更检测的异步操作有很多,但不是所有的操作都需要引起Angular应用的变更检测,我们可以利用ngZone将这些异步行为排除掉。如:

let onErrorSubscription: Subscription;
// 不引起变更检查
ngZone.runOutsideAngular(() => {
    onErrorSubscription = ngZone.onError.subscribe({
        next: (error: any) => {
            exceptionHandler!.handleError(error);
        }
    });
});

5.3 用 pure pipe代替 {{ function(data) }}

  • 在html文件内,{{function(data)}}的写法会导致每次变更检测发生时,所有数值都会重新被计算。 在当一个1000条的列表,你只修改了其中一条数据,但另外另外999条无需更新的数据也会被重新运算。
  • 纯管道和非纯管道是相对于管道所传的参数(如上例的 data)而言的。如果管道是纯管道,只监听基本类型的参数的变化或者引用类型引用的变化;然而, 对于非纯管道,不管是基本类型参数的改变还是引用类型内部数据变化(而非引用变化)都可以触发管道。

六、学习资料

补充

当然Angular变更检查的内容远不止于此,如:

  • 手动触发变更检测的方式有哪些,这些方法的区别是什么?
  • 什么场景会触发ExpressionChangedAfterItHasBeenCheckedError这个报错,为什么angular会添加这个报错?

这些下次再为大家介绍。