Mechanizm wykrywania zmian w Angularze, czyli o OnPush
Za wykrywanie, że dane w komponencie się zmieniły, a jego widok trzeba przerysować - odpowiada mechanizm wykrywania zmian "Change Detection. W moim pierwszym projekcie angularowym wszystkie komponenty używały domyślnej strategii wykrywania zmian.
Nie dokopałem się wtedy na czas do informacji, że istnieje inna opcja. Początkowo wszystko było elegancko, dokładaliśmy kolejne funkcjonalności / komponenty. Aplikacja rosła i nic nie wskazywało problemów.
Po pewnym czasie zauważyliśmy jednak, że COŚ SIĘ DZIEJE. Okazało się, że kiedy chcę wykonać najprostszą czynność w postaci kliknięcia w button, aplikacja zamula.
Prosta sprawa klik -> zapytanie -> odpowiedź -> zmiana koloru. A jednak trwało to kilka sekund…
Kłopoty wydajnościowe – jak się ich pozbyć?
Wtedy nadszedł czas na śledztwo i szukanie źródła problemu. Po dwóch, czy trzech dniach doszedłem, o co biega. Na ekranie miałem 50 instancji tego samego komponentu (takie wymagania biznesowe). Kliknięcie w jedną z nich powodowało, że wszystkie pozostałe 49 sprawdzały, czy ich propercje się nie zmieniły! I próbowały się przerysować. WSZYSTKIE NA RAZ. Właśnie w ten sposób:
To powodowało efekt opóźnienia. A przecież tak być nie powinno… Klikam w jedną, określoną instancję. Tylko ona powinna sprawdzić swoje zmiany i przerysować się.
Strategia OnPush
Wystarczyła jedna mała zmiana w klasie komponentu. Dołożenie jednej linijki. I wszystko wróciło na właściwe tory. Czas przetwarzania pojedynczego kliknięcia zmalał z ok. 200ms na początku do 2-3ms. Także bajlando ?
Zmiana ta była mega prosta i wyglądała mniej więcej tak:
@Component(
…
changeDetection: ChangeDetectionStrategy.OnPush
)
To spowodowało odpięcie mojego komponentu od domyślnej strategii wykrywania zmian. Efekt był piorunujący:
DA BOOM! Czerwona kropa oznacza komponenty z podpiętą strategią OnPush. Od teraz event przeglądarkowy powoduje przerenderowanie tylko tego komponentu, w którym nastąpił plus rzecz jasna jego rodzica.
No dobra, ale nie samym klikiem apka żyje…
OnPush – kiedy stwarza problemy?
Co w przypadku drugiej gałęzi DOM prezentowanej na rysunku? Załóżmy, że komponent pośredni jest typu Smart. Pobiera dane z serwisu i przekazuje je w dół drzewka za pomocą one way data binding (czyli coś w stylu [input]=”data”
). Dane trafiają do komponentów Presentational (tych na samym dole) ({{ data }}
).
Wszystko pięknie w momencie inicjalizacji, ale co ze zmianą tych danych? Jeżeli nie były przekazane przez referencję - to zaczyna się jazda.
Co w tej zmiennej date
się znajduje? Powiedzmy, że prosta informacja o prawidłowym podłączeniu do serwisu bazy danych. Informacja ta spływa do naszego Smart komponentu z zewnętrznego serwisu (serviceEmitter) co 5 sekund:
ngOnInit() { serviceEmitter() .pipe(takeUntil(this.destroyed$)) .subscribe(serviceValue => { this.data = serviceValue; // ❌ false, false, false, true, ale szablon wciąż widzi false }); } // symulacja serwisu const serviceEmitter = () => { return of(false, false, false, true).pipe(delay(5000)); };
Wprawdzie komponent Smart odbiera te dane prawidłowo, ale nie jest w stanie przekazać ich do swojego Presentational dziecka, bo jego szablon nic o tej zmianie nie wie!
Dane mogą zostać ponownie przesłane pomiędzy komponentami tylko w przypadku rerenderowania. Podpinając tutaj strategię OnPush, powiedzieliśmy komponentowi, że może się przerendereować tylko w dwóch przypadkach:
- Nastąpił na nim event przeglądarkowy – ale w tej sytuacji nie nastąpił
- Zmieniły się jego propercje wejściowe – nie zmieniły się, bo on takich nie ma! Ma zwykłą propercję data, ale nie związaną dekoratorem @Input().
Skoro SmartComponent się nie przerenderowuje, tym bardziej nie zrobi tego jego dziecko, zainteresowane tą niewykrytą zmianą.
Czyli OnPush nie działa…
Pierwszym nasuwającym się rozwiązaniem jest przywrócenie strategii domyślnej na komponencie Smart.
I to jest całkiem spoko rozwiązanie. Pod warunkiem, że wiemy co robimy i godzimy się na przerenderowanie tego komponentu w wyniku zdarzeń i zmian DOM zachodzących w kompletnie innej gałęzi:
Teraz komponent Smart wprawdzie przerenderowuje się wskutek kliknięcia w inny rejon DOM, ale tutaj to przerenderowanie się kończy. Jego dzieci mają OnPush, więc już nie reagują na taką sytuację.
markForCheck()
Co jeśli jednak takie rozwiązanie Cię nie satysfakcjonuje i chcesz uniezależnić gałęzie od siebie z zachowaniem wykrycia zmiany? Możesz oznaczyć ten komponent do sprawdzenia w następnym cyklu change detectora:
constructor(private cd: ChangeDetectorRef) {}
serviceEmitter() .pipe(takeUntil(this.destroyed$)) .subscribe(serviceValue => { this.isConnected = serviceValue; // false, false, false, true // ✅Wykryj zmiany przy kolejnym cyklu CD. Rerendering również na // dzieciach z domyślną strategią this.cd.markForCheck(); });
Dodanie tej prostej linii skutkuje prawidłowym przerenderowaniem komponentu Smart:
W dodatku podczas tego przerenderowania następuje przekazanie nowej wartości input do komponentu Presentational. Dzięki temu on też się przerenderowuje (drugi wyzwalacz przerenderowania komponentu z OnPush).
Podsumowując
OnPushe są fajne, o ile pamiętasz o kilku prostych zasadach:
- Nie są lekiem na wszystko
- Mówią komponentowi, że może przerenderować się tylko w trzech przypadkach:
- Wystąpił na nim event przeglądarkowy
- Nastąpiła zmiana danych wejściowych przekazanych z rodzica przez one way data binding
- Programista świadomie wymusza sprawdzenie tego komponentu metodą
markForCheck()
Istnieje jeszcze alternatywa dla markForCheck(): detectChanges()
. Jednak o różnicach pomiędzy nimi i wykorzystaniu całkowitego odpiecia komponentu z drzewa change detectora (detach()
) pogaramy innym razem, bo to temat na osobny wpis.
Tyle na dziś. Dzięki!