Angular 的变化检测

文章翻译自 A Comprehensive Guide to Angular onPush Change Detection Strategy(有翻译不准确的地方请见谅QAQ)

1. 默认变更检测策略

默认情况下,Angular使用 ChangeDetectionStrategy.Default 来检查变化。

默认策略下不会对应用程序进行任何假设,因此,由于各种用户事件,计时器,XHR,promise 等导致我们的应用程序每次发生更改时,都会在所有组件上运行更改检测。

这意味着从单击事件到从ajax调用接收到的数据之类的任何东西都会触发更改检测。

我们可以很容易的在一个组件模板中写一个 getter 函数做一个例子:

@Component({
  template: `
    <h1>Hello {{name}}!</h1>
    {{runChangeDetection}}
  `
})
export class HelloComponent {
  @Input() name: string;

  get runChangeDetection() {
    console.log('Checking the view');
    return true;
  }
}
@Component({
  template: `
    <hello></hello>
    <button (click)="onClick()">Trigger change detection</button>
  `
})
export class AppComponent  {
  onClick() {}
}

上面的代码运行后,每次单击按钮时,Angular 将运行更改检测周期,我们可以在控制台中看到两条 console(如果在生产环境下会产生一条)。

此技术被称为脏数据检查。为了知道是否应更新视图,Angular 需要访问值,将其与值进行比较,然后决定是否应更新视图。

现在,想象一个具有数千个组件的大型应用程序;如果在变更检测周期运行时让Angular检查它们中的每一个,我们可能会遇到性能问题。

尽管Angular的速度非常快,但是随着我们的应用程序的发展,Angular将不得不更加努力地跟踪所有更改。

如果怎样做才能帮助Angular更好地检查组件呢?

2. OnPush变更检测策略

我们可以将组件的 ChangeDetectionStrategy 设置为 ChangeDetectionStrategy.OnPush

这告诉Angular该组件数据仅依赖于其通过 @inputs()(pure) 传入数据的情况下才需要检查:

1. input 更改

通过设置 onPush 更改检测策略,我们与 Angular 签订了一项合约,该合约使我们不得不使用不可变的对象(或稍后将要介绍的可观察对象)。

在更改检测的上下文中使用不可变对象的好处是Angular可以执行简单的引用检查,以便知道是否应检查视图。这种检查比深度比较检查所消耗的性能要小很多。

让我们改变一个对象并查看结果。

@Component({
  selector: 'tooltip',
  template: `
    <h1>{{config.position}}</h1>
    {{runChangeDetection}}
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TooltipComponent  {

  @Input() config;

  get runChangeDetection() {
    console.log('Checking the view');
    return true;
  }
}
@Component({
  template: `
    <tooltip [config]="config"></tooltip>
  `
})
export class AppComponent  {
  config = {
    position: 'top'
  };

  onClick() {
    this.config.position = 'bottom';
  }
}

当我们单击按钮时,我们将看不到任何 console 这是因为 Angular 通过引用将旧值与新值进行了比较,例如:

/** Returns false in our case */
if( oldValue !== newValue ) { 
  runChangeDetection();
}

需要注意的是,数字,布尔值,字符串,null和undefined是原始类型。所有原始类型均按值传递。对象,数组和函数也按值传递,但该值是引用的 副本

因此,为了触发组件中的更改检测,我们需要更改对象引用。如下:

@Component({
  template: `
    <tooltip [config]="config"></tooltip>
  `
})
export class AppComponent  {
  config = {
    position: 'top'
  };

  onClick() {
    this.config = {
      position: 'bottom'
    }
  }
}

代码更改后,我们将看到该视图已被检查,并且新值将按预期显示。

2. 一个事件源自该组件或者它其中一个子组件。

组件可能具有内部状态,当组件或它其中一个子组件触发事件时,该内部状态会更新。

例如:

@Component({
  template: `
    <button (click)="add()">Add</button>
    {{count}}
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  count = 0;

  add() {
    this.count++;
  }

}

当我们单击按钮时,Angular运行更改检测周期,并且视图将按预计的更新。

正如我们一开始所学到的那样,您可能会想,它应该与每个触发更改检测的异步API一起使用,但事实出乎意料。

事实证明,以上规则仅适用于DOM事件,下面的API将不起作用。如:

@Component({
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  count = 0;

  constructor() {
    setTimeout(() => this.count = 5, 0);

    setInterval(() => this.count = 5, 100);

    Promise.resolve().then(() => this.count = 5); 
    
    this.http.get('https://count.com').subscribe(res => {
      this.count = res;
    });
  }

  add() {
    this.count++;
  }
}

请注意,您仍在更新属性,但是会在下一个更改检测周期中,例如,当我们单击按钮时,该值将为6(5 +1)

3. 我们明确地运行变更检测

Angular 为我们提供了三种在需要时自行触发变更检测的方法。

第一个是 detectChanges() ,它告诉 Angular 在组件及其子组件上运行更改检测。如:

@Component({
  selector: 'counter',
  template: `{{count}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent { 
  count = 0;

  constructor(private cdr: ChangeDetectorRef) {

    setTimeout(() => {
      this.count = 5;
      this.cdr.detectChanges();
    }, 1000);
  }
}

第二个是 ApplicationRef.tick() ,它告诉 Angular 对 整个 应用程序运行更改检测。如:

tick() {
 
  try {
    this._views.forEach((view) => view.detectChanges());
    ...
  } catch (e) {
    ...
  }
}

第三个是 markForCheck() ,它不会触发更改检测。而是将所有 onPush 父组件标记为要检查一次,作为当前或下一个更改检测周期的一部分。如:

markForCheck(): void { 
  markParentViewsForCheck(this._view); 
}

export function markParentViewsForCheck(view: ViewData) {
  let currView: ViewData|null = view;
  while (currView) {
    if (currView.def.flags & ViewFlags.OnPush) {
      currView.state |= ViewState.ChecksEnabled;
    }
    currView = currView.viewContainerParent || currView.parent;
  }
}

这里要注意的另一件事是,手动运行变更检测不被视为 “黑箱操作” ,这是设计使然,完全是正确的行为(当然,在合理的情况下)。

4. Angular 异步管道

异步管道订阅一个 observablepromise,并返回它发出的最新值。

让我们来看一个在 onPush 组件中使用 input()observable 的简单例子:

@Component({
  template: `
    <button (click)="add()">Add</button>
    <app-list [items$]="items$"></app-list>
  `
})
export class AppComponent {
  items = [];
  items$ = new BehaviorSubject(this.items);

  add() {
    this.items.push({ title: Math.random() })
    this.items$.next(this.items);
  }
}
@Component({
  template: `
     <div *ngFor="let item of items">{{item.title}}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
  @Input() items: Observable<Item>;
  _items: Item[];
  
  ngOnInit() {
    this.items.subscribe(items => {
      this._items = items;
    });
  }
}

当我们单击按钮时,我们将不会看到视图已更新。这是因为上述条件均未发生,因此Angular不会在当前更改检测周期并检查组件。

现在,我们将其更改为使用 async

@Component({
  template: `
    <div *ngFor="let item of items | async">{{item.title}}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
  @Input() items;
}

现在我们可以看到单击该按钮时视图已更新。这样做的原因是,当发出新值时,async 会将要检查的组件标记为更改。源代码

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    this._ref.markForCheck();
  }
}

Angular 要求我们使用 markForCheck() ,这就是即使引用未更改也能更新视图的原因.

如果组件仅取决于其输入属性(@Input),并且可以被观察到(Observable),则只有当其输入属性之一发出事件时,此组件才能更改。

5. onPush 和视图查询

让我们看下面的例子:

@Component({
  selector: 'app-tabs',
  template: `<ng-content></ng-content>`
})
export class TabsComponent implements OnInit {
  @ContentChild(TabComponent) tab: TabComponent;

  ngAfterContentInit() {
    setTimeout(() => {
      this.tab.content = 'Content'; 
    }, 3000);
  }
}
@Component({
  selector: 'app-tab',
  template: `{{content}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabComponent {
  @Input() content;
}
<app-tabs>
  <app-tab></app-tab>
</app-tabs>

您可能希望三秒钟后 Angular 将使用新值来更新选项卡组件视图。

毕竟,我们看到如果更新 onPush 组件中的输入引用,这将触发更改检测,不是吗?

不幸的是,在这种情况下,它不能那样工作。Angular 无法知道我们正在更新选项卡组件中的属性。
在模板中定义 input() 是让 Angular 知道应在更改检测周期中检查此属性的唯一方法。

例如:

<app-tabs>
  <app-tab [content]="content"></app-tab>
</app-tabs>

因为我们在模板中明确定义了 input() ,所以 Angular 创建了一个称为 updateRenderer 的函数,该函数在每个更改检测周期内都跟踪值。

AppComponent.ngfactory.ts.png

在这些情况下,简单的解决方案是使用 setter 方法并调用 markForCheck()

@Component({
  selector: 'app-tab',
  template: `
    {{_content}}
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabComponent {
  _content;

  @Input() set content(value) {
    this._content = value;
    this.cdr.markForCheck();
  }

  constructor(private cdr: ChangeDetectorRef) {}

}

6. onPush++

了解 onPush 的功能后,我们可以利用它来创建性能更高的应用程序。onPush 组件越多,Angular 需要执行的检查就越少。让我们看一个真实的例子:

假设我们有一个待办事项组件,将待办事项作为 input()

@Component({
  selector: 'app-todos',
  template: `
     <div *ngFor="let todo of todos">
       {{todo.title}} - {{runChangeDetection}}
     </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent {
  @Input() todos;

  get runChangeDetection() {
    console.log('TodosComponent - Checking the view');
    return true;
  }

}
@Component({
  template: `
    <button (click)="add()">Add</button>
    <app-todos [todos]="todos"></app-todos>
  `
})
export class AppComponent {
  todos = [{ title: 'One' }, { title: 'Two' }];

  add() {
    this.todos = [...this.todos, { title: 'Three' }];
  }
}

上述方法的缺点是,当我们单击添加按钮 Angular 时,即使没有任何更改,也需要检查每个待办事项,因此在第一次单击中,控制台中将显示三个 console 。

在上面的示例中,只有一个表达式需要检查,但是想象一下一个具有多个绑定(ngIf,ngClass,表达式等)的真实组件。这可能会变得非常浪费性能。

性能更高的方法是创建待办事项组件并将其更改检测策略定义为 onPush。例如:

@Component({
  selector: 'app-todos',
  template: `
    <app-todo [todo]="todo" *ngFor="let todo of todos"></app-todo>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodosComponent {
  @Input() todos;
}

@Component({
  selector: 'app-todo',
  template: `{{todo.title}} {{runChangeDetection}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent {
  @Input() todo;

  get runChangeDetection() {
    console.log('TodoComponent - Checking the view');
    return true;
  }

}

现在,当我们单击添加按钮时,将在控制台中看到一个 console ,因为其他待办事项组件的输入均未更改,因此未检查其视图。

另外,通过创建专用组件,我们使我们的代码更具可读性和可重用性。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容