在Angular中,我们的组件是以树形结构呈现的。当一个脏检查周期来临的时候,我们会从组件数树的根(引导组件)进行递归,查看每一个子级节点的变化。
ChangeDetectionStrategy
为了更好的管理脏值检测逻辑,Angular为我们提供了ChangeDetectionStrategy枚举。它包含两种脏值检查策略,如下:
export enum ChangeDetectionStrategey{// 默认策略,每次都会检查Default = 1,// 只在初始化时检查1次,之后只有@Input值发生变化时再进行检测OnPush = 0,}
当脏值检测遍历组件树时,遇到标记为OnPush的节点,会忽略掉当前节点及其子节点。因此我们在组件为纯(Pure)组件时,或者需要进行性能优化时,可以考虑设置组件的脏值检测策略为OnPush。
下面我们思考一下这个组件会发生什么
@Component({template:`{{count}}`,changeDetection:ChangeDetectionStrategey.OnPush})export class OnPushComponent implements OnInit{count = 0;ngOnInit(){setInterval(()=>{this.count += 1;},1000)}}
我们看到这个组件的策略是OnPush,并且没有@Input输入属性。因此我们看到视图显示的值,只有初始化时候的0。那么如果我想要更新这个组件的视图,该怎么办呢?
ChangeDetectorRef
除了使用策略,我们还可以使用ChangeDetectorRef来进行更加精细的脏检查控制,它的签名如下:
export abstract class ChangeDetectorRef {// 标记组件为需要进行脏值检查 会在下一次检查时进行检查 ,对 detach的组件无效abstract markForCheck(): void;// 将组件从树中分离,不再进行自动检测 包括初始化。 通常配合detectChanges使用abstract detach(): void;// 标记组件需要进行脏值检查,并立刻进行一次检查 配合 detach使用。abstract detectChanges(): void;// 检查组件没有发生任何变化 如果存在变化则抛出异常, 文档说这个是配合调试用的abstract checkNoChanges(): void;// 将组件重新附加到树中,并立刻进行一次检查abstract reattach(): void;}
每个组件实例,都会产生一个属于自己的ChangeDetectorRef实例,因此我们可以通过注入的方式拿到当前组件的ChangeDetectorRef
@Component()export class DemoComponent{constructor(private readonly cdr:ChangeDetectorRef)}
现在我们来改造一下刚刚的组件,使我们count的变化,可以反映在View上
@Component({template:`{{count}}`,changeDetection:ChangeDetectionStrategey.OnPush})export class OnPushComponent implements OnInit{constructor(private readonly cdr:ChangeDetectorRef){}count = 0;ngOnInit(){setInterval(()=>{this.count += 1;// 每次变化 都标记当前组件需要被检查this.cdr.markForCheck();// 这个也具有相同的效果// this.cdr.detectChagnes();},1000)}}
在OnPush的策略下,还有一种能触发脏值检查的情况,就是为组件内的元素绑定事件,像这样
@Component({template:`{{count}} <button (click)="undefined">ClickMe!</button>`,changeDetection:ChangeDetectionStrategey.OnPush})export class OnPushComponent implements OnInit{count = 0;ngOnInit(){setInterval(()=>{this.count += 1;},1000)}}
除了使用组件自己的ChangeDetectorRef,当我们在页面中点击按钮的时候,也会让当前组件被检查一次。
这是Angular自身的设计,在dispatchEvent时,会自动markParentViewsForCheck(), 源码截取如下:
export function dispatchEvent(view: ViewData, nodeIndex: number, eventName: string, event: any): boolean {const nodeDef = view.def.nodes[nodeIndex];const startView =nodeDef.flags & NodeFlags.ComponentView ? asElementData(view, nodeIndex).componentView : view;markParentViewsForCheck(startView); // <==== 这里return Services.handleEvent(view, nodeIndex, eventName, event);}
Detach && DetectChanges
当我们期待这个组件完全被我们控制检测方式的时候,我们可以使用 detach 将当前组件分离出来。分离之后的组件,不再受组件树控制,同时检查策略也会失效。只能使用detechChanges手动触发检测,或者从新reattach回去。
我们实现一个完全自己控制的定时刷新的组件
@Component({template:`{{count}}`,changeDetection:ChangeDetectionStrategey.Default})export class DetachComponent implements OnInit{constructor(private readonly cdr:ChangeDetectorRef){// 分离出来cdr.detach();}count = 0;ngOnInit(){// 每1秒+1setInterval(()=>{this.count += 1;},1000)// 每5秒触发一次脏检查setInterval(()=>{this.cdr.detectChanges();},5000)}}
此时我们会看到View的变化为 “啥都没有” … 5s => “5” …. 5s => “10” …
此时我们的组件刷新时机,就完全由我们进行控制了。
View && Content中的检查策略
组件Template,在渲染时被理解为View,即一个视图。我们进行脏检查的组件树,实际上就是由视图构成的,因此当父组件为OnPush的时候且不被检查的时候,子组件也会被忽略。
但是组件Template中还可以包含ng-content。通过外部动态加入部分组件/Dom。这部分内容,被称为Content,这个行为 被称为投影。
投影进来的组件,它的父组件节点并不属于提供 ng-content的组件,而是提供这些组件的组件。因此它在进行脏值检测时,被理解为提供投影内容的组件的子View,而不是被投影组件的子View。因此它是否会被检测到,会受到提供投影的这个组件的影响。
我们来写一段伪代码:
@Component({template:`<child-component><content-component></content-component></child-component>`})export class AppComponent{}
他们的脏检查树结构如下:
这里的 ContentComponent 就被投影到了 ChildComponent 中,然而它的父级,是 AppComponent。
因此即使 ChildComponent 被忽略掉,只要 AppComponent 被检查,则 ContentComponent 一样会被检查。
Angular中的脏检查
手动触发脏检查
除了我们刚刚用到ChangeDetectorRef,还有一种触发从根开始的脏检查,就是 ApplicationRef 的 tick()函数。它与ChangeDetectorRef的使用场景不同,总结如下。
// 从根开始都检查一遍Application -> tick();// 从当前组件开始,检查自身及子组件ChangeDetectorRef -> detectChanges()
自动触发脏检查
我们知道了可以通过这ApplicationRef与ChangeDetectorRef手动的去触发检查,那么Angula在什么情况下会执行脏检查呢?
Angular触发脏检查的时机分为如下几种情况
- 在用户发生被订阅的交互时,如 ClickEvent、之类的
- 在用户发生网络请求时
- 使用定时器时 如setTimeout()、setInterval()等
那么在这些情况下,Angular是如何知道我们进行了这些操作,并自动进行脏检查的呢?这就要提到zone.js与NgZone了
zone.js & NgZone
zone.js为我们提供了异步操作的执行上下文,在这个上下文中进行的异步操作,它都会拦截到,并为我们抛出事件。Angular就是通过订阅上下文中的事件,知道我们执行了一个任务,在这个任务完成的时候,它就会自动帮我们调用ApplicationRef的tick()函数。伪代码像这样:
const application:ApplicationRef;// 基于root zone创建一个新的zone上下文const insideZone = Zone.current.fork({name:"angular",})// 在微任务全部完成时(如 Promiss.then) 触发一次检查insideZone.onMicrotaskEmpty.subscribe(()=>{ applicaiton.tick(); })// 在宏任务全部完成时 (如 setTimeout requestAnimationFrame) 触发一次检查insideZone.onMicrotaskEmpty.subscribe(()=>{ applicaiton.tick(); })// 我们的代码 都跑在这个上下文中insideZone.run(()=>{// Todo});
当我们有一些比较频繁的请求、或者对数据变更比较频繁的操作的时候,我们可以考虑如下两种方式去优化脏值检测
方式1 移除zone.js 完全由自己去管理脏值检测的时机
从polyfill.ts中移除zone.js
// polyfill.ts/**************************************************************************************************** Zone JS is required by default for Angular itself.*/// import 'zone.js/dist/zone'; // Included with Angular CLI.
在Module中排除对NgZone的引用
platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' }).catch(err => console.error(err));
方式2 将这段任务,在别的 zone 上下文中执行
Angular为我们提供了一个包装好的NgZone对象,它内部包装了两个Zone上下文,一个是我们正常执行的 insideZone,还有一个不触发监视的outsideZone。我们可以通过NgZone提供的runOutsideAngular() 函数来切换上下文,代码如下:
@Component(template:`<h1>Progress:{{progress}}</h1><button (click)="runTask()">执行任务</button>`)export class AppComponent{constructor(private readonly _zone:NgZone){}progress = 0;runTask(){// 切换到Outside Zonethis._zone.runOutsideAngular(()=>{// 这行这个计时器任务this.longTask(()=>{// 切换回Inner Zonethis._zone.run(()=>console.log("done"));})})}/** 一个1秒执行完的计时器 每10毫秒推进1点进度条*/private longTask(done:()=>void){this.progress = 0;// 开启一个定时器 即MacroTask 1秒时间将progress从0跑到100const id= setInterval(()=>{// 自增进度条this.progress++;if(this.progress>=100){clearInterval(id);done();}},10)}}
在这段代码里,我们在页面中会看到 进度条的值 一直都是0 只有在1秒后完成之后 才会变到 100
这是因为 outsideZone不会监视任务队列,也不会调用Application对象的tick()
当我们最后完成的时候,需要将上下文环境切换回 insideZone 。 不然是不会触发变更检测的,当然 我们也可以手动的去调用一次tick()实现相同的目的。
此时如果我们在DoCheck钩子中console.log的话,我们会发现DoCheck钩子只被调用了3次,他们分别是如下时机
- AppComponent初始化
- 点击执行任务按钮
- 输出done 之前
如果我们直接运行longTask的话,我们则会看到我们的页面progress的值会一直变化,而DoCheck钩子 会被调用103次。
总结
以上就是如何通过合理利用脏检查策略,NgZone,减少脏检查次数的思路。虽然我们实际情况下触发脏检查的频率并不是很频繁,但是当它遍历整颗组件树的时候,这个开销就会变得很可观了。因此养成良好的思考习惯,有助于我们大型的项目为用户提供更好的体验。
最后 在这里给一个 完整示例的传送门
