Strona główna / Artykuły / Najpierw pomiar: dlaczego wczesna optymalizacja utrudnia uruchamianie aplikacji Next.js

Najpierw pomiar: dlaczego wczesna optymalizacja utrudnia uruchamianie aplikacji Next.js

Zobacz, jak przedwczesna memoizacja, szerokie granice klienta oraz warstwowe pamięci cache zwiększają złożoność aplikacji Next.js, oraz jak podejście oparte na pomiarach zapewnia im szybkość.

2401 słów

Strona w Next.js ładowa się w 1,2 sekundy, ktoś uznaje to za zbyt wolne i rozpoczyna serię działań optymalizacyjnych, zanim ktokolwiek przejrzy profil aplikacji. Kilka miesięcy później baza kodu zawiera wszędzie mechanizmy memoizacji, dynamiczne importy, których nikt nie sprawdził pod kątem wydajności, kilka nakładających się buforów pamięci oraz niestandardowe ścieżki renderowania, a aplikacja staje się trudniejsza do zrozumienia niż kiedykolwiek przed wprowadzeniem tych zmian. Ten przewodnik wyjaśnia, dlaczego ten wzorzec jest tak powszechny, jakie konkretne nawyki go powodują oraz jak zastąpić je procesem pracy opartym na dowodach i dodawaniem złożoności tylko wtedy, gdy liczby na to pozwalają.

Prawdziwy błąd: złożoność przed dowodami

Każda pojedyncza optymalizacja zwykle wydaje się rozsądna podczas przeglądu kodu. useMemo w jednym miejscu, cache w innym, oddzielny plik dla komponentu, który wydawał się ciężki. Problem polega na tym, że efekty są kumulatywne – każdy mechanizm dodaje nowe zachowania związane z cache’owaniem do zrozumienia, kolejną ścieżkę renderowania do debugowania, jeszcze jedną granicę do rozważenia oraz więcej kodu specyficznego dla wydajności, który trzeba utrzymać w działaniu.

Zatem niebezpieczeństwem, przed którym należy się chronić, nie jest brak optymalizacji. Chodzi raczej o wprowadzanie różnych rozwiązań, zanim dowiemy się, co jest wolne, dlaczego jest wolne i czy naprawa tego zmieni cokolwiek, co zauważy użytkownik. Zanim zaczniesz dostosowywać proces wykonywania, upewnij się, że system jest na tyle obserwowalny, aby można go było zmierzyć.

Przeprowadź profilowanie, zanim cokolwiek zmienisz

Najczęstszą formą tego błędu jest reakcja na powszechnie przyjętą ideę, że wydajność ma znaczenie. Programista zaczyna edytować kod bez przeprowadzania analizy wydajności, bez zidentyfikowania wąskiego gardła oraz bez upewnienia się, że użytkownicy czekają konkretnie na coś.

Symptomy są znajome:

  • useMemo i useCallback stosowane przy prostych obliczeniach
  • dodawanie warstw cacheowania do danych, których pobieranie nigdy nie było kosztowne
  • działanie polegające na dzieleniu kodu zanim sprawdzono, które jego fragmenty są duże
  • abstrakcje budowane w oparciu o hipotetyczne przyszłe obciążenia
  • logika renderowania, która rozszerza warunki, by uniknąć renderowania elementów, których nikt nie mierzył

Często oryginalny problem w ogóle nie istniał. Prawdziwa praca nad wydajnością zaczyna się od danych: czasu ładowania strony, analizy plików, nagrania profilera React, wykresu aktywności sieciowej oraz jasnego obrazu tego, gdzie faktycznie czekają użytkownicy. Optymalizacja powinna odpowiadać na zaobserwowane zachowanie, a nie na nieokreślone obawy co do tego, że coś może kiedyś stać się wolne.

Praktyczną zasadą jest zapisanie metryki, którą zamierzasz poprawić, oraz jej obecnej wartości przed edycją kodu. Jeśli nie potrafisz podać tej liczby, to nie jesteś gotowy na zmianę implementacji.

Zazwyczaj problemem jest po prostu zbyt dużo JavaScript

