Błędna detekcja zmian w Angularze
Spotkałem się ostatnio z sytuacją, w której zmiana wartości @Input()’a nie była przechwytywana w hooku ngOnChanges w komponencie Angulara. To dosyć osobliwe, ponieważ to właśnie do przechwytywania takich zmian wprowadzono do Angulara ten hook. Jak to możliwe? Otóż wartość rzeczywiście się zmieniła, jednak referencja - nie. Tak właśnie działa Angualar - przy wykrywaniu zmian używa operatora ścisłej równości (===), a więc porównuje referencje, nie wartości. Jak dokładnie ten fakt potrafi “zepsuć” mechanizm detekcji zmian?
Przykład
Powiedzmy, że mamy @Input() o nazwie “items”, będący tablicą obiektów typu, zgadza się, Item. Próbujemy wykryć zmiany wartości tego pola w hooku ngOnChanges:
Przeciwnie do wszelkich oczekiwań, ten kod nie wykrył żadnych zmian, nawet mimo faktu, że widziałem jak zawartość tablicy “items” ulega zmianie (a nawet jak zmienia się jej długość!) - logowałem ją w obsłudze zdarzeń kliknięcia, na zakończenie operacji asynchronicznych itp.
Nie budując napięcia w nieskończoność zdradzę, że problem istniał w komponencie wyższego rzędu, w sposobie dodawania/usuwania/modyfikowania elementów tablicy “items”. Zobaczmy:
W tym przykładzie, “items” jest ciągle tą samą tablicą, co oznacza tę samą referencję, a więc brak wykrycia zmiany wartości. Ten sam efekt mógłby zostać zaobserwowany po użyciu każdej z mutujących metod typu Array, jak na przykład:
Efekt ten zaobserwujemy też przekazując do komponentu obiekty - dodawanie/usuwanie kluczy lub modyfikacja wartości nie zmienia referencji obiektu!
Rozwiązanie
Istnieją 2 rozwiązania tego problemu:
- tworzenie nowego obiektu/tablicy za każdym razem, gdy zmienia się jego wartość
- (nie zalecane) napisanie własnego mechanizmu wykrywania zmian w hooku ngDoCheck
Skorzystałem z pierwszej opcji, a więc przepisałem wszystkie miejsca, gdzie tablica “items” była mutowana. Jak dokładnie?
Tworzenie nowego obiektu zamiast modyfikacji
Przykłady powyżej pokazują jak można zastąpić najczęściej używane metody tablicy tak, aby efekt użycia był ten sam, ale stworzony został nowy obiekt. Można skorzystać też z pewnych “trików” na wymuszenie stworzenia płytkiej kopii (ang. shallow copy) tablicy (w moim przypadku to było wystarczające):
i użyciu takich funkcji pomocniczych na zmodyfikowanej tablicy:
Własny mechanizm detekcji zmian w hooku ngDoCheck
Alternatywą jest zdefiniowanie własnego mechanizmu detekcji zmian, najlepiej w hooku ngDoCheck, jednakże nie jest to zalecane. Dlaczego? Ten hook wykonywany jest bardzo często, co może mieć negatywny wpływ na wydajność aplikacji. Jeśli naprawdę chcesz go użyć, pamiętaj, że ostrzegałem zawarty w nim kod powinien być naprawdę niewielki, niezbyt złożony.
Oto jak można rozwiązać omawiany problem za pomocą ngDoCheck:
Zauważcie, że sposób ten wymaga przechowywania starej wartości sprawdzanej zmiennej - musimy ją pamiętać, aby móc wykryć jakąkolwiek zmianę (this.oldItems w kodzie powyżej). W tym przykładzie, zaimplementowałem prosty mechanizm “płytkiego” porównania, wykrywający zmianę długości tablicy oraz zmianę referencji dowolnego z elementów tablicy. Jednak nawet pisząc w ten sposób, musiałem użyć funkcji copyArray zdefiniowanej wcześniej w poście po to, aby po wykryciu zmiany skopiować tablicę do zmiennej this.oldItems. Jest to konieczne, aby wartości “items” i “oldItems” były niezależnie przechowywane w pamięci i aby ich porównywanie miało sens.
Jak zawsze, zachęcam do przeczytania bardziej szczegółowej, oficjalnej dokumentacji, pokazującej więcej przykładów i wyjaśniającej szczegóły działania hooka ngDoCheck.
Podsumowanie
Należy pamiętać o tym, w jaki sposób Angular wykrywa zmiany wartości @Input() - używa do tego najprostszego możliwego operatora ścisłej równości (===), co niesie czasami niespodziewane konsekwencje. Dzisiejszy post pokazał jak radzić sobie z takimi przypadkami, aby móc wykrywać zmiany także w mutowanych tablicach i obiektach.