这篇文章来自我们团队的徐姣同学,她为我们介绍了Angular 变更检测简介、哪些行为会引起Angular变更检测、谁在帮忙做变更检测、变更检测的执行顺序、Angular 变更检测性能优化
本文旨在学习梳理 Angular 整个变更检测的流程,也希望通过这篇文章,大家对Angular变更检测的机制也更加的了解。
一、Angular 变更检测简介
1.1 Angular变更检测概念
WEB应用大多数是人机交互的,需要检测到应用的状态变化并及时将变化反射到用户 UI 界面上,这就是变更检测机制的主要工作。
Angular 需要提供的一个重要功能就是要能保证程序的内部状态
和UI状态
的同步性。而这个同步功能就是Angular的变更检测的机制来维持的。
简单的说,就是Angular的变更检测器去遍历组件树并访问组件属性新值,将其与组件属性旧值对比,决定是否更新视图。
1.2 Angular变更检测树
组件实例化的时候,会自动根据用户选择的变更检测策略去生成一个变更检测器,也就是说当组件树生成的时候,逻辑上相应的变更检测树(cd树)也随之生成。
- 每一个组件都有一个变更检测器。Angualr会为每一个组件生成一个变化监测器
changeDetector
,记录组件的变化状态。
默认情况下,每次Change Detector被触发,CD树会被遍历一遍
,决定对应组件是否需要更新视图。
二、哪些行为会引起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组件变更检测顺序
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);
}
// 子组件开启onPush模式,父组件修改子组件输入
constructor() {
setTimeout(() => {
// 引入发生改变,触发了子组件的变更检查
this.userInfo1 = {
name: 'xujiao2',
age: 33,
gender: 'female',
};
console.log('父组件通过setTimeout修改@Input属性');
}, 3000);
}
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的变更检测:juejin.cn/post/699211…
- 深入分析 Angular 变更检测:juejin.cn/post/684490…
- 深入浅出 Angular 变更检测:segmentfault.com/a/119000004…
- Angular变更检测机制以及对应性能优化:juejin.cn/post/709377…
- If you think
ngDoCheck
means your component is being checked — read this article:indepth.dev/posts/1131/…
补充
当然Angular变更检查的内容远不止于此,如:
- 手动触发变更检测的方式有哪些,这些方法的区别是什么?
- 什么场景会触发ExpressionChangedAfterItHasBeenCheckedError这个报错,为什么angular会添加这个报错?
这些下次再为大家介绍。