Będąc dalej w transie programowania aplikacji do monitorowania jednostek morskich chciałem udostępniać niektóre opcje w zależności od tego czy użytkownik jest zalogowany czy nie. Oczywiście chodzi tutaj o kontrolki na interfejsie użytkownika, który tworzę w Angularze. Zanim jednak dotarło do mnie, że trzeba w tym przypadku skorzystać z Observable i BehaviorSubject to kombinowałem z wywoływaniem metod w szablonach HTML. To był błąd, który na moim etapie tworzenia aplikacji nie miałby takiego wielkiego znaczenia jednak później mógłby się odbić czkawką. Zaraz przekonamy się dlaczego.

Przykładowy projekt

Stworzyłem na potrzeby tego artykułu bardzo prostą aplikację, która będzie reprezentowała konto użytkownika w banku. Wyświetlone zostanie na stronie imię i nazwisko naszego klienta oraz stan jego konta. Wygląda to w następujący sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, Input } from '@angular/core';
import { Client } from './client.type';

@Component({
  selector: 'app-client-account',
  template: `
    <h1>
      Client: <strong></strong>
    </h1>
    <div style="margin-bottom: 10px;">
      <input type="text" #updatedName/>
      <button (click)="updateName(updatedName.value)">Update name</button>
    </div>
    <app-bank-balance [account]="client.account"></app-bank-balance>
  `,
  styleUrls: ['./client-account.component.css']
})
export class ClientAccountComponent {

  @Input()
  client!: Client;

  constructor() { }