Wiele przypadków wolnego działania interfejsu nie ma tajemniczej przyczyny. Przeglądarce prosi się o pobranie, zinterpretowanie, skompilowanie i wykonanie większej ilości skryptów, niż potrzebuje strona, a na telefonie średniej klasy każdy z tych kroków jest kosztowny.

Niepozorna konsola może po cichu gromadzić kilka bibliotek animacji, duży zestaw komponentów, ciężki menedżer stanu, pakiety do tworzenia wykresów, zbiory narzędzi oraz logikę po stronie klienta dla funkcji, które mogłyby pozostać na serwerze. Żadna z tych elementów sama w sobie nie wygląda alarmująco. Razem jednak powodują ogromną ilość pracy, zanim strona będzie w stanie sprawnie zareagować na wprowadzone dane. W tym momencie zespoły często zaczynają dążyć do lepszych wyników w Lighthouse, jakby naprawa wymagała genialnej strategii.

Next.js oferuje już solidne domyślne ustawienia: renderowanie na serwerze, automatyczne dzielenie kodu według tras oraz model oparty na React Server Components. Te domyślne ustawienia tracą wiele ze swojej wartości, gdy duże części aplikacji i tak są wysyłane do przeglądarki. Dlatego zanim sięgniesz po inną technikę, sprawdź, ile kodu wysyłasz, które zależności dominują na każdej trasie oraz czy każda z nich jest warta swojej masy. Wiele aplikacji nie potrzebuje bardziej zaawansowanej optymalizacji – potrzebują mniej JavaScriptu. Aby dowiedzieć się więcej na temat tego, co oprócz samej wielkości pliku może spowalniać stronę, przeczytaj co faktycznie powoduje spowolnienie aplikacji internetowej.

Grenice klienta, które przesuwają się w górę

Naj szybszym sposobem na utratę zalet architektonicznych App Routera jest oznaczanie zbyt wielu elementów jako kodu klienta. Ktoś potrzebuje interaktywności głęboko w strukturze drzewa, umieszcza "use client" na elementie nadrzędnym, potem na jeszcze bardziej nadrzędnym, i wkrótce komponenty, które wyświetlają jedynie statyczny markup, odczytują dane serwerowe lub składają układ, stają się częścią paczki klienta tylko dlatego, że znajdują się poniżej tej instrukcji.

Ta zmiana wpływa nie tylko na to, gdzie wykonywany jest kod. Zazwyczaj oznacza to:

  • więcej JavaScriptu wysyłanego do przeglądarki
  • więcej pracy związanej z „hydratacją” przed tym, jak strona stanie się interaktywna
  • dodatkowy stan po stronie klienta do zarządzania
  • więcej miejsc, gdzie dane serwerowe i klienta mogą stracić synchronizację

Istnieje tu pewna ironia. Wiele zespołów przechodzi na nowoczesny Next.js właśnie po to, by uzyskać architekturę opartą na serwerze, a następnie stopniowo odbudowywać aplikacje jednostronicowe o dużej liczbie komponentów klienckich, których próbowali uniknąć.

Ciekawe pytanie podczas projektowania brzmi: jaka jest najmniejsza część tej interfejsu, która rzeczywiście wymaga przeglądarki? Przycisk „polubić”, menu rozwijane lub pole formularza mogą wymagać stanu klienckiego; natomiast karta, lista i strona wokół nich zazwyczaj nie. Utrzymywanie ścisłych granic oraz przekazywanie treści wygenerowanych na serwerze do komponentów klienckich jako ich elementów dziecięcych, gdzie to możliwe, pozwala serwerowi wykonywać więcej zadań, przy jednoczesnym zachowaniu skupienia na interaktywnych elementach. Długoterminową korzyścią są mniejsze pliki pakietowe oraz prostszy model myślowy. Mechanizmy stojące za tym omówiono w artykule o tym, jak React Server Components chroni kod przed umieszczaniem go w plikach pakietowych.

Jak przedwczesna optymalizacja czyni architekturę kruchą

