在Angular中,我们的组件是以树形结构呈现的。当一个脏检查周期来临的时候,我们会从组件数树的根(引导组件)进行递归,查看每一个子级节点的变化。image.png

ChangeDetectionStrategy

为了更好的管理脏值检测逻辑,Angular为我们提供了ChangeDetectionStrategy枚举。它包含两种脏值检查策略,如下:

  1. export enum ChangeDetectionStrategey{
  2. // 默认策略,每次都会检查
  3. Default = 1,
  4. // 只在初始化时检查1次,之后只有@Input值发生变化时再进行检测
  5. OnPush = 0,
  6. }

当脏值检测遍历组件树时,遇到标记为OnPush的节点,会忽略掉当前节点及其子节点。因此我们在组件为纯(Pure)组件时,或者需要进行性能优化时,可以考虑设置组件的脏值检测策略为OnPush。
下面我们思考一下这个组件会发生什么

  1. @Component({
  2. template:`{{count}}`,
  3. changeDetection:ChangeDetectionStrategey.OnPush
  4. })
  5. export class OnPushComponent implements OnInit{
  6. count = 0;
  7. ngOnInit(){
  8. setInterval(()=>{
  9. this.count += 1;
  10. },1000)
  11. }
  12. }

我们看到这个组件的策略是OnPush,并且没有@Input输入属性。因此我们看到视图显示的值,只有初始化时候的0。那么如果我想要更新这个组件的视图,该怎么办呢?

ChangeDetectorRef

除了使用策略,我们还可以使用ChangeDetectorRef来进行更加精细的脏检查控制,它的签名如下:

  1. export abstract class ChangeDetectorRef {
  2. // 标记组件为需要进行脏值检查 会在下一次检查时进行检查 ,对 detach的组件无效
  3. abstract markForCheck(): void;
  4. // 将组件从树中分离,不再进行自动检测 包括初始化。 通常配合detectChanges使用
  5. abstract detach(): void;
  6. // 标记组件需要进行脏值检查,并立刻进行一次检查 配合 detach使用。
  7. abstract detectChanges(): void;
  8. // 检查组件没有发生任何变化 如果存在变化则抛出异常, 文档说这个是配合调试用的
  9. abstract checkNoChanges(): void;
  10. // 将组件重新附加到树中,并立刻进行一次检查
  11. abstract reattach(): void;
  12. }

每个组件实例,都会产生一个属于自己的ChangeDetectorRef实例,因此我们可以通过注入的方式拿到当前组件的ChangeDetectorRef

  1. @Component()
  2. export class DemoComponent{
  3. constructor(private readonly cdr:ChangeDetectorRef)
  4. }

现在我们来改造一下刚刚的组件,使我们count的变化,可以反映在View上

  1. @Component({
  2. template:`{{count}}`,
  3. changeDetection:ChangeDetectionStrategey.OnPush
  4. })
  5. export class OnPushComponent implements OnInit{
  6. constructor(private readonly cdr:ChangeDetectorRef){}
  7. count = 0;
  8. ngOnInit(){
  9. setInterval(()=>{
  10. this.count += 1;
  11. // 每次变化 都标记当前组件需要被检查
  12. this.cdr.markForCheck();
  13. // 这个也具有相同的效果
  14. // this.cdr.detectChagnes();
  15. },1000)
  16. }
  17. }

在OnPush的策略下,还有一种能触发脏值检查的情况,就是为组件内的元素绑定事件,像这样

  1. @Component({
  2. template:`{{count}} <button (click)="undefined">ClickMe!</button>`,
  3. changeDetection:ChangeDetectionStrategey.OnPush
  4. })
  5. export class OnPushComponent implements OnInit{
  6. count = 0;
  7. ngOnInit(){
  8. setInterval(()=>{
  9. this.count += 1;
  10. },1000)
  11. }
  12. }

