Sposób na śledzenie zmian property @Input - ngOnChanges i Angular
Szczegółowe omówienie metody ngOnChanges w Angularze, służącej do śledzenia zmian w polach @Input. Alternatywne metody, pułapki i przykłady użycia.
Spis treści
- Ogólne działanie
ngOnChanges - Śledzenie zmian
@Inputza pomocą getters/setters` - Kiedy
ngOnChangesnie odnotuje zmiany` - NgOnChanges i referencje
- Podsumowanie
ngOnChanges - Przydatne linki
Metoda ngOnChanges bardzo często przydaje się do nadsłuchiwania zmian w polach @Input. Jeżeli chcemy się dowiedzieć kiedy zmienia się wartość takiego property, lifecycle hook ngOnChanges może się do tego przydać.
Ogólne działanie ngOnChanges
Zacznijmy jednak od początku. Metoda ngOnChanges pochodzi z interfejsu OnChanges. Jest to metoda, która w cyklu życia komponentu wywoływana jest jako pierwsza, zaraz po konstruktorze. Wywoływana jest także za każdym razem, gdy property @Input zmieni się przez przekazanie nowej wartości z komponentu położonego wyżej.
Metoda ngOnChages wygląda tak:
ngOnChanges(changes: SimpleChanges): void {}
Jako parametr przyjmuje obiekt SimpleChanges, który jest tablicą asocjacyjną wszystkich property znajdujących się w naszym komponencie. Za pomocą klucza typu string możemy z obiektu changes wyciągnąć obiekt SimpleChange, który reprezentuje dokładnie property @Input w naszym komponencie. Jeżeli w komponencie mamy property @Input name, to możemy je z obiektu SimpleChanges wyciągnąć w taki sposób:
let prop: SimpleChange = changes['name']
Obiekt reprezentujący zmianę property @Input wygląda tak:
class SimpleChange {
constructor(previousValue: any, currentValue: any, firstChange: boolean)
previousValue: any
currentValue: any
firstChange: boolean
isFirstChange(): boolean
}
Mamy więc w tym miejscu dostęp do poprzedniej i obecnej wartości, a także dodatkowe informacje o pierwszej zmianie obiektu.
Możemy też użyć prostej pętli i sprawdzić wszystkie obiekty:
ngOnChanges(changes: SimpleChanges) {
for (const prop of Object.keys(changes)) {
console.log(changes[prop]);
}
}
Rozważmy prosty komponent, który posiada jedno property @Input name.
export class ChildComponent implements OnChanges {
@Input() name
ngOnChanges(changes: SimpleChanges) {
for (const prop of Object.keys(changes)) {
console.log(changes[prop])
}
}
}
Jeżeli zainicjalizujemy property z komponentu położonego wyżej:
<app-child name="Angular"></app-child>
W konsoli otrzymujemy informację o tym, że nastąpiła zmiana wartości:
previousValue: undefined
currentValue: "Angular"
firstChange: true
Właśnie w taki sposób, możemy śledzić zmianę wartości pól @Input w klasie komponentu. Jeżeli w naszym komponencie będziemy mieli inne property @Input ale nie będzie zainicjalizowane, to SimpleChanges nie będzie posiadał o nim żadnej informacji. Ponieważ SimpleChanges posiada informacje tylko o tych property, które się zmieniają i o zmianach, które potrafi wykryć.
Jeżeli chcemy śledzić tylko konkretne property to musimy się posłużyć na przykład takim zapisem:
ngOnChanges(changes: SimpleChanges) {
if (changes.name) {
console.log('Nowa wartość "@name": ', changes.name.currentValue);
}
}
To jest jeden ze sposobów śledzenia zmian w property @Input w Angularze, ale nie jedyny.
Śledzenie zmian @Input za pomocą getters/setters
Innym sposobem śledzenia zmian w polach@Input jest użycie czystego TypeScript oraz set i get.
private _company;
@Input()
set company(value: string) {
this._company = value;
console.log(`title is changed to ${value}`);
}
get company(): string {
return this._company;
}
Przy takim sposobie, musimy stworzyć prywatne pole w klasie komponentu i odpowiednio akcesor set i get dla tego pola klasy. Za każdym razem gdy komponent wyższego rzędu, będzie chciał użyć tego pola musi użyć nazwy akcesora:
<app-child company="Google"></app-child>
Będziemy mogli odnotować zmianę przy każdej zmianie property. Oczywiście ma to swoje plusy i minus. Musimy dopisać większą ilość kodu i posługiwać się dodatkowym polem prywatnym, które i tak nie jest prywatne bo stworzyliśmy dla niego set i get :-D.
Jednak ten sposób ma jedną ważną zaletę, możemy wyśledzić zmianę nawet jeżeli metoda ngOnChanges nie odnotuje zmiany. Może się tak wydarzyć, gdy zmienimy pole @Input wewnątrz klasy komponentu lub też będziemy pracować na obiekcie.
Kiedy ngOnChanges nie odnotuje zmiany
Może się tak zdarzyć, że metoda ngOnChanges nie odnotuje zmiany w polu @Input. Dzieje się tak gdy postanowimy nadpisać property np. w metodzie ngOnInit, rozważmy taki przykład:
ngOnInit() {
this.name = 'React';
}
W tym przypadku podmieniam wartość @Input name na inną w ngOnInit. Pole @Input name cały czas jest inicjalizowane z komponentu położonego wyżej i ngOnChanges widzi inicjalizacyjną zmianę property, ale nie odnotowuje nadpisania w metodzie ngOnInit. Oczywiście w widoku HTML widnieje napis React i strona renderuje prawidłową wartość, jednak ngOnChanges jest w tym przypadku głuchy.
SimpleChange {previousValue: undefined, currentValue: "Angular", firstChange: true}
Property @Input color zmieniła wartość, ale nie zostało to odnotowane przez metodę ngOnChanges, tylko wartość inicjalizacyjna została odnotowana. Metoda ta uruchamia się tylko wtedy, gdy zmiana pól @Input przychodzi przez bindowanie w widoku HTML.
Dlatego użycie akcesora set może się przydać jeżeli chcemy sprawdzać zmieniające się wartości gdy modyfikujemy property @Input inaczej niż przez bindowanie w widoku HTML.
NgOnChanges i referencje
Inną sytuacją kiedy ngOnChanges nie powiadomi nas o zmianach są obiekty referencyjne. Gdy zmienia się tylko zawartość obiektu lub listy, Angular nie uruchomi metody ngOnChanges. Dodaję do przykładu nowy @Input:
@Input() frameworks: Array<string>;
Nadrzędny komponent wygląda tak:
@Component({
selector: 'app-root',
template: `
<app-child name="Angular" [frameworks]="others"></app-child>
<button (click)="add()">Dodaj</button>
`,
})
export class AppComponent {
others = ['Vue', 'React']
add() {
this.others.push('Svelte')
// Należy odkomentować, aby Angular wykrył zmianę
// i odnotowałę w ngOnChanges
// this.others = [...this.others];
}
}
Lista others zbindowana jest do @Input() frameworks. Na początku wszystko inicjalizuje się poprawnie i ngOnChanges odnotowuje zmianę:
SimpleChange {previousValue: undefined, currentValue: Array(2), firstChange: true}
previousValue: undefined
currentValue: (2) ["Vue", "React"]
firstChange: true
Gdy za pomocą przycisku dodamy kolejną wartość do listy, ngOnChanges nie wykrywa takiej zmiany. Zmiany oczywiście renderowane są w widoku HTML ponieważ Change Detection w Angularze działa, ale ngOnChanges milczy.
W przypadku obiektów ngOnChanges potrafi wykryć tylko zmieniającą się referencję. Nie jest w stanie sprawdzić czy coś mogło się zmienić w samym obiekcie.
Sposobem na to jest przekazanie nowej referencji. W ten sposób metoda ngOnChanges odnotuje zmianę w property @Input:
add() {
this.others.push('Svelte');
this.others = [...this.others];
}
Podsumowanie ngOnChanges
ngOnChangesuruchamia się zaraz po konstruktorze i przedngOnInitngOnChangesmoże się przydać do śledzenia zmian w property@Input- alternatywnym rozwiązaniem śledzenia zmian jest użycie
set/getdla property@Iput ngOnChangesuruchamia się za każdym razem, gdy property@Inputzmieni wartość w widoku HTML gdzie jest bindowanangOnChangesnie uruchamia się gdy zmieniają się pola obiektów lub zawartość listy (nie obsługuje zmian w obiektach referencyjnych)- aby
ngOnChangesodnotował zmiany przy obiektach, musimy przekazać je jako nową referencję ngOnChangesnie uruchomi się gdy zmienimy wartość property@Inputprzez bezpośrednie odniesienie np.this.color = 'red'
Przydatne linki
Mój przykład na StackBlitz: https://stackblitz.com/edit/przyklad-ng-on-changes
https://angular.io/guide/lifecycle-hooks#onchanges
https://angular.io/api/core/SimpleChange
https://medium.com/angular-in-depth/creatively-decouple-ngonchanges-fab95395cc6e
https://www.angular.love/2017/01/23/angular-2-lifecycle-hooks-ngonchanges-ngoncheck/