Angular 的變化檢測

文章翻譯自 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 異步管道

異步管道訂閱一個 observablepromise,并返回它發(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)都跟蹤值。

AppComponent.ngfactory.ts.png

在這些情況下,簡單的解決方案是使用 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)建專用組件,我們使我們的代碼更具可讀性和可重用性。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容