浅析Angular变更检测机制
change detection ------ 变更检测
参考资料
写在开始:为什么要写这篇文章?
这要从今年1月5号一场异常耻辱的面试说起。
面试官:讲一下Angular双向绑定的原理?
我懵了?大脑中疯狂检索:好像之前看过博客?什么检测来着?什么脏数据来着?好像和性能挺有关系来着?Angular?NgModel?一个Input一个Output[()]?...
我:NgModel?
面试官:......
我:getter setter!
面试官:......那是Vue的来着。
我:脏数据!
面试官:......
当我下楼的时候,我真是汗颜,也无颜再见江东父老。
在此,也感谢面试官对我的"严刑拷打"。
Angular官网虽然将Input一个属性例如set
和Output一个事件例如:setChange()
定义为双向数据绑定的原理。但是,这里我们将要讨论的是实现双向数据绑定的机制------变更检测机制。
废话少说,让我们直接开始。
概述:什么是变更检测
Angular官网的描述是:
Angular框架会通过此机制将应用程序UI的状态与数据的状态同步。变更检测器在运行时会检查数据模型的当前状态,并在下一轮迭代时将其和先前保存的状态进行比较。当应用逻辑更改组件数据时,绑定到视图中DOM属性上的值也要随之更改。变更检测器负责更新视图以反映当前的数据模型。类似地,用户也可以与 UI进行交互,从而引发要更改数据模型状态的事件。这些事件可以触发变更检测。
The mechanism by which the Angular framework synchronizes the state of the UI of an application with the state of the data. The change detector checks the current state of the data model whenever it runs, and maintains it as the previous state to compare on the next iteration.
As the application logic updates component data, values that are bound to DOM properties in the view can change. The change detector is responsible for updating the view to reflect the current data model. Similarly, the user can interact with the UI, causing events that change the state of the data model. These events can trigger change detection.
简单地说就是,变更检测当视图或数据发生变化时,保持二者一致的功能。
提问:了解变更检测的核心三问
想要了解变更检查,必须思考下列三个问题。
- 什么行为需要进行变更检测?
- 怎么知道何时进行变更检测?
- 如何执行变更检测?
接下来,让我们一个一个来分析。
第一个问题:什么行为需要进行变更检测?
- DOM事件。
- 网络请求。
- Timers:setTimeout、setInterval。
总结:发生了异步操作的时候。
第二个问题:怎么知道何时进行变更检测?
Angular内部使用了Zone.js给几乎所有的异步API都做了猴补丁(Monkey Patching)用来监听这些异步事件。
简单地说,是NgZone(Angular内部的Zone.js)告诉Angular什么时候需要进行变更检测。
第三个问题------1. 虚晃一招
第三个问题呢?
别着急,先等等!
要理解Angular变更检测,还得让我们先来看接下来这个例子。
例子1:Angular事件绑定
template:
html
<div class="w-screen h-screen flex flex-row justify-center items-center">
<div class="card w-96 h-36 bg-slate-600">
<div class="card-body">
<h2 class="card-title text-white">{{ number }}</h2>
<div class="card-actions justify-end">
<button class="btn btn-warning" (click)="add()">ADD</button>
</div>
</div>
</div>
</div>
模板效果:

logic:
js
number = 1;
add() {
this.number++;
console.log(' Add:', this.number);
}
此时点击ADD按钮后
模板变化为:

控制台输出:
显然的,变化发生了。
这种方式浑然天成,让人觉得理应如此。
是吗?真的浑然天成、理应如此吗?非也。
难道不该理应如此吗?难道还有不是这样的情况吗?
还真有,让我们接下来看。
例子2:DOM事件+runOutsideAngular
接下来我们给这段代码上点强度!
- 首先,采用原生DOM事件来替换我们的点击事件绑定。
template:
html
<div class="w-screen h-screen flex flex-row justify-center items-center">
<div class="card w-96 h-36 bg-slate-600">
<div class="card-body">
<h2 class="card-title text-white">{{ number }}</h2>
<div class="card-actions justify-end">
<button class="btn btn-warning" #addBtn>ADD</button>
</div>
</div>
</div>
</div>
- 其次,我们使用runOutsideAngular方法让点击事件逃离ngZone。
官方文档的说明:
Running functions via #runOutsideAngular allows you to escape Angular's zone and do work that doesn't trigger Angular change-detection or is subject to Angular's error handling.
logic:
js
@ViewChild('addBtn') addBtn!: ElementRef;
number = 1;
ngZone = inject(NgZone); // 依赖注入NgZone
ngAfterViewInit(): void {
this.ngZone.runOutsideAngular(() => {
(this.addBtn.nativeElement as HTMLButtonElement).onclick = () => {
this.number++;
console.log('tick:', this.number);
};
});
}
点击按钮:

