文章翻譯自 A Comprehensive Guide to Angular onPush Change Detection Strategy(有翻譯不準確的地方請見諒QAQ)
1. 默認變更檢測策略
默認情況下,Angular使用 ChangeDetectionStrategy.Default 來檢查變化。
默認策略下不會對應用程序進行任何假設,因此,由于各種用戶事件,計時器,XHR,promise 等導致我們的應用程序每次發(fā)生更改時,都會在所有組件上運行更改檢測。
這意味著從單擊事件到從ajax調(diào)用接收到的數(shù)據(jù)之類的任何東西都會觸發(fā)更改檢測。
我們可以很容易的在一個組件模板中寫一個 getter 函數(shù)做一個例子:
@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(如果在生產(chǎn)環(huán)境下會產(chǎn)生一條)。
此技術(shù)被稱為臟數(shù)據(jù)檢查。為了知道是否應更新視圖,Angular 需要訪問新值,將其與舊值進行比較,然后決定是否應更新視圖。
現(xiàn)在,想象一個具有數(shù)千個組件的大型應用程序;如果在變更檢測周期運行時讓Angular檢查它們中的每一個,我們可能會遇到性能問題。
盡管Angular的速度非常快,但是隨著我們的應用程序的發(fā)展,Angular將不得不更加努力地跟蹤所有更改。
如果怎樣做才能幫助Angular更好地檢查組件呢?
2. OnPush變更檢測策略
我們可以將組件的 ChangeDetectionStrategy 設置為 ChangeDetectionStrategy.OnPush
這告訴Angular該組件數(shù)據(jù)僅依賴于其通過 @inputs()(pure) 傳入數(shù)據(jù)的情況下才需要檢查:
1. input 更改
通過設置 onPush 更改檢測策略,我們與 Angular 簽訂了一項合約,該合約使我們不得不使用不可變的對象(或稍后將要介紹的可觀察對象)。
在更改檢測的上下文中使用不可變對象的好處是Angular可以執(zhí)行簡單的引用檢查,以便知道是否應檢查視圖。這種檢查比深度比較檢查所消耗的性能要小很多。
讓我們改變一個對象并查看結(jié)果。
@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();
}
需要注意的是,數(shù)字,布爾值,字符串,null和undefined是原始類型。所有原始類型均按值傳遞。對象,數(shù)組和函數(shù)也按值傳遞,但該值是引用的 副本。
因此,為了觸發(fā)組件中的更改檢測,我們需要更改對象引用。如下:
@Component({
template: `
<tooltip [config]="config"></tooltip>
`
})
export class AppComponent {
config = {
position: 'top'
};
onClick() {
this.config = {
position: 'bottom'
}
}
}
代碼更改后,我們將看到該視圖已被檢查,并且新值將按預期顯示。
2. 一個事件源自該組件或者它其中一個子組件。
組件可能具有內(nèi)部狀態(tài),當組件或它其中一個子組件觸發(fā)事件時,該內(nèi)部狀態(tài)會更新。
例如:
@Component({
template: `
<button (click)="add()">Add</button>
{{count}}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;
add() {
this.count++;
}
}
當我們單擊按鈕時,Angular運行更改檢測周期,并且視圖將按預計的更新。
正如我們一開始所學到的那樣,您可能會想,它應該與每個觸發(fā)更改檢測的異步API一起使用,但事實出乎意料。
事實證明,以上規(guī)則僅適用于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 為我們提供了三種在需要時自行觸發(fā)變更檢測的方法。
第一個是 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() ,它不會觸發(fā)更改檢測。而是將所有 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,并返回它發(fā)出的最新值。
讓我們來看一個在 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;
});
}
}
當我們單擊按鈕時,我們將不會看到視圖已更新。這是因為上述條件均未發(fā)生,因此Angular不會在當前更改檢測周期并檢查組件。
現(xiàn)在,我們將其更改為使用 async 。
@Component({
template: `
<div *ngFor="let item of items | async">{{item.title}}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
@Input() items;
}
現(xiàn)在我們可以看到單擊該按鈕時視圖已更新。這樣做的原因是,當發(fā)出新值時,async 會將要檢查的組件標記為更改。源代碼 :
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref.markForCheck();
}
}
Angular 要求我們使用 markForCheck() ,這就是即使引用未更改也能更新視圖的原因.
如果組件僅取決于其輸入屬性(@Input),并且可以被觀察到(Observable),則只有當其輸入屬性之一發(fā)出事件時,此組件才能更改。
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 組件中的輸入引用,這將觸發(fā)更改檢測,不是嗎?
不幸的是,在這種情況下,它不能那樣工作。Angular 無法知道我們正在更新選項卡組件中的屬性。
在模板中定義 input() 是讓 Angular 知道應在更改檢測周期中檢查此屬性的唯一方法。
例如:
<app-tabs>
<app-tab [content]="content"></app-tab>
</app-tabs>
因為我們在模板中明確定義了 input() ,所以 Angular 創(chuàng)建了一個稱為 updateRenderer 的函數(shù),該函數(shù)在每個更改檢測周期內(nèi)都跟蹤值。

在這些情況下,簡單的解決方案是使用 setter 方法并調(diào)用 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 的功能后,我們可以利用它來創(chuàng)建性能更高的應用程序。onPush 組件越多,Angular 需要執(zhí)行的檢查就越少。讓我們看一個真實的例子:
假設我們有一個待辦事項組件,將待辦事項作為 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,表達式等)的真實組件。這可能會變得非常浪費性能。
性能更高的方法是創(chuàng)建待辦事項組件并將其更改檢測策略定義為 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;
}
}
現(xiàn)在,當我們單擊添加按鈕時,將在控制臺中看到一個 console ,因為其他待辦事項組件的輸入均未更改,因此未檢查其視圖。
另外,通過創(chuàng)建專用組件,我們使我們的代碼更具可讀性和可重用性。