  updateName(updatedName: string) {
    this.client.name = updatedName;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { Component, Input } from '@angular/core';
import { Account } from '../client-account/client.type';

@Component({
  selector: 'app-bank-balance',
  template: `
    <div>Current balance: 
      <span [ngClass]="isBalancePositive() ? 'balance-positive' : 'balance-negative'">{{ printBalance() }}</span>
    </div>
    <div style="margin-top: 10px;">
      <button (click)="randomizeOffer()">Randomize offer</button>
      <div></div>
    </div>
  `,
  styleUrls: ['./bank-balance.component.css']
})
export class BankBalanceComponent {

  @Input()
  account!: Account;

  randomizedOffer = '';

  counterForIsBalancePositive = 0;
  counterForPrintBalance = 0;

  constructor() { }

  isBalancePositive() {
    console.log(`Rerendered isBalancePositive: ${++this.counterForIsBalancePositive}!`);
    return this.account.balance >= 0;
  }

  printBalance() {
    console.log(`Rerendered printBalance: ${++this.counterForPrintBalance}!`);
    return `${this.account.balance} ${this.account.currency}`;
  }

  randomizeOffer() {
    this.randomizedOffer = `You randomized ${Math.floor(Math.random() * (50 - 10 + 1)) + 10}% discount!`
  }
} 

Komponent ClientAccountComponent jest odpowiedzialny za wyświetlenia danych użytkownika oraz daje możliwość ich aktualizacji. Natomiast zagnieżdżony w nim BankBalanceComponent wyświetla aktualny stan konta klienta oraz pozwala na wylosowanie jednej z wielu promocyjnych ofert. Warto zwrócić uwagę na fakt, że w jednym z szablonów HTML wywołujemy dwie metody: isBalancePositive oraz printBalance. W ich ciele od razu przygotowałem wyświetlenie logów w konsoli, które jak zobaczymy przydadzą nam się od razu podczas uruchomienia projektu.

Uruchamiamy naszą aplikację… i co tu się wydarzyło?

Kiedy już wpiszemy w konsoli komendę ng serve aplikacja odpali się domyślnie na localhost:4200. Wchodzimy w konsolę i dostajemy taką oto informację. Metody isBalancePositive oraz printBalance zdążyły się już wywołać po 4 razy!

Uruchomienie aplikacji wiąże się z czterokrotnym wywołaniem naszych metod
Uruchomienie aplikacji wiąże się z czterokrotnym wywołaniem naszych metod

No dobrze, załóżmy, że tak musi być, bo przecież aplikacja dopiero co się uruchomiła, więc pewnie istnieją mechanizmy, które potrzebują tylu wywołań. Jednak po kliknięciu Randomize offer licznik ten powiększył się o dodatkowe dwukrotne wywołanie każdej z metod. Dziwne, bo przecież randomizeOffer nie uruchamia ich wewnątrz siebie ani nie dotyka zmiennych, którymi te dwie metody są zainteresowane. Dobra, ale zróbmy jeszcze jeden test. Zmieńmy dane nasze klienta i kliknijmy guzik Update name. Jest to w ogóle inny komponent, więc nic złego nie powinno się wydarzyć. Jednak ku naszemu zdziwieniu isBalancePositive oraz printBalance ponownie nabiły swój licznik. Jakie jest wyjaśnienie tej sytuacji?

Dotykanie innych metod czy komponentów również nie ułatwia sprawy
Dotykanie innych metod czy komponentów również nie ułatwia sprawy

Dlaczego Angluar robi takie rzeczy?

W Angularze istnieje taki mechanizm jak change detection, którego zadaniem jest okrycie, które części interfejsu graficznego potrzebują ponownego wyrenderowania. Jasne jest, że powstał on po to, aby na nowo generować treści, które uległy zmianie. Jednak w przypadku metod nie jest to takie proste, ponieważ Angular nie ma w jaki sposób dowiedzieć się czy zwracana wartość uległa zmianie. Rozwiązaniem jest po prostu jej… wywołanie, a co w naszym przypadku za tym idzie - dodanie logów do konsoli. Z tego powodu za każdym razem kiedy uruchamiany jest mechanizm change detection Angular wywołuje wszystkie metody jakie ma w szablonie HTML. W naszym przypadku nie ma to dużego narzutu, po prostu zaśmiecamy sobie konsolę. Natomiast można sobie wyobrazić, że taka metoda woła zewnętrzny serwis za każdym razem, gdy coś zmieni się na UI nawet nieskorelowanego z daną funkcjonalnością. Jak się przed tym chronić?

Pierwszy na pomoc - ChangeDetectionStrategy.OnPush

ChangeDetectionStrategy.OnPush powstał po to, aby powiedzieć Angularowi, żeby ignorował w danym komponencie zmiany pochodzące z zewnątrz, które nie mają wpływu na przekazywane do niego dane. Jeżeli, więc dodamy do BankBalanceComponent taką strategię to nie powinniśmy się już niczym martwić. Sprawdźmy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Account } from '../client-account/client.type';

@Component({
  selector: 'app-bank-balance',
  template: `
    <div>Current balance: 
      <span [ngClass]="isBalancePositive() ? 'balance-positive' : 'balance-negative'">{{ printBalance() }}</span>
    </div>
    <div style="margin-top: 10px;">
      <button (click)="randomizeOffer()">Randomize offer</button>
      <div></div>
    </div>
  `,
  styleUrls: ['./bank-balance.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BankBalanceComponent {

  @Input()
  account!: Account;

  randomizedOffer = '';

  counterForIsBalancePositive = 0;
  counterForPrintBalance = 0;

  constructor() { }

  isBalancePositive() {
    console.log(`Rerendered isBalancePositive: ${++this.counterForIsBalancePositive}!`);
    return this.account.balance >= 0;
  }

  printBalance() {
    console.log(`Rerendered printBalance: ${++this.counterForPrintBalance}!`);
    return `${this.account.balance} ${this.account.currency}`;
  }

  randomizeOffer() {
    this.randomizedOffer = `You randomized ${Math.floor(Math.random() * (50 - 10 + 1)) + 10}% discount!`
  }
}

Widzimy już poprawę przy uruchomieniu aplikacji na nowo. Metody isBalancePositive oraz printBalance uruchomiły się tylko raz. Sprawdźmy co się stanie jak zmienimy dane klienta. Tak jak się spodziewaliśmy, nie ma żadnych wpisów w konsoli. Spróbujmy wylosować jeszcze jakąś ofertę promocyjną. Niestety, nowe logi pojawiły się. Tak jak wspomniałem wcześniej, ChangeDetectionStrategy.OnPush pomaga nam ignorować mechanizm change detection dla danego komponentu, gdy nie ulegną zmianie wartości przekazywanych do niego parametrów. Natomiast nie pomaga to na weryfikację zmian wewnątrz tego komponentu. Warto mieć to na uwadze.

Jest lepiej, ale nie do końca
Jest lepiej, ale nie do końca

Rozwiązania definitywnie eliminujące problem

Zastosowanie pure pipe

Pierwszym ze sposobów rozwiązujących definitywnie problem zbędnego wywoływania metod jest zastosowanie mechanizmu pipe. Dzięki niemu informujemy Angulara, że wartość zwracana z utworzonego pipe nie zmieni się dopóki nie ulegną zmianie jego parametry wejściowe. Przejdźmy, więc do działania i zobaczmy jak to działa w praktyce.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Pipe, PipeTransform } from '@angular/core';
import { Account } from '../client-account/client.type';

@Pipe({
  name: 'bankBalance',
  pure: true // by default
})

export class BankBalancePipe implements PipeTransform {
  
  transform(account: Account): string {
    console.log(`Call transform for bank balance pipe`);
    return `${account.balance} ${account.currency}`;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { BankBalanceComponent } from './bank-balance/bank-balance.component';
import { BankBalancePipe } from './bank-balance/bank-balance.pipe';
import { ClientAccountComponent } from './client-account/client-account.component';

@NgModule({
  declarations: [
    AppComponent,
    BankBalanceComponent,
    ClientAccountComponent,
    BankBalancePipe
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Account } from '../client-account/client.type';

@Component({
  selector: 'app-bank-balance',
  template: `
    <div>Current balance: 
      <span [ngClass]="isBalancePositive() ? 'balance-positive' : 'balance-negative'"></span>
    </div>
    <div style="margin-top: 10px;">
      <button (click)="randomizeOffer()">Randomize offer</button>
      <div></div>
    </div>
  `,
  styleUrls: ['./bank-balance.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BankBalanceComponent {

  @Input()
  account!: Account;

  randomizedOffer = '';

  counterForIsBalancePositive = 0;
  counterForPrintBalance = 0;

  constructor() { }

  isBalancePositive() {
    console.log(`Rerendered isBalancePositive: ${++this.counterForIsBalancePositive}!`);
    return this.account.balance >= 0;
  }

  randomizeOffer() {
    this.randomizedOffer = `You randomized ${Math.floor(Math.random() * (50 - 10 + 1)) + 10}% discount!`
  }
}

W nagłówku napisałem, że musimy stworzyć tzw. pure pipe. Jednak co to oznacza w praktyce? Zaglądając do dokumentacji dowiadujemy się, że zaimplementowana metoda transform będzie wywoływana tylko wtedy, gdy jej argumenty ulegną zmianie. Właśnie na tym nam zależy. Warto dodać, że ten stan jest domyślny dla nowotworzonych pipe.

When true, the pipe is pure, meaning that the transform() method is invoked only when its input arguments change. Pipes are pure by default.

Przy okazji w metodzie transform dodałem wyświetlenie logu, abyśmy mieli lepszy pogląd na sytuację. Oczywiście musimy jeszcze zadeklarować ten nowy twór w module oraz wywołać go w szablonie HTML. Uruchamiamy aplikację i voila! W konsoli otrzymujemy już tylko logi pozostawionej metody isBalancePositive podczas generowania oferty promocyjnej. Oczywiście komunikat ‘Call transform for bank balance pipe’ pojawi się, ponieważ cała aplikacja na starcie musi zostać wyrenderowana.

Problem został naprawiony przy wykorzystaniu pure pipe
Problem został naprawiony przy wykorzystaniu pure pipe

Ręczne wyliczenie wartości

Jako deweloperzy możemy wykorzystać fakt, że wiemy dokładnie kiedy dana wartość się zmieni. W naszym przypadku suma pieniędzy znajdująca się na rachunku zmieni się tylko wtedy, gdy ktoś z zewnątrz przekaże jej nowy stan. Mając tą wiedzę możemy skorzystać z interfejsu OnChanges, aby nauczyć nasz komponent jak ma sobie radzić z taką sytuacją.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Account } from '../client-account/client.type';

@Component({
  selector: 'app-bank-balance',
  template: `
    <div>Current balance: 
      <span [ngClass]="isBalancePositive() ? 'balance-positive' : 'balance-negative'"></span>
    </div>
    <div style="margin-top: 10px;">
      <button (click)="randomizeOffer()">Randomize offer</button>
      <div></div>
    </div>
  `,
  styleUrls: ['./bank-balance.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BankBalanceComponent implements OnChanges {

  @Input()
  account!: Account;

  printBalance = '';

  randomizedOffer = '';

  counterForIsBalancePositive = 0;
  counterForPrintBalance = 0;

  constructor() { }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['account']) {
      this.printBalance = this.calculateBalance();
    }
  }

  isBalancePositive() {
    console.log(`Rerendered isBalancePositive: ${++this.counterForIsBalancePositive}!`);
    return this.account.balance >= 0;
  }

  calculateBalance() {
    console.log(`Invoked calculateBalance: ${++this.counterForPrintBalance}!`);
    return `${this.account.balance} ${this.account.currency}`;
  }

  randomizeOffer() {
    this.randomizedOffer = `You randomized ${Math.floor(Math.random() * (50 - 10 + 1)) + 10}% discount!`
  }
}

Uruchamiamy naszą aplikację i znowu sukces! Metoda calculateBalance wywołała się dokładnie raz tak jak się tego spodziewaliśmy. Po prostu zamiast umieszczać w szablonie HTML wywołanie naszej metody zamieniliśmy ją na zmienną, którą aktualizujemy tylko wtedy, gdy przekazana wartość z zewnątrz account się zmieni.

Osiągnęliśmy ponownie oczekiwany rezultat
Osiągnęliśmy ponownie oczekiwany rezultat

Podsumowanie

Kolejny dzień i kolejny problem do rozwiązania. Teraz już wiem, że wywoływanie metod z szablonu HTML może spowodować brzemienne w skutkach problemy wydajnościowe. Niekoniecznie musi to wyjść na etapie dewelopmentu, bo może nie być na tym środowisku takiej ilości danych jak na produkcji, które spowodują spadek wydajności aplikacji. Z tego powodu zawsze trzeba mieć się na baczności pod względem tego problemu. Wiedzę, którą przedstawiłem w dzisiejszym wpisie zdobyłem czytając artykuł Jurgen Van de Moere za co mu serdecznie dziękuję!