再点击按钮:

再点击按钮:

控制台打印:

怎么回事?数据变了,而模板却没有发生改变。
这时,我们发现了原来模板和数据的匹配不是那么的浑然天成,那么的理应如此了。
还记得我们给这个小小的点击事件上的第二个强度吗?
使用runOutsideAngular方法让点击事件逃离ngZone!
点击事件:"哥,ngZone哥,你看我还有机会吗?"
有,还真有,还有两种。
第一种改造方式
js
@ViewChild('addBtn') addBtn!: ElementRef;
number = 1;
ngZone = inject(NgZone); // 依赖注入NgZone
cdr = inject(ChangeDetectorRef); // 依赖注入ChangeDetectorRef
ngAfterViewInit(): void {
this.ngZone.runOutsideAngular(() => {
(this.addBtn.nativeElement as HTMLButtonElement).onclick = () => {
this.number++;
// 不推荐使用,同步方法,会阻塞UI渲染的进程
this.cdr.detectChanges();
console.log('tick:', this.number);
};
});
}
改变的点有两个:
- 依赖注入了ChangeDetectorRef:
cdr = inject(ChangeDetectorRef);
- 使用了cdr的方法:
this.cdr.detectChanges();
刷新页面,连点5下ADD
页面和控制台打印如下:
这很简单,官方说了我们依赖注入的ChangeDetectorRef以及它的方法detectChanges()就是用来做变更检测的:
Checks this view and its children. Use in combination with {@link ChangeDetectorRef#detach detach} to implement local change detection checks.
第二种改造方式
好吧,让我们这种方式似乎太过于简单,以至于我们好像不能直接接触变更检测机制的核心。
让我们来看第二种方法:
js
@ViewChild('addBtn') addBtn!: ElementRef;
number = 1;
ngZone = inject(NgZone); // 依赖注入NgZone
cdr = inject(ChangeDetectorRef); // 依赖注入ChangeDetectorRef
appRef = inject(ApplicationRef); // 依赖注入ApplicationRef
ngAfterViewInit(): void {
this.ngZone.runOutsideAngular(() => {
(this.addBtn.nativeElement as HTMLButtonElement).onclick = () => {
this.number++;
// markForCheck方法和tick方法
this.cdr.markForCheck();
this.appRef.tick();
console.log('tick:', this.number);
};
});
}
改变的点有三个:
- 依赖注入了ApplicationRef:
appRef = inject(ApplicationRef);
- 去掉了cdr的detectChanges方法换用了cdr的另一个方法:
this.cdr.markForCheck();
- 使用了appRef的方法:
this.appRef.tick();
贴心翻译:tick 滴答声
一些程序中常用的最小事件单位,如果对《我的世界》游戏的红石有了解的话,会非常好理解这个概念。
刷新页面,连点6下ADD
页面和控制台打印如下:
这种方式也不难理解:就是我们先标记了需要进行变更检测 this.cdr.markForCheck()
,又说明了何时进行变更检测 this.appRef.tick()
。
这似乎又回到了我们一开始提出的变更检测核心三问的前两问。
我们走了回头路?
不。
为什么?
因为,因果循环!
第三个问题------2. 变更检测的原理就是前两个问题
原理1=问题1:标记哪些行为需要变更检测。
原理2=问题2:说明何时需要进行变更检测。
谁来标记?
ngzone。Angular中的zone.js。
标记谁?
异步事件
上源码!
第三个问题------3. 变更检测的原理之源码解析
源码1:NgZoneChangeDetectionScheduler
贴心翻译:Scheduler 调度程序
更贴心的翻译:schedule 日程
更更贴心的翻译 schedules 时间表
js
export class NgZoneChangeDetectionScheduler {
// ...省略...
// 注意下一行的onMicrotaskEmpty
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
next: () => {
this.zone.run(() => {
// 和之前出现的this.appRef.tick()是一个东西
this.applicationRef.tick();
});
}
});
// ...省略...
}
从贴心翻译++不难看出这个方法说明了变更检测在什么时候触发。
在什么时候?
在onMicrotaskEmpty的时候!
为什么?Microtask是什么?
看我贴心翻译...,翻个头!百度去吧!
是微任务!微任务是什么?
百度!
可进一步了解宏任务、微任务、事件循环相关概念。
源码2:applicationRef.tick()
ts
tick(): void {
// ...省略...
try {
this._runningTick = true;
for (let view of this._views) {
// 变更检测
view.detectChanges();
}
// Angular开发者的巧思
if (typeof ngDevMode === 'undefined' || ngDevMode) {
for (let view of this._views) {
view.checkNoChanges();
}
}
}
// ...省略...
}
此处有Angular开发组的巧思,用来确保一次完成所有数据与视图的变更检测来确保单向数据流。
原注解如下:
In development mode, tick() also performs a second change detection cycle to ensure that no further changes are detected. If additional changes are picked up during this second cycle, bindings in the app have side-effects that cannot be resolved in a single change detection pass. In this case, Angular throws an error, since an Angular application can only have one change detection pass during which all change detection must complete.
源码3:detectChanges()
js
detectChanges(): void {
// Add `RefreshView` flag to ensure this view is refreshed if not already dirty.
// `RefreshView` flag is used intentionally over `Dirty` because it gets cleared before
// executing any of the actual refresh code while the `Dirty` flag doesn't get cleared
// until the end of the refresh. Using `RefreshView` prevents creating a potential difference
// in the state of the LViewFlags during template execution.
this._lView[FLAGS] |= LViewFlags.RefreshView;
detectChangesInternal(this._lView, this.notifyErrorHandler);
}
|=
是按位或赋值运算符我绝对没有COPY错源码!(毕竟我可是首席CV工程师)!
结合注释我们不难理解代码主要在标记哪些视图View需要进行更新,Angular开发组怀旧地用了Dirty来形容(因为AngularJS变更检测的机制就被称为脏数据检测)。
写在压轴
请回看例子1:
template:
html
<div class="w-screen h-screen flex flex-row justify-center items-center">
<div class="card w-96 h-36 bg-slate-600">
<div class="card-body">
<h2 class="card-title text-white">{{ number }}</h2>
<div class="card-actions justify-end">
<button class="btn btn-warning" (click)="add()">ADD</button>
</div>
</div>
</div>
</div>
logic:
js
number = 1;
add() {
this.number++;
console.log(' Add:', this.number);
}
当初我们对它做了两部分改造:
- 采用原生DOM事件来替换我们的点击事件绑定。
- 使用runOutsideAngular方法让点击事件逃离ngZone。
除了让点击事件逃离ngZone,为什么当初在此处还需要采用原生DOM事件来改造我们的点击事件?仅仅逃离ngZone不行吗?
来看!
例子3:Angular事件绑定+runOutsideAngular
logic:
js
add() {
this.ngZone.runOutsideAngular(() => {
this.number++;
console.log('isInAngularZone: ', NgZone.isInAngularZone());
console.log(' Add:', this.number);
});
}
刷新页面,连点7下ADD(模板变化及控制台输入如下):
我们发现虽然runOutsideAngular逃离了(isInAngularZone: false),但number还是递增了。
这可能涉及一个概念------Angular的单项数据流。窃以为,简单地说number++在变更检测前就已经发生了。
这个结论是我在看了你真的知道Angular单向数据流吗 - 掘金一文之后得出的,但对于Angular单项数据流我还没有深入的了解,此处我的观点不易轻信。
写在最后
浅薄之见,欢迎大佬斧正。特别是写在压轴的内容!
如果大佬们有对Angular单项数据流方面知识的了解,请不吝赐教,在此先行拜谢!

