Spis treści
- Wprowadzenie
- Eksperymenty implementacyjne
- Kod przykładów na Github
- Działający przykład na Github Pages
- Podsumowanie
Wprowadzenie
Kwestie wydajności to jedna z tych rzeczy, którą w Redux popsuć niełatwo. Często jednak wiele mikroproblemów potrafi spiętrzyć się w jeden duży kłopot – aplikacja z każdą nową funkcjonalnością działa coraz wolniej a my, deweloperzy jak i testerzy, nie widzimy problemu na naszych ponadprzeciętnie szybkich urządzeniach. Aż z problemem wydajności aplikacji zgłasza się klient, i wtedy następuje paniczne poszukiwanie źródła problemu, gdzie często zamiast jednego dużego jest kilka mniejszych źródeł.
W tym artykule spojrzymy na wydajność pod kątem liczby i powodów rerenderów w aplikacji używającej Reduxa gdy używamy obiektów- ułatwień od twórców biblioteki, dobrych praktyk i potencjalnych nieoczywistych pułapek.
Zapraszam do czytania 🙂
Eksperymenty implementacyjne
Spójrzmy jak różne implementacje i podejścia sprawdzą się w przypadku liczby rerenderów.
Dodajmy formularz w którym będziemy zmieniać wartości by w komponentach obserwować liczbę rerenderów – dodatkowo dodajmy przycisk który będzie tworzył nowy obiekt ze starymi wartościami – symulacja, przykładowo, zapytania na serwer.
Formularz prezentuje się następująco:
Sam pełny kod aplikacji dostępny będzie bezposrednio na Github a w pełni klikalna wersja na Github Pages.
Zwykły useSelector
Użyjmy useSelector bez żadnych dodatkowych parametrów by pobrać obiekt:
const user = useSelector(state => state.user); return ( <> <h3 className="bad">Container with regular useSelector</h3> <UserDisplay user={user} /> </> )
Funkcja useSelector wymusi rerender w sytuacji, gdy pojawiła się nowa wartość. Ponieważ każda zmiana stanu w naszej implementacji tworzy nowy obiekt, useSelector zawsze wymusi rerender. Dzieje się tak, ponieważ useSelector porównuje obiekty za pomocą ===
, czyli porównania przez referencje. Jeżeli taki obiekt przekażemy dalej bezpośrednio w dół, komponentowi który otrzymuje obiekt nie pomoże nawet React.memo.
Zwykły useSelector + memo na komponencie
Zmiana ze zwykłego komponentu na komponent z memo:
const UserDisplayMemo = memo(UserDisplay); // .... return ( <> <h3 className="bad">Container with regular useSelector and memo</h3> <UserDisplayMemo user={user} /> </> );
Nic nam nie da. Memo pomaga w sytuacji, gdy z jakiegoś powodu przerenderował się rodzić a nie ma potrzeby przerenderowania dziecka. Samo sprawdzenie konieczności rerenderu sprawdza propsy za pomocą ===
– więc ponieważ zawsze pojawi się nowa referencja, zawsze nastąpi rerender.
Zwykły connect
Zachowanie będzie identyczne gdy użyjemy metody connect:
const mapStateToProps = state => ({ user: state.user, }); return connect(mapStateToProps)(Container);
Użycie metody connect wiąże się z pewnymi optymalizacjami – zamiast porównania bezpośrednich wartości mamy porównanie wartości zwracanego z mapStateToProps obiektu ( {user: state.user }
), dodatkowo zapewnia brak rerendera komponentu w sytuacji, gdy nie ma takiej potrzeby ale rodzic się przerenderował. Jednak te optymalizacje to za mało: porównanie następuje po polach obiektu (w tym przypadku jest jedno pole user) i to te pola porównywane są operatorem ===
. Stąd nowa referencja do obiektu user wymusi rerender, nawet gdy wartości pól są takie same.
Connect z memo
Podobnie nic nie da zestawienie connect + memo – ten sam problem co w przypadku useSelector z memo, następuje porównanie po referencji.
useSelector z shallowEqual
Krokiem do przodu z punktu widzenia useSelector będzie zastosowanie shallowEqual – wtedy porównanie zamiast używać operatora ===
porównywać będzie tak jak w connect, po polach obiektu:
const user = useSelector(state => state.user, shallowEqual);
Choć nadal zmiana wartości age będzie rerenderować obiekt, to render nie nastąpi w sytuacji nowej referencji – ponieważ wartości pól w obiekcie pozostaną takie same.
useSelector pobierający proste wartości
Najefektywniejszym podejściem będzie rezygnacja z pobierania całego obiektu i odczyt tylko tego co potrzebujemy:
const name = useSelector(state => state.user.name); const surname = useSelector(state => state.user.surname);
Wtedy ani zmiana pola age ani nowa referencja do obiektu nie spowodują rerendera.
Kosztem w takiej sytuacji jest nieco mniej czytelny i bardziej rozwiązły kod.
Connect pobierający proste wartości
Analogicznie poprawne zachowanie dostaniemy, gdy użyjemy prostych wartości razem z connect:
const mapStateToProps = state => ({ name: state.user.name, surname: state.user.surname, });
connect z mapStateToProps który zwraca tylko jeden obiekt
Co prawda przywykliśmy (albo przynajmniej ja przywykłem 🙂 ) do tego, że w mapStateToProps zwracamy obiekt:
const mapStateToProps = state => ({ user: state.user, // inne pola });
W naszym przypadku możemy jednak zwrócić pojedynczy obiekt:
const mapStateToProps = state => state.user;
Zachowa się on identycznie jak useSelector z shallowEqual, czyli nowa referencja nie spowoduje rerendera. Będzie tak dlatego, ponieważ porównywanie następuje po polach obiektu, więc mimo że mamy nowy obiekt wartości jego pól są takie same, nie ma więc konieczności odświeżenia widoku.
Kod przykładów na Github
Pełny kod przykładu:
https://github.com/radek-anuszewski/redux-objects
Działający przykład na Github Pages
Działający przykład:
https://radek-anuszewski.github.io/redux-objects/
Podsumowanie
Choć te przykłady mogą Ci się wydać mocno naciągane, istnieją przypadki gdy nadmiarowe rerendery potrafią napsuć dużo krwi – przykładowo, przy obsłudze wykresów. Dobrze więc wiedzieć jak się przed nimi zabezpieczyć, i jak rozwiązać potencjalne problemy zarówno w “starej” jak i “nowej” obsłudze Reduxa.