Prace nad wydajnością zamieniają się w problem konserwacyjny, gdy optymalizacje pojawiają się szybciej, niż ktokolwiek może udowodnić, że przynoszą korzyść. Zwykle zaczyna się to od małych kroków: komponent zostaje zapamiętany, pojawia się ręcznie stworzona pamięć cache, dodawany jest hook w celu uniknięcia renderowania, a potem pojawia się kolejna warstwa mająca na celu utrzymanie spójności dwóch stanów. W tym momencie nic nie wydaje się ryzykowne.

Miesiące później baza kodu zawiera własne hooki, których interakcje są trudne do prześledzenia, reguły unieważniania zrozumiałe tylko dla kilku osób, łańcuchy wartości zapamiętanych, warunki renderowania oparte na założeniach, które już nie są prawdziwe, oraz kod synchronizacyjny istniejący głównie dlatego, że wymagała go wcześniejsza optymalizacja.

To ma rzeczywisty koszt. Proces wdrażania trwa dłużej, debugowanie wymaga więcej kontekstu, a małe zmiany w funkcjach ostatecznie wpływają na mechanizmy dodane pierwotnie dla przyspieszenia. Prawidłowe pytanie dotyczące każdej optymalizacji brzmi nie to, czy poprawia ona wyniki testów wydajnościowych, lecz czy ta poprawa jest na tyle duża, by pokryć koszty architektoniczne, które się z nią wiążą. Celem jest trwała wydajność: aplikacja, która reaguje szybko, przy czym jej codzienny kod pozostaje prosty i czytelny, a nie pełen sztuczek przyspieszających.

Wrażenie wydajności to problem UX

Inżynierowie skupiają się na tym, co można dokładnie zmierzyć, takim jak milisekundy, rozmiary plików, liczba renderowań i wyniki. Użytkownicy doświadczają czegoś szerszego. Skrócenie czasu ładowania strony o 100 ms niewiele daje, jeśli nawigacja jest myląca, stany ładowania nie dostarczają żadnych informacji, elementy sterujące wydają się nieruchome, układ zmienia się w momencie pojawienia się treści lub ważna akcja nie daje żadnego sygnału, że została zarejestrowana.

Weźmy formularz, którego wysłanie zajmuje dwa sekundy. Skrócenie czasu przetwarzania na poziomie 1,7 sekundy to rzeczywisty postęp techniczny. Natychmiastowe informacje zwrotne, wyłączenie przycisku, aby zapobiec podwójnemu wysłaniu, oraz wyraźny wskaźnik postępu często znacznie poprawiają doświadczenie użytkownika, mimo że czas obsługi żądania pozostaje taki sam jak wcześniej.

To jest różnica pomiędzy mierzonym a odczuwanym wydajnością. Ludzie oceniają interfejs pod kątem tego, czy reaguje na ich intencje, czy rozumieją, co się dzieje, oraz czy wydaje im się stabilny podczas korzystania z niego. Dlatego dobre rozwiązania dotyczące wydajności frontendu opierają się zarówno na projektowaniu interakcji, jak i na mechanizmach renderowania. Narzędzia takie jak przejścia w React, optymistyczne aktualizacje i stany szkieletowe należą do tej samej grupy narzędzi co analiza plików bundle.

Kasowanie z pamięci tymczasowej pomaga, dopóki nikt nie potrafi tego wyjaśnić

Kasowanie z pamięci tymczasowej może przynieść duże korzyści, ponieważ system przestaje powtarzać kosztowne operacje. Trudności pojawiają się wtedy, gdy zespół nie jest już w stanie określić, jaką wersję danych powinien widzieć dany użytkownik.

Klasyczny scenariusz: strona staje się szybka, a potem pojawiają się przestarzałe dane. Zapis zostaje zmodyfikowany i jedna strona się aktualizuje, podczas gdy inna nadal pokazuje stary wartość. W środowisku rozwojowym wszystko działa poprawnie, natomiast w produkcji nie – a dochodzenie przeradza się w pytania o to, który cache dostarczył odpowiedź, która warstwa została unieważniona i jaki żądanie wygenerowało ten wynik.