除了使用组件自己的ChangeDetectorRef,当我们在页面中点击按钮的时候,也会让当前组件被检查一次。
这是Angular自身的设计,在dispatchEvent时,会自动markParentViewsForCheck(), 源码截取如下:

  1. export function dispatchEvent(
  2. view: ViewData, nodeIndex: number, eventName: string, event: any): boolean {
  3. const nodeDef = view.def.nodes[nodeIndex];
  4. const startView =
  5. nodeDef.flags & NodeFlags.ComponentView ? asElementData(view, nodeIndex).componentView : view;
  6. markParentViewsForCheck(startView); // <==== 这里
  7. return Services.handleEvent(view, nodeIndex, eventName, event);
  8. }

Detach && DetectChanges

当我们期待这个组件完全被我们控制检测方式的时候,我们可以使用 detach 将当前组件分离出来。分离之后的组件,不再受组件树控制,同时检查策略也会失效。只能使用detechChanges手动触发检测,或者从新reattach回去。
我们实现一个完全自己控制的定时刷新的组件

  1. @Component({
  2. template:`{{count}}`,
  3. changeDetection:ChangeDetectionStrategey.Default
  4. })
  5. export class DetachComponent implements OnInit{
  6. constructor(private readonly cdr:ChangeDetectorRef){
  7. // 分离出来
  8. cdr.detach();
  9. }
  10. count = 0;
  11. ngOnInit(){
  12. // 每1秒+1
  13. setInterval(()=>{
  14. this.count += 1;
  15. },1000)
  16. // 每5秒触发一次脏检查
  17. setInterval(()=>{
  18. this.cdr.detectChanges();
  19. },5000)
  20. }
  21. }

此时我们会看到View的变化为 “啥都没有” … 5s => “5” …. 5s => “10” …

此时我们的组件刷新时机,就完全由我们进行控制了。

View && Content中的检查策略

组件Template,在渲染时被理解为View,即一个视图。我们进行脏检查的组件树,实际上就是由视图构成的,因此当父组件为OnPush的时候且不被检查的时候,子组件也会被忽略。
但是组件Template中还可以包含ng-content。通过外部动态加入部分组件/Dom。这部分内容,被称为Content,这个行为 被称为投影。
投影进来的组件,它的父组件节点并不属于提供 ng-content的组件,而是提供这些组件的组件。因此它在进行脏值检测时,被理解为提供投影内容的组件的子View,而不是被投影组件的子View。因此它是否会被检测到,会受到提供投影的这个组件的影响。
我们来写一段伪代码:

  1. @Component({
  2. template:`
  3. <child-component>
  4. <content-component></content-component>
  5. </child-component>
  6. `
  7. })
  8. export class AppComponent{}

他们的脏检查树结构如下:
Content-Tree.jpg
这里的 ContentComponent 就被投影到了 ChildComponent 中,然而它的父级,是 AppComponent。

因此即使 ChildComponent 被忽略掉,只要 AppComponent 被检查,则 ContentComponent 一样会被检查。

Angular中的脏检查

手动触发脏检查

除了我们刚刚用到ChangeDetectorRef,还有一种触发从根开始的脏检查,就是 ApplicationRef 的 tick()函数。它与ChangeDetectorRef的使用场景不同,总结如下。

  1. // 从根开始都检查一遍
  2. Application -> tick();
  3. // 从当前组件开始,检查自身及子组件
  4. ChangeDetectorRef -> detectChanges()

自动触发脏检查

我们知道了可以通过这ApplicationRef与ChangeDetectorRef手动的去触发检查,那么Angula在什么情况下会执行脏检查呢?

Angular触发脏检查的时机分为如下几种情况

  1. 在用户发生被订阅的交互时,如 ClickEvent、之类的
  2. 在用户发生网络请求时
  3. 使用定时器时 如setTimeout()、setInterval()等

那么在这些情况下,Angular是如何知道我们进行了这些操作,并自动进行脏检查的呢?这就要提到zone.js与NgZone了

zone.js & NgZone

