Kiedy memoizacja w React działa przeciwnie: dlaczego mniej renderowań spowolniło aplikację.
Gdy memoizacja w React działa przeciwnie: funkcje memo, useMemo i useCallback dodawały koszt i przestarzałe zamykania funkcji na dużej stronie. Przeniesienie stanu i analiza przepływu naprawiły to.
Przypadek naprawy niewłaściwej rzeczy
Wyobraź sobie dużą stronę, która przerysowuje się za każdym razem, gdy ktoś wpisuje coś do filtru. Zawiera ona tabele, wykresy, podsumowania oraz kilka interaktywnych paneli, a React Profiler pokazuje długą listę komponentów. To wygląda jak dowód na to, że React wykonywa zbyt wiele pracy.
Zastosowuje się zwykłe rozwiązanie. Komponenty są otaczane funkcją memo, obsługi zdarzeń funkcją useCallback, obliczenia funkcją useMemo, a selektory otrzymują spersonalizowane funkcje równości. Komponent, który nadal się renderuje, ma dodatkowo stabilizowane swoje właściwości. Obiekt, którego referencja się zmienia, jest memoizowany. Porównanie, które nie udaje się, jest zastępowane bardziej szczegółowym.
Ostatecznie narzędzie do analizy wydajności rzeczywiście pokazuje mniej renderowań komponentów. Aplikacja jednak nie wydaje się szybsza. Niektóre interakcje stają się mniej responsywne, niektóre ekrany zużywają więcej pamięci, a kilka komponentów wyświetla przestarzałe wartości, ponieważ pominęto jakąś zależność, aby utrzymać stabilność odniesienia. Debugowanie również staje się trudniejsze, ponieważ wydajność zależy teraz od cache’ów oraz reguł porównywania rozproszonych w całym drzewie struktury. Zespół dostosował liczbę, którą łatwo było zobaczyć, a nie to, na czym faktycznie czekali użytkownicy.
Użycie mechanizmu memoizacji nie było błędem, ponieważ React oferuje takie narzędzia do rozwiązywania rzeczywistych problemów. Błędem było przekonanie, że zatrzymanie procesu renderowania zawsze jest lepsze niż jego dopuszczenie. Renderowanie wymaga wysiłku, podobnie jak podjęcie decyzji o jego pominięciu. Gdy ta decyzja oznacza konieczność odbudowy tablic zależności, porównywania dużych wartości propów, przechowywania starych wartości oraz uruchamiania specjalistycznej logiki cache, optymalizacja może okazać się droższa niż praca, którą miała zaoszczędzić.
Co naprawdę oznacza „niepotrzebne”
Pierwszy błąd leżał w definicji niepotrzebnego renderowania. Komponent pojawił się w narzędziu analizy, mimo że jego końcowy wynik w DOM nie uległ widocznym zmianom, dlatego renderowanie zostało określone jako marnotrawstwo zasobów. To ignoruje fakt, jak tanie są wiele komponentów.
Weź komponent funkcyjny, który odczytuje kilka prostych właściwości i zwraca niewielki drzewo elementów. Wykonuje się on błyskawicznie, React porównuje wyniki i prawie w ogóle nie modyfikuje DOM-u, więc zablokowanie tej funkcji przynosi praktycznie żaden efekt.
Wersja z memozacją ma własne zadanie do wykonania. React musi przechowywać poprzednie właściwości, otrzymywać nowe i je porównywać. Gdy właściwości jest kilka, a niektóre z nich to obiekty, tablice lub funkcje, porównanie może często zawieść lub stać się na tyle kosztowne, że zniweczy wszelkie korzyści. Liczba renderowań stała się w praktyce wskaźnikiem wydajności – im niższa, tym lepiej, nawet jeśli to nie one powodowały opóźnienie.
To prosta pułapka, ponieważ narzędzie do analizy wydajności pokazuje proces renderowania, podczas gdy inne koszty pozostają niewidoczne. Porównywanie właściwości, alokacja pamięci w JavaScript, przechowywanie w pamięci cache, selektory przechowywania, układ elementów, proces rysowania oraz biblioteki third-party mogą dominować w interakcji, podczas gdy liczba renderowań przyciąga całą uwagę. Warto zapobiegać ponownemu renderowaniu, gdy komponent wykonywać znaczącą pracę, a jego dane wejściowe często pozostają niezmienne. Nie warto tego zapobiegać tylko dlatego, że tak się stało.
Jaki jest rzeczywisty koszt memo
Ludzie często opisują memo jako mechanizm, który zapobiega ponownemu renderowaniu potomka, gdy właściwości się nie zmieniły. Taki opis pomija jeden krok: React musi najpierw porównać stare właściwości z nowymi, zanim będzie mógł cokolwiek pominąć.
Przy prostych właściwościach porównywanie jest tanie. Przy rozbudowanych umowach komponentów koszt jest trudniejszy do przewidzenia. Przekazywano wtedy obiekty wierszy, tablice konfiguracji, modele uprawnień, kolekcje funkcji zwrotnych oraz nawiasowane opcje. Otoczenie odbiorcy funkcją memo nie stabilizowało żadnej z tych wartości – jedynie dodawało kolejny etap, w którym React sprawdzał, czy są one stabilne.
Większość tych sprawdzeń zawodziła, ponieważ komponent nadrzędny odbudowywał przynajmniej jedno odniesienie podczas renderowania. React mimo to dokonywał porównania i kontynuował renderowanie, więc dodano wysiłek, a nic nie zostało usunięte.
Kolejno stosowano dostosowane funkcje porównywania, mające na celu dokładniejszą analizę wartości. Komponent siatki danych sprawdzał swoje kolumny, wybrane wiersze, szczegóły stronicowania, stan ładowania oraz same wiersze. Ten komparator stanowił drugą kopię modelu zależności komponentu i mógł zawieść w obu przypadkach: dokładne porównanie wymagało czasu rzeczywistego, natomiast niedbale wykonane mogło pominąć konieczną renderizację.
Teraz istniała nowa granica poprawności. Wszystko, co wpływało na renderowanie, musiało być odzwierciedlone w komparatorze, więc dodanie nowej właściwości oznaczało edycję zarówno samego komponentu, jak i jego logiki memoizacji. Komponenty, które wcześniej renderowały się poprawnie przez domyślność, zachowywały się prawidłowo tylko wtedy, gdy funkcja równości była idealnie skonstruowana i opisywała je bezbłędnie.
Głębokie porównanie
Gdy powierzchowne porównania zbyt często zawodzą, głębokie porównanie wydaje się oczywistym następnym krokiem. Jeśli dwa obiekty konfiguracji zawierają te same wartości, dlaczego różne referencje powinny zmuszać do renderowania? Porównaj zawartość i pomiń ten przypadek, jeśli są one strukturalnie identyczne.
To działa w demonstracjach z małymi obiektami. W rzeczywistej aplikacji niektóre właściwości zawierają setki lub tysiące wartości, a głębokie porównanie przeszukuje tablice, sprawdza zagnieżdżone obiekty i powtarza cały ten proces przy każdej aktualizacji rodzica. Komponent chroniony może wyświetlić tylko kilka prostych wierszy lub zwrócić komponent od third party z własną mechanizmem memoizacji dla stabilnych poddrzew. Sprawdzenie równości może kosztować więcej niż sam wywołanie komponentu.
Głębokie porównanie skaluje się również z rozmiarem danych wejściowych, a nie z rozmiarem zmiany. Jedna mała aktualizacja stanu może zmusić narzędzie porównawcze do przejścia przez całą kolekcję, aby udowodnić, że większość jej elementów się nie zmieniła.
Struktura również nie zawsze stanowi bezpieczną podstawę do porównań. Funkcje, daty, mapy, instancje klas oraz obiekty biblioteczne mogą mieć zachowanie lub tożsamość, której ogólna funkcja głębokiego równania nie jest w stanie zinterpretować, więc nawet porównanie wydające się kompleksowe może zachować przestarzałe zachowanie. Zespół porzucił prosty model odniesień React na rzecz dostosowanego systemu semantycznego równania, nie określając nigdy, co oznacza równość dla każdej wartości z danej dziedziny.
Skuteczniejszym rozwiązaniem rzadko było intensywniejsze porównywanie niestabilnych obiektów. Chodziło raczej o naturalne zachowanie ich tożsamości, gdy leżące u ich podstaw dane się nie zmieniły, o zawężenie umowy propów lub o przeniesienie aktualizacji bliżej komponentu, który je posiadał.
Memoizacja wartości
Następnie przyszły wartości. Zmodyfikowane tablice, obiekty konfiguracyjne, sformatowane etykiety, tablice referencyjne oraz proste obliczenia otrzymywały funkcję useMemo, gdy komponenty, które je otaczały, zostały już zaimplementowane z użyciem tej funkcji.
Niektóre z nich rzeczywiście tego wymagały. Sortowanie dużej kolekcji lub budowa skomplikowanego modelu może uzasadniać użycie pamięci podręcznej, jeśli dane wejściowe zmieniają się rzadziej niż komponent nadrzędny jest renderowany. Wiele innych przypadków tak nie było. Łączenie dwóch ciągów znaków lub tworzenie małego obiektu jest mniej kosztowne niż cała struktura otaczająca je – komponent nadal tworzy tablicę zależności, React nadal porównuje każdą pozycję z poprzednią, poprzedni wynik nadal zajmuje pamięć, a programiści nadal muszą sprawdzić, czy lista jest kompletna.
Kod stał się również bardziej złożony pod względem wizualnym. Wartość, która wcześniej mogła być wyrażona w jednym wyrażeniu, teraz była otoczona Hookiem oraz tablicą zależności.
const label = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
W trakcie działania aplikacji cache dodawał praktycznie nic, a to oznaczało, że obliczenia nie były na tyle ważne, by je optymalizować. Co gorsza, stosowanie useMemo wszędzie utrudniało zrozumienie przepływu danych. Programiści utrzymywali stabilność referencji do obiektów nie dlatego, że ich tworzenie było kosztowne, ale dlatego, że inne komponenty z zastosowaną memoizacją polegały na ich identyfikatorach. Każda optymalizacja wymagała kolejnej, co prowadziło do powstania drzewa cache, które funkcjonowało tylko dopóki wszystkie połączenia były nienaruszone.
Memoizacja przynosi największe korzyści wtedy, gdy chroni kosztowne operacje. W tym przypadku chodziło o zachowanie referencji potrzebnych do innej memoizacji, co jest zupełnie innym celem.
Callbacki i przestarzałe zamknięcia
Callbacki tworzone podczas renderowania otrzymują nowy identyfikator za każdym razem. Przekazywane do komponentów z zastosowaną memoizacją, te nowe referencje powodują, że porównania w tych komponentach zawodzą. Rozwiązaniem było otaczanie niemal każdego obsługiwanego zdarzenia funkcją useCallback.
To zmniejszyło pewien poziom zmienności referencji, ale doprowadziło do dłuższych list zależności. Obsługa zapisu polegała na użyciu wersji roboczej, wybranego rekordu, bieżącego użytkownika, uprawnień, usługi powiadamiania oraz funkcji mutacji, a każda zmiana w którymkolwiek z tych elementów i tak powodowała powstanie nowego funkcji callback. Funkcja ta rzadko pozostawała na tyle stabilna, by mogła być ponownie wykorzystana. Dziecko renderowało się bez względu na wszystko, a rodzic przechowywał dodatkowy Hook oraz kolejny kontrakt zależności.
Rozwijający czasami usuwają zależności, aby utrzymać stabilność funkcji zwrotnych, co powoduje powstanie przestarzałych zamknięć. Funkcja zwrotna nadal czyta stary projekt, przestarzałą wartość uprawnień lub wcześniej wybrany rekord, ponieważ zachowanie tożsamości stało się ważniejsze niż zachowanie aktualnego zachowania programu. Taki kod może przejść testy na początkowym szlaku interakcji i zawieść się dopiero po zmianie stanu, bez ponownego utworzenia funkcji zwrotnej. Te błędy są trudne do wykrycia, ponieważ funkcja istnieje i działa bez błędów – po prostu działa na podstawie historii. Tożsamość funkcji zwrotnych była optymalizowana kosztem świeżości danych.
Lepiej postawić pytanie, czy dziecko w ogóle potrzebuje stabilnego callbacka. W przypadku taniego dziecka, które nie jest zapisywane w pamięci, useCallback niewiele daje. Jeśli callback służy do obsługi subskrypcji lub drogiego, zapisanego w pamięci obiektu, stabilność ma znaczenie, a jego zależności można często zmniejszyć poprzez zmianę właściciela, przekazanie węższej operacji lub umieszczenie tego zachowania bliżej stanu, który odczytuje. Funkcja o stałej tożsamości nie jest z natury szybsza – pomaga tylko wtedy, gdy jest ponownie wykorzystywana w sensowny sposób.
Cache’y zużywają pamięć
Cache’y zapobiegają wielokrotnym obliczeniom poprzez przechowywanie wcześniejszych wyników, a to przechowywanie wiąże się z określonym kosztem. Mała wartość zapisana w pamięci rzadko stanowi samodzielny problem związany z pamięcią. W dużym aplikacji jednak wiele komponentów może przechowywać tablice, tablice mapowe, przetworzone dane, sformatowane modele oraz funkcje zamknięte, które przechowują dodatkowe informacje.
To jest część powodu, dla którego pojedynczy widok profilera nie może opisać wydajności. Obliczenie zapisane w pamięci podręcznej może zniknąć z harmonogramu renderowania, choć później przyczynia się do wzrostu zużycia pamięci i dłuższych przerw. To, czy jest to opłacalne, zależy od wielkości wyniku, kosztów ponownego utworzenia, częstotliwości ponownego użycia oraz od czasu, przez jaki komponent pozostaje włączony. Duży model pochodny zapisany w pamięci podręcznej dla rzadko otwieranego panelu może kosztować więcej niż jego ponowne utworzenie w momencie potrzeby. Pamięci podręczne nie są darmowym magazynem dla starych danych. Zastępują one obliczenia przechowywanym stanem oraz dodatkową logiką unieważniania danych.
Prawdziwym problemem było to, gdzie znajdował się stan
Głównym ustaleniem było to, że ponowne renderowanie odzwierciedlało miejsce przechowywania stanu. Ciągle zmieniający się filtr znajdował się w komponencie strony, który renderował również wykresy, podsumowania, dużą tabelę oraz kilka paneli. Każde naciśnięcie klawisza powodowało ponowne renderowanie strony oraz ponowne utworzenie właściwości dla każdego potomnego elementu.
Ochrona dzieci za pomocą mechanizmu memoizacji polegała na stabilizowaniu ich tablic, obiektów oraz funkcji zwrotnych, a także dostosowanych selektorów i porównań, przy czym architektura wciąż opierała się na jednej głównej granicy aktualizacji. Przeniesienie stanu filtru obok tabeli usunęło większość problemów, a reszta strony nie musiała już się bronić.
W innych miejscach działo się to samo. Stan formularza dialogowego znajdował się w komponencie ekranu, stan nadpisywania występował nad całym zbiorem kart, jeden kontekst przechowywał wartości o zupełnie różnych szybkościach aktualizacji, a selektor globalnego magazynu zwracał więcej danych, niż potrzebował jego użytkownik. Memoizacja jedynie maskowała decyzje dotyczące właścicieli danych, które sprawiały, że małe zmiany rozprzestrzeniały się zbyt daleko.
Gdy te ograniczenia zostały zaostrzone, wiele warstw memoizacji stało się bezużytecznych. Komponenty poza ścieżką aktualizacji przestały się renderować, ponieważ stan nie znajdował się już nad nimi, a dziecięce komponenty miały prostsze umowy, gdyż rodzic nie kontrolował ich już w drobnych szczegółach.
Mniej renderowań, ta sama obciążenie przeglądarki
Nawet tam, gdzie memoizacja pomagała uniknąć renderowań w React, interakcje czasami pozostawały powolne, ponieważ React nie był głównym czynnikiem obciążenia.
Biblioteka do tworzenia wykresów nadal wykonywała kosztowne aktualizacje imperatywne. Duży drzewo DOM nadal wymagało przeliczenia układu po zmianie wymiarów. Jeden efekt CSS zmuszał do szerokich ponownych renderowań, synchroniczna operacja zapisu do pamięci blokowała wątek główny, a obsługa wprowadzania tekstu analizowała duży dokument, zanim mógł pojawić się kolejny znak. Komponenty przyciągały uwagę tylko dlatego, że to one były mierzone przez narzędzie profilowania, a w miarę jak renderowanie stawało się tańsze, obciążenia przeglądarki stały się widoczne.
Wydajność Reacta to tylko jeden aspekt wydajności interfejsu użytkownika, a nie jej pełny zamiennik. Nawet przy zastosowaniu memoizacji, zmiana zależności Effect może nadal sprawić, że widget od third-party zostanie przeprojektowany. Pominęcie renderowania elementu nadrzędnego nie skraca czasu dekodowania zbyt dużego obrazu. Stabilny callback nie rozwiąże problemu niestabilnego układu elementów spowodowanego odczytywaniem i zapisywaniem pomiarów elementów w niewłaściwej kolejności. Konieczne jest prześledzenie całego procesu – od JavaScripta, przez renderowanie w React, mutacje DOM, obliczanie stylów, układ elementów, proces malowania aż po sieć, ponieważ przyspieszenie jednego etapu może nie wpłynąć na pozostałe części. React milczał, podczas gdy przeglądarka była zajęta.
Kod trudniejszy do modyfikacji
Koszt wydajnościowy był zaledwie połową problemu – aplikacja stała się również trudniejsza w utrzymaniu.
Każda granica komponentu rodziła nowe pytania. Czy komponent jest zapisywany w pamięci? Które atrybuty wymagają stabilnych referencji? Czy domowy komparator obejmuje nowy atrybut? Czy jakaś wartość została celowo pominięta z listy zależności? Czy zmiana struktury jednego obiektu unieważni kilka buforów? Codzienne prace nad funkcjonalnościami wymagały zrozumienia zasad optymalizacji, które nie miały nic wspólnego z zachowaniem produktu.
Wyobraźmy sobie programistę, który dodaje pole do obiektu konfiguracji i przypadkowo powoduje szeroko zakrojone ponowne renderowanie. Kolega „naprawia” to poprzez dokładniejsze porównanie. Później ktoś dodaje funkcję do obiektu i odkrywa, że komparator traktuje dwie wyraźnie różne konfiguracje jako równe. Kod był dostosowany do swojej obecnej struktury, a każda modyfikacja zakłócała delikatnie zrównoważoną sieć referencji i tablic zależności.
Dobra architektura wydajności sprawia, że częste zmiany są tańsze i nie zmuszają każdej zmiany do przestrzegania ukrytych założeń dotyczących pamięci podręcznej. To nie oznacza, że optymalizowany kod musi pozostać prosty. Niektóre kosztowne interakcje rzeczywiście wymagają specjalistycznej mechanizmu memoizacji oraz starannego zarządzania referencjami. Złożoność powinna być proporcjonalna do uzyskanych korzyści i skoncentrowana wokół znanych punktów o najwyższej aktywności, podczas gdy tutaj była rozproszona wszędzie, w oparciu o założenie, że każda renderizacja może kiedyś stać się takim punktem.
Pomiar interakcji zmienił decyzję
Punktem zwrotnym było profilowanie pełnych interakcji użytkowników zamiast liczenia renderizacji.
Zarejestrowano operacje, które użytkownicy określili jako wolne, i przeanalizowano, gdzie traci się czas. Kilka komponentów renderowało się często, ale praktycznie nie wykorzystywało zasobów. Dominowało kilka obliczeń oraz elementy od stron trzecich, a niestandardowe porównania rejestrowano jako rzeczywistą pracę JavaScripta. W niektórych przypadkach usunięcie mechanizmu memoizacji powodowało nieco więcej renderowań, ale krótszy czas całkowity.
Początkowo wydawało się to błędne, ponieważ przyjęty model mentalny traktował mniejszą liczbę renderowań jako postęp. Przeglądarce nie zależy na tym, ile razy wywoływane są funkcje komponentów – liczy się ilość pracy wykonywanej przed następnym responsywnym kadrem.
Od tamtej pory zmiany oceniano na podstawie całkowitego kosztu interakcji. Czy dane wejściowe były przetwarzane szybciej? Czy główny wątek pozostawał wolny? Czy pamięć utrzymywała stabilność podczas długich sesji? Czy wersje produkcyjne ulegały poprawie, a nie tylko wersje rozwojowe? Naprawy stały się również bardziej lokalne: jedna kosztowna transformacja, jeden duży poddrzewo lub jedna niestabilna konfiguracja biblioteki mogła być rozwiązana bezpośrednio, podczas gdy reszta aplikacji mogła rezygnować z mechanizmów ochronnych typu memoizacja. Profilowanie nie doprowadziło do przebudowy aplikacji – po prostu zapobiegło temu, by zespół gonił za widoczną liczbą, która nie miała wpływu na doświadczenie użytkownika.
Co pozostało
Nie usunięto wszystkich funkcji memo, useMemo i useCallback, ponieważ to oznaczałoby powtórzenie tego samego błędu, tylko w odwrotnej kolejności.
Memoizacja była stosowana tylko w przypadku drogich komponentów, których właściwości rzadko się zmieniały. Transformacje, które okazały się kosztowne i były wykorzystywane przy częstym renderowaniu rodziców, pozostawały w pamięci cache, a funkcje zwrotne zachowywały stabilność tam, gdzie ich tożsamość miała znaczenie dla subskrypcji, integracji z zewnętrznymi serwisami lub istotnych granic memoizacji.
To, co zniknęło: cache do prostych obliczeń, niestandardowe funkcje porównawcze wykonujące szerokie sprawdzenia strukturalne oraz niechęć do renderowania tanich komponentów. Stany często zmieniające się przeniesiono bliżej interfejsów, które je wykorzystywały, a wartości kontekstowe o niewielu wspólnych cechach zostały rozdzielone. Rezultatem nie był najniższy możliwy liczbę renderowań – był to model aktualizacji łatwiejszy do zrozumienia, z optymalizacjami stosowanymi tam, gdzie istniały na to podstawy. Dzięki temu aplikacja była szybsza i łatwiejsza w modyfikacji.
Renderowanie nie jest wrogiem
Ta historia zaczęła się od rozsądnego instynktu: zobaczyć, co się dzieje, i spróbować to zatrzymać. Błędem było traktowanie każdego renderowania jako marnotrawstwa, a każdego pominętego renderowania jako sukcesu.
Memoizacja porównuje dane wejściowe, przechowuje wartości, śledzi zależności i musi pozostać poprawna wraz ze zmianami w kodzie. Dobór własnej logiki równości może kosztować więcej niż samo renderowanie. Stabilne funkcje zwrotne mogą zamrażać przestarzały stan. Wyniki z pamięci podręcznej zajmują pamięć. A gmatwanina optymalizacji może utrudnić modyfikację drzewa komponentów bez żadnej korzyści, której zauważyliby użytkownicy.
Renderowanie w React jest często tanie. Słaba kontrola nad stanem, powtarzające się kosztowne obliczenia, szerokie subskrypcje, aktualizacje od stron trzecich oraz układ przeglądarki mają często znacznie większe znaczenie. Najskuteczniejszym rozwiązaniem było pozostawienie tanich operacji takimi, jakie są: należy przestać otaczać zwykłe komponenty buforami pamięci i skupić się na nielicznych naprawdę kosztownych operacjach. Optymalizacja powinna obniżyć całkowity koszt interakcji, a nie tylko liczbę razy, gdy React wywołuje funkcję. Szybkość pojawiła się w momencie, gdy pytanie przesunęło się z tego, jak powstrzymać renderowanie w React, na to, dlaczego każda aktualizacja stała się tak kosztowna.
Literatura pokrewna
- Jak przechowywanie w przeglądarce cichо niszczy dane użytkowników i jak temu zapobiec — Utracone aktualizacje, przestarzałe zrzuty stanu, zmiany schematu oraz wycieki danych z urządzeń współdzielonych mogą uszkodzić dane z localStorage bez żadnego błędu. Wzorce dotyczące zarządzania własnością, wersjonowania i walidacji.
- Zrzuty pamięci agenta, umiejętności, degradacja i zakres pamięci w gałęziach — Cztery ulepszenia warstwy pamięci typu vector-DB przeznaczonej dla agentów programistycznych: przenośne zrzuty stanu z możliwością łączenia, aktywacja umiejętności, degradacja z kontrolą konfliktów oraz pamięć ograniczona do konkretnych gałęzi.