Zaawansowane aplikacje Next.js ułatwiają to szczególnie, ponieważ ponowne wykorzystanie może odbywać się na wielu poziomach: we własnym kodzie, w buforowaniu danych i tras frameworka, w pojedynczych wywołaniach fetch, za pośrednictwem CDN, w przeglądarce oraz usługach backendowych. Dodawanie kolejnego warstwy bez zrozumienia sposobu ich wzajemnej interakcji może zmniejszyć opóźnienia, ale jednocześnie zwiększyć liczbę stanów, w których może się znajdować system. Należy pamiętać, że domyślne ustawienia buforowania w Next.js zmieniały się w poszczególnych głównych wersjach, dlatego należy sprawdzić zachowanie aktualnej wersji w najnowszej dokumentacji, zamiast polegać na starszych przewodnikach.

Obserwowalność powinna być priorytetem przed agresywnym buforowaniem. W przypadku każdej odpowiedzi zbuforowanej zespół powinien być w stanie odpowiedzieć na następujące pytania:

  • skąd pochodzi odpowiedź
  • jak długo ma pozostać ważna
  • co ją unieważnia
  • co się dzieje, gdy unieważnienie nie udaje się

Bufor, który przyspiesza system, ale sprawia, że zachowanie w warunkach produkcyjnych jest nieprzewidywalne, nie stanowi bezwarunkowej korzyści.

Zwolnienie może wcale nie wynikać z Reacta

Czasami opóźnienie jest widoczne dopiero w warstwie frontendu, a nie tam, gdzie się ono zaczyna. Gdy interfejs wymaga kilku sekund, zanim będzie mógł być używany, zespół może zacząć optymalizować procesy renderowania, stosować mechanizmy memoizacji komponentów lub przebudowywać stan klienta. Takie zmiany mogą zaoszczędzić kilka milisekund pracy przeglądarki, podczas gdy strona wciąż czeka na zapytanie do bazy danych trwające dwa sekundy lub na endpoint, który zwraca znacznie więcej danych, niż potrzebuje interfejs.

Załóżmy sobie cykl życia tej żądania: przeglądarka je wysyła, przechodzi przez middleware i mechanizmy autoryzacji, serwer wywołuje backend lub bazę danych, dane powracają z powrotem, a dopiero wtedy React je renderuje. Jeśli większość czasu upływa przed otrzymaniem odpowiedzi, niewiele pomoże nieco przyspieszenie ostatniego kroku.

To samo dotyczy zbyt dużych obciążeń danych, sekwencyjnych połączeń sieciowych, które można wykonywać równolegle, kosztownych sprawdzeń autoryzacji, przeciążonych usług oraz zapytań bez indeksowania. Żadne z tych problemów nie zostanie rozwiązane poprzez rzadsze renderowanie komponentów. Skuteczne badanie obejmuje całe żądanie i sprawdza, gdzie traci się czas. Wąskie gardła nie uwzględniają granic zespołów ani tego, kto jest właścicielem kodu.

Proste systemy dłużej pozostają szybkie

Wiele szybkich aplikacji w środku nie różni się niczym szczególnym. Przesyłają stosunkowo małe pliki, wyznaczają jasne granice renderowania, utrzymują minimalny stan klienta, pobierają dane w przewidywalny sposób i mają architekturę, której nowy programista może się trzymać bez konieczności analizy złożonych mechanizmów.

Taka prostota przynosi korzyści w miarę rozwoju aplikacji. Gdy przepływ danych jest oczywisty, łatwo zauważyć kosztowne operacje. Gdy granice klienta są wąskie, jasno wiadomo, za co odpowiada przeglądarka. Gdy zasady cache’owania są niewiele i jasno określone, problemy w produkcji łatwiej zdiagnozować.

Mądra optymalizacja jest atrakcyjna po części dlatego, że pokazuje umiejętności techniczne, ale każdy mechanizm staje się czymś, co przyszli deweloperzy muszą zrozumieć, naprawić, zachować lub ostatecznie usunąć. Nic z tego nie przemawia przeciwko optymalizacji – wręcz przeciwnie, należy stosować najprostsze rozwiązanie spełniające rzeczywiste wymagania wydajnościowe, dodając złożoność tylko wtedy, gdy pomiary pokazują, że prosty projekt już nie ma marginesu manewru. System nieco mniej pomysłowy, ale o wiele łatwiejszy do zrozumienia, zazwyczaj lepiej się sprawdza z biegiem czasu.

Traktuj wyniki jako sygnały, a nie cele

Narzędzia do testów wydajności są cenne, ponieważ umożliwiają sprawdzanie niewidocznych cech produktu. Problem pojawia się wtedy, gdy podnoszenie wyników staje się ważniejsze niż udoskonalanie samego produktu.

Dobry wynik w teście Lighthouse nie gwarantuje dobrego projektu interakcji, utrzymywalnej architektury, niezawodnego funkcjonowania w produkcji ani szybkiego ukończenia zadań, które są ważne dla użytkowników. Pomiary w laboratorium przeprowadzane są przy kontrolowanych założeniach; prawdziwi użytkownicy korzystają z różnych urządzeń, sieci, ilości danych, stanów autoryzacji oraz ścieżek nawigacyjnych. Dlatego właśnie dane ze środowiska rzeczywistego, takie jak Core Web Vitals zebrane podczas prawdziwych sesji, stanowią przydatne uzupełnienie.

To nie czyni metryk laboratoryjnych nieważnymi; zmienia to jedynie sposób ich wykorzystania:

  • Jeśli jakaś metryka wskazuje na rzeczywisty problem, należy go zbadać.
  • Jeśli jakaś zmiana podnosi wynik, ale dodaje znaczną złożoność, a użytkownicy prawie niczego nie zauważają, należy zakwestionować ten kompromis.
  • Każdą metrykę należy powiązać z zachowaniem widocznym dla użytkownika, które można opisać.

Celem nie jest aplikacja, która świetnie wygląda w testach wydajnościowych. Chodzi o aplikację, która umożliwia ludziom wykonywanie pracy bez niepotrzebnych opóźnień i przeszkód.

Proces pracy oparty na pomiarach

Łącząc te koncepcje, zrównoważony cykl wygląda następująco:

  1. Nazwij problem dotyczący użytkownika oraz wskaźnik, który go reprezentuje.
  2. Pomierz aktualną wartość zarówno w warunkach laboratoryjnych, jak i, jeśli to możliwe, w warunkach rzeczywistych.
  3. Prześledź całą ścieżkę żądania i renderowania, aby zlokalizować główny koszt.
  4. Spróbuj najpierw wprowadzić zmiany, które eliminują nadmiar pracy: mniej zależności, węższe granice klienta, mniejszy obciążenie danych, szybsze zapytania.
  5. Zastosuj memoizację, dodatkowe cacheowanie lub niestandardowy rendering tylko wtedy, gdy samo usunięcie elementów nie wystarczy.
  6. Pomierz ponownie i zachowaj zmianę tylko wtedy, gdy korzyść przewyższa koszt jej utrzymania.

Granica klienta na liściu, nie na stronie

'use client' na stronie wciąga pobieranie danych do bundla przeglądarki. Strona zostaje komponentem serwerowym.

// app/dashboard/page.tsx — a Server Component, no 'use client'
import { Chart } from './chart';

export default async function Dashboard() {
  const points = await loadSeries();
  return <Chart points={points} />;
}

Memoizuj liść dopiero, gdy profil pokaże, że kosztowne jest rysowanie.

'use client';

export const Chart = ({ points }: { points: number[] }) => {
  return <svg data-count={points.length} />;
};

Główne wnioski

  • Najdroższym błędem dotyczącym wydajności w Next.js jest optymalizacja przed zrozumieniem, co robi system, a nie zapomnienie o jej przeprowadzeniu.
  • Największe szkody dla utrzymywalności pochodzą od gromadzącej się złożoności: nadmiaru kodu po stronie klienta, warstwowych buforów pamięci, uniknionych możliwości hydratacji oraz spekulatywnych abstrakcji.
  • Zwykle lepiej jest usunąć niepotrzebne elementy niż dodawać nowe mechanizmy – czy to poprzez wysyłanie mniej JavaScriptu, przechowywanie komponentów na serwerze, uproszczenie stanu, eliminację zbędnych żądań, naprawę wolnego wywołania backendu lub usunięcie optymalizacji, która kosztuje więcej, niż oszczędza.
  • Naj szybszą aplikacją jest często ta, która wykonywa najmniej niepotrzebnych działań.

Literatura pokrewna