zone.js为我们提供了异步操作的执行上下文,在这个上下文中进行的异步操作,它都会拦截到,并为我们抛出事件。Angular就是通过订阅上下文中的事件,知道我们执行了一个任务,在这个任务完成的时候,它就会自动帮我们调用ApplicationRef的tick()函数。伪代码像这样:

  1. const application:ApplicationRef;
  2. // 基于root zone创建一个新的zone上下文
  3. const insideZone = Zone.current.fork(
  4. {
  5. name:"angular",
  6. }
  7. )
  8. // 在微任务全部完成时(如 Promiss.then) 触发一次检查
  9. insideZone.onMicrotaskEmpty.subscribe(()=>{ applicaiton.tick(); })
  10. // 在宏任务全部完成时 (如 setTimeout requestAnimationFrame) 触发一次检查
  11. insideZone.onMicrotaskEmpty.subscribe(()=>{ applicaiton.tick(); })
  12. // 我们的代码 都跑在这个上下文中
  13. insideZone.run(()=>{
  14. // Todo
  15. });

当我们有一些比较频繁的请求、或者对数据变更比较频繁的操作的时候,我们可以考虑如下两种方式去优化脏值检测

方式1 移除zone.js 完全由自己去管理脏值检测的时机

  1. 从polyfill.ts中移除zone.js

    1. // polyfill.ts
    2. /***************************************************************************************************
    3. * Zone JS is required by default for Angular itself.
    4. */
    5. // import 'zone.js/dist/zone'; // Included with Angular CLI.
  2. 在Module中排除对NgZone的引用

    1. platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' })
    2. .catch(err => console.error(err));

    方式2 将这段任务,在别的 zone 上下文中执行

    Angular为我们提供了一个包装好的NgZone对象,它内部包装了两个Zone上下文,一个是我们正常执行的 insideZone,还有一个不触发监视的outsideZone。我们可以通过NgZone提供的runOutsideAngular() 函数来切换上下文,代码如下:

    1. @Component(
    2. template:`
    3. <h1>Progress:{{progress}}</h1>
    4. <button (click)="runTask()">执行任务</button>
    5. `
    6. )
    7. export class AppComponent{
    8. constructor(private readonly _zone:NgZone){}
    9. progress = 0;
    10. runTask(){
    11. // 切换到Outside Zone
    12. this._zone.runOutsideAngular(()=>{
    13. // 这行这个计时器任务
    14. this.longTask(()=>{
    15. // 切换回Inner Zone
    16. this._zone.run(()=>console.log("done"));
    17. })
    18. })
    19. }
    20. /*
    21. * 一个1秒执行完的计时器 每10毫秒推进1点进度条
    22. */
    23. private longTask(done:()=>void){
    24. this.progress = 0;
    25. // 开启一个定时器 即MacroTask 1秒时间将progress从0跑到100
    26. const id= setInterval(()=>{
    27. // 自增进度条
    28. this.progress++;
    29. if(this.progress>=100){
    30. clearInterval(id);
    31. done();
    32. }
    33. },10)
    34. }
    35. }

    在这段代码里,我们在页面中会看到 进度条的值 一直都是0 只有在1秒后完成之后 才会变到 100
    这是因为 outsideZone不会监视任务队列,也不会调用Application对象的tick()
    当我们最后完成的时候,需要将上下文环境切换回 insideZone 。 不然是不会触发变更检测的,当然 我们也可以手动的去调用一次tick()实现相同的目的。
    此时如果我们在DoCheck钩子中console.log的话,我们会发现DoCheck钩子只被调用了3次,他们分别是如下时机

  • AppComponent初始化
  • 点击执行任务按钮
  • 输出done 之前

如果我们直接运行longTask的话,我们则会看到我们的页面progress的值会一直变化,而DoCheck钩子 会被调用103次。

总结

以上就是如何通过合理利用脏检查策略,NgZone,减少脏检查次数的思路。虽然我们实际情况下触发脏检查的频率并不是很频繁,但是当它遍历整颗组件树的时候,这个开销就会变得很可观了。因此养成良好的思考习惯,有助于我们大型的项目为用户提供更好的体验。
最后 在这里给一个 完整示例的传送门