Шаблоны Angular потрясающие и чрезвычайно мощные.

Благодаря структурным директивам и привязкам свойств мы можем создавать даже самые сложные представления с очень четкой семантикой:

<ng-container *ngIf="isLoggedIn">
  <h1>Welcome {{ fullName }}!</h1>
</ng-container>

Поскольку выражения настолько мощны, они могут легко усложняться, когда наши взгляды становятся более сложными.

Прежде чем мы это узнаем, мы получаем вызовы функций в наших шаблонах Angular:

<ng-container *ngIf="isLoggedIn">
  <h1>Welcome {{ fullName }}!</h1>
  <a href="files" *ngIf="hasAccessTo('files')">Files</a>
  <a href="photos" *ngIf="hasAccessTo('photos')">Photos</a>
</ng-container>

Хотя вызовы функций в шаблонах Angular очень удобны и технически допустимы, они могут вызвать серьезные проблемы с производительностью.

В этой статье объясняется, почему и как можно обойти проблемы с производительностью.

Эта проблема

Для демонстрации предположим, что у нас есть PersonComponent, который использует вызов функции fullName() в своем шаблоне для отображения полного имени человека, которое передается через свойство person:

@Component({
  template: `
    <p>Welcome {{ fullName() }}!</p>
    <button (click)="onClick()">Trigger change detection</button>
`
})
export class PersonComponent {
  @Input() person: { firstName: string, lastName: string };
  constructor() { }
  fullName() {
    return this.person.firstName + ' ' + this.person.lastName
  }
  onClick() {
    console.log('Button was clicked';
  }
}

Здесь функция fullName() выполняется каждый раз, когда запускается обнаружение угловых изменений. И это может быть много раз!

Каждый раз, когда нажимается кнопка, выполняется fullName() функция.

Хотя нажатие кнопки может показаться безобидным, предположим, что шесть месяцев спустя требования к нашему компоненту PersonComponent изменились, и теперь нам нужно иметь дело с большим количеством событий, запускающих обнаружение изменений:

@Component({
  template: `
    <p>Welcome {{ fullName() }}!</p>
    <div (mousemove)="onMouseMove()">Drop a picture here</div>
`
})
export class PersonComponent {
@Input() person: { firstName: string, lastName: string };
  constructor() { }
  fullName() {
    return this.person.firstName + ' ' + this.person.lastName
  }
  onMouseMove() {
    console.log('Mouse was moved');
  }
}

Внезапно функция fullName() выполняется сотни раз при наведении указателя мыши на div.

И поскольку код fullName() был написан уже шесть месяцев назад, мы можем не осознавать влияние нашего нового кода.

Кроме того, обнаружение изменений может быть запущено вне PersonComponent:

<person [person]="person"></person>
<button (click)="onClick()">
  Trigger change detection outside of PersonComponent
</button>

Здесь функция fullName() внутри PersonComponent выполняется каждый раз, когда нажимается кнопка за пределами PersonComponent.

Так почему это происходит? И что мы можем с этим поделать?

Почему функции в выражениях вызываются так много раз?

Цель обнаружения изменений Angular - выяснить, какие части пользовательского интерфейса необходимо повторно отображать при возникновении изменений.

Чтобы определить, нужно ли перерисовывать <p>Welcome {{ fullName() }}!</p>, Angular необходимо выполнить выражение fullName(), чтобы проверить, изменилось ли его возвращаемое значение.

Поскольку Angular не может предсказать, изменилось ли возвращаемое значение fullName(), ему необходимо выполнять функцию каждый раз, когда запускается обнаружение изменений.

Таким образом, если обнаружение изменений выполняется 300 раз, функция вызывается 300 раз, даже если ее возвращаемое значение никогда не изменяется.

В зависимости от логики внутри функции, запуск функции сотни раз может стать серьезной проблемой для производительности.

Когда используются геттеры, это становится болезненно скрытым в шаблонах:

@Component({
  template: `
    <p>Welcome {{ fullName }}!</p>
`
})
export class PersonComponent {
  @Input() person: { firstName: string, lastName: string };
  constructor() { }
  get fullName() {
    return this.person.firstName + ' ' + this.person.lastName
  }
}

Здесь шаблон не показывает визуальной индикации выполнения какого-либо вызова функции, но геттер get fullName() вызывается каждый раз, когда запускается обнаружение изменений.

А как насчет ChangeDetectionStrategy.OnPush?

Когда мы включаем ChangeDetectionStrategy.OnPush для PersonComponent, мы говорим обнаружению изменений Angular игнорировать изменения вне PersonComponent, которые не влияют на его входные свойства.

В результате обнаружение изменений внутри PersonComponent запускается только при изменении одного из его входных свойств, в данном случае его свойства person.

В следующем примере:

<person [person]="person"></person>
<button (click)="onClick()">
  Trigger change detection outside of PersonComponent
</button>

функция fullName() внутри PersonComponent больше не выполняется при нажатии кнопки за пределами PersonComponent.

Блестяще! Разве это не решает всех наших потенциальных проблем с производительностью?

Нет, к сожалению нет.

Почему нет?

Поскольку функция fullName() по-прежнему выполняется каждый раз, когда обнаружение изменений инициируется внутри самого PersonComponent:

@Component({
  template: `
    <p>Welcome {{ fullName() }}!</p>
    <div (mousemove)="onMouseMove()">Drop a picture here</div>
`
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonComponent {
  @Input() person: { firstName: string, lastName: string };
  constructor() { }
  fullName() {
    return this.person.firstName + ' ' + this.person.lastName
  }
  onMouseMove() {
    console.log('Mouse was moved');
  }
}

Здесь функция fullName() по-прежнему выполняется каждый раз, когда указатель мыши перемещается по div, даже если у нас включено ChangeDetectionStrategy.OnPush.

Таким образом, хотя ChangeDetectionStrategy.OnPush может помочь нам игнорировать циклы обнаружения изменений извне, функции в наших выражениях по-прежнему выполняются каждый раз, когда обнаружение изменений запускается из самого компонента.

Итак, как мы можем предотвратить ненужные вызовы функций?

Решение: как избежать ненужных вызовов функций

Стратегия 1 - Чистые трубы

Первая стратегия уменьшения количества вызовов функций - использовать чистые каналы.

Сообщая Angular, что канал является чистым, Angular знает, что возвращаемое значение канала не меняется, если вход канала не изменяется.

В нашем существующем примере мы можем создать чистую трубу для вычисления полного имени человека:

import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'fullName',
  pure: true
})
export class FullNamePipe implements PipeTransform {
  transform(person: any, args?: any): any {
    return person.firstName + ' ' + person.lastName;
  }
}

Канал в Angular по умолчанию является чистым, поэтому мы можем пропустить часть pure: true в декораторе конвейера:

import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'fullName'
})
export class FullNamePipe implements PipeTransform {
  transform(person: any, args?: any): any {
    return person.firstName + ' ' + person.lastName;
  }
}

Мы можем использовать канал, добавив его в качестве объявления в наш модуль:

@NgModule({
  declarations: [ AppComponent, PersonComponent, FullNamePipe ]
})
export class AppModule { }

и используя его с точки зрения нашего PersonComponent:

@Component({
  template: `
    <p>Welcome {{ person | fullName }}!</p>
    <div (mousemove)="onMouseMove()">Drop a picture here</div>
`
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonComponent implements OnChanges {
@Input() person: { firstName: string, lastName: string };
constructor() { }
onMouseMove() {
    console.log('Mouse was moved');
  }
}

Angular теперь достаточно умен, чтобы знать, что выражение {{ person | fullName }} не изменится, если person не изменится.

В результате Angular пропускает выполнение transform метода канала, если человек не меняется.

Но что, если наша логика более сложна и не может быть обработана трубкой?

Стратегия 2. Расчет значений вручную

Вторая стратегия, позволяющая избежать ненужных вызовов функций, состоит в том, чтобы вручную вычислить значения, которые нам нужны в нашем представлении, из контроллера компонента.

Как разработчик, мы можем воспользоваться тем фактом, что знаем, когда значение выражения может измениться.

В нашем примере мы знаем, что полное имя может измениться только при изменении person, поэтому мы можем добавить свойство fullName к компоненту и пересчитать его значение в ngOnChanges только при изменении значения входного person компонента.

Это оставляет нам простое свойство, которое содержит необходимое нам значение, поэтому мы можем заменить выражение вызова функции {{ fullName() }} в представлении выражением свойства {{ fullName }}:

@Component({
  template: `
    <p>Welcome {{ fullName }}!</p>
    <div (mousemove)="onMouseMove()">Drop a picture here</div>
`
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonComponent implements OnChanges {
  @Input() person: { firstName: string, lastName: string };
  fullName = '';
  constructor() { }
  ngOnChanges(changes: SimpleChanges) {
    if (changes.person) {
      this.fullName = this.calculateFullName();
    }
  }
  calculateFullName() {  
    return this.person.firstName + ' ' + this.person.lastName;
  }
  onMouseMove() {
    console.log('Mouse was moved');
  }
}

В результате свойство fullName пересчитывается только при изменении входа person компонента.

Когда мы наводим курсор на div, обнаружение изменений больше не выполняет логику для вычисления полного имени.

Посмотрите, как это простое изменение сокращает 294 вызова функций до 5 вызовов функций, когда мы перемещаемся по объектам с 5 людьми и запускаем событие mousemove:

Визуально разницы нет. Но за кулисами влияние наших изменений огромно.

Вы можете сами поиграть с демоверсией прямо здесь: https://stackblitz.com/edit/angular-hxmb6q

Резюме

В этой статье мы узнали, что вызовы функций в выражении могут вызвать серьезные проблемы с производительностью, которые могут остаться незамеченными и замедлить работу вашего приложения даже при включенном ChangeDetectionStrategy.OnPush.

Чтобы избежать проблем, настоятельно рекомендуется не использовать вызовы функций в выражениях шаблона Angular.

Вместо этого вы можете:

  • используйте чистые каналы, чтобы Angular знал, что выполнение канала можно безопасно пропустить, если вход канала не изменяется.
  • вычисляйте вручную нужные вам значения в контроллере вашего компонента и пересчитывайте их только при необходимости.

Чтобы увидеть влияние на производительность, вы можете поиграть с живым демо прямо здесь: https://stackblitz.com/edit/angular-hxmb6q

В следующий раз, когда вы обнаружите, что пишете вызов функции в шаблоне, обязательно подумайте о возможных последствиях.

И если вы заметили другого разработчика, использующего вызов функции в выражении шаблона Angular во время проверки кода, любезно отправьте ему ссылку на эту статью, чтобы поделиться с ним своими знаниями.

Потому что производительность имеет значение!

Всего хорошего!