文章翻译自 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 异步管道
异步管道订阅一个 observable
或 promise
,并返回它发出的最新值。
让我们来看一个在 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 的函数,该函数在每个更改检测周期内都跟踪值。
在这些情况下,简单的解决方案是使用 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 ,因为其他待办事项组件的输入均未更改,因此未检查其视图。
另外,通过创建专用组件,我们使我们的代码更具可读性和可重用性。