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 ,因为其他待办事项组件的输入均未更改,因此未检查其视图。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,546评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,224评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,911评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,737评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,753评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,598评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,338评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,249评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,696评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,888评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,013评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,731评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,348评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,929评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,048评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,203评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,960评论 2 355

推荐阅读更多精彩内容