Poza rozmiarem paczki: odkrywanie tego, co naprawdę spowalnia twoją aplikację internetową
Dlaczego usuwanie kilobajtów rzadko naprawia wolną aplikację oraz jak śledzić rzeczywisty czas oczekiwania w serwerach, strukturach typu waterfall, procesach hydratacji, skryptach i obrazach od dostawców zewnętrznych.
W zespołach zajmujących się frontendem często powtarza się ten sam scenariusz: tygodniami próbuje się skrócić rozmiar pliku JavaScript o 40 KB, podczas gdy zapytanie do bazy danych trwające 900 milisekund pozostaje nietknięte na kluczowej ścieżce żądania. Zmienia się bibliotekę ikon, zastępuje się zależność, konfiguruje inny plugin do pakowania plików, a potem toczy się debata na temat tego, czy dany pakiet waży 18 KB, czy 12 KB. Następnie ktoś uruchamia aplikację na prawdziwym telefonie przez rzeczywistą sieć, a i tak wydaje się wolna.
W tym artykule wyjaśniono, dlaczego rozmiar pliku jest tak często niewłaściwym celem optymalizacji, skąd naprawdę pochodzi opóźnienie oraz jak uruchomić pętlę optymalizacji, która najpierw naprawi największe problemy. Mniejsze pliki rzeczywiście pomagają, czasem nawet bardzo. Ale jeśli strona jest wolna z powodu oczekiwania na odpowiedź od serwera, blokowania procesu renderowania, wykonywania niepotrzebnych działań, wysyłania zbyt wielu żądań lub ładowania ogromnego drzewa komponentów, dodatkowe 20 KB nie pomoże jej przyspieszyć.
Dlaczego rozmiar pliku jest standardowym celem wydajnościowym
Rozmiar pliku jest atrakcyjny, ponieważ jest to liczba. Twoje procesy budowania wyświetlają coś w tym stylu:
main.js 842 KB
vendor.js 611 KB
styles.css 94 KB
Ktoś proponuje, aby rozmiar pliku JavaScript nie przekraczał 500 KB, i nagle zespół ma konkretny cel. Można to wdrożyć w CI, śledzić w pull requestach i cieszyć się z każdego zmniejszenia rozmiaru. To sprawia wrażenie postępu inżynieryjnego, a czasami rzeczywiście nim jest.
Problemy pojawiają się, gdy liczba przestaje być jedynie objawem i staje się celem samym w sobie. Zespoły mają tendencję do optymalizacji tej części wydajności, którą mogą najwyraźniej zobaczyć, a nie tej, która kosztuje użytkowników najwięcej czasu. Aby zrozumieć, dlaczego to ma znaczenie, porównajmy dwa hipotetyczne aplikacje.
Aplikacja A: mały plik, wolne wszystko inne
Pierwsza aplikacja zawiera niewielki plik:
JavaScript: 250 KB
Jednak wszystko wokół niej jest kosztowne:
Server response: 1.2s
Database query: 700ms
API calls before rendering: 5
Main-thread work: 900ms
Aplikacja B: duży plik, szybki dostęp do treści
Druga aplikacja zawiera prawie trzy razy więcej kodu JavaScript:
JavaScript: 700 KB
Jednak zarówno serwer, jak i klient wykonywają znacznie mniej operacji, zanim strona stanie się użyteczna:
Server response: 150ms
Database query: 40ms
API calls before rendering: 1
Main-thread work: 180ms
Aplikacja A nie zawsze wygrywa. Aplikacje typu B często sprawiają wrażenie szybszych, ponieważ spędzają mniej czasu na serwerze, w bazie danych, przy sekwencyjnych żądaniach i pracach na głównej wątku – co przy rozsądnej łączności znacznie przewyższa korzyści z większego rozmiaru pliku do pobrania. Zasada, na której powinno opierać się każde omawianie wydajności, jest prosta: użytkownicy nie dostrzegają kilobajtów, lecz czas oczekiwania.
Ładowanie strony to długi proces, a nie trzy kroki
wielu programistów ma w głowie uproszczony model ładowania:
Download JavaScript
↓
Execute JavaScript
↓
Page appears
Rzeczywiste żądanie przechodzi przez znacznie więcej etapów, z których każdy może spowodować opóźnienie:
DNS
↓
Connection
↓
TLS
↓
Request
↓
Server processing
↓
Database
↓
Response
↓
HTML parsing
↓
CSS processing
↓
JavaScript download
↓
JavaScript parsing
↓
JavaScript execution
↓
Hydration
↓
API requests
↓
Rendering
↓
Layout
↓
Paint
Zapytanie DNS, nawiązanie połączenia oraz negocjacje TLS odbywają się zanim serwer cokolwiek zobaczy. Następnie następuje przetwarzanie przez serwer i praca z bazą danych. Następnie przeglądarka analizuje HTML, przetwarza CSS, pobiera, analizuje i wykonywa JavaScript, aktualizuje zawartość strony, wysyła żądania API i dopiero wtedy renderuje, układa elementy i rysuje obraz. A gdy użytkownik kliknie, większość tych kroków powtarza się.
Z tak wieloma etapami plik bundle jest po prostu jednym z miejsc, gdzie czas może się ulotnić. „Zmniejszenie rozmiaru pliku bundle” to słaby początek, ponieważ zakłada rozwiązanie, zanim dowiemy się, gdzie traci się czas.
Serwer może być naj wolniejszą częścią twojego frontendu
Inżynierowie frontendu naturalnie traktują wydajność jako problem przeglądarki. Otwierają DevTools, sprawdzają zakładkę Network oraz fragmenty kodu JavaScript i uruchamiają Lighthouse. Jednak znaczna część postrzeganej szybkości frontendu jest określana jeszcze zanim przeglądarka otrzyma cokolwiek przydatnego.
Weźmy na przykład żądanie do panelu sterowania:
GET /dashboard
Za jego plecami serwer może wykonać to wszystko przed udzieleniem odpowiedzi:
→ authenticate user
→ fetch organization
→ fetch permissions
→ query projects
→ query project statistics
→ query notifications
→ calculate recommendations
→ render response
Jeśli ta sekwencja zajmuje 1,4 sekundy, zmniejszenie rozmiaru pliku z 600 KB na 500 KB ledwo wpływa na doświadczenie użytkownika, ponieważ pierwsze istotne dane nadal przychodzą po upływie 1,4 sekundy. Przeglądarka nie może wyświetlić danych, których jeszcze nie otrzymała.
Częstym winowajcą jest kod backendu, który oczekuje na kolejne niezależne operacje. Zaczyna się to od pobierania informacji o użytkowniku:
const user = await getUser();
i kontynuuje się łańcuchem dalszych operacji await dotyczących organizacji, projektów i powiadomień, przy czym każda z nich czeka, aż poprzednia się zakończy:
const organization = await getOrganization(user.orgId);const projects = await getProjects(organization.id);const notifications = await getNotifications(user.id);
Część tych kroków rzeczywiście jest od siebie zależna: wyszukiwanie organizacji wymaga user.orgId, a projekty potrzebują identyfikatora organizacji. Jednak wszystko, co nie zależy od wcześniejszego wyniku, bez powodu ponosi koszt opóźnienia w sekwencji. Gdy operacje są niezależne, ich jednoczesne uruchomienie i wspólne oczekiwanie może znacznie skrócić czas odpowiedzi:
const [user, notifications] = await Promise.all([
getUser(),
getNotifications()
]);
Zauważ, że wersja równoległa wywołuje getNotifications() bez identyfikatora użytkownika. Działa to tylko wtedy, gdy powiadomienia można uzyskać na podstawie czegoś już dostępnego, takiego jak sesja; w przeciwnym razie to wywołanie nadal musi czekać na użytkownika. Ogólną zasadą jest odwzorowanie rzeczywistego grafu zależności i równoczesne uruchamianie każdego jego poziomu. Aby dowiedzieć się więcej na temat wyboru między tymi wzorcami, zapoznaj się z naszym przewodnikiem na temat Promise.all, Promise.race i sekwencyjnych awaits. Taka zmiana może z łatwością przewyższyć wydajność jakiejkolwiek pracy związanej z pakietowaniem.
Metoda kaskadowa kosztuje więcej niż same bajty
Jednym z najskuteczniejszych miejsc na rozpoczęcie dochodzenia jest karta Sieć, a nie analizator pakietów. Bardzo powszechny wzorzec wygląda następująco:
HTML
↓
JavaScript
↓
API A
↓
API B
↓
API C
↓
API D
Każdy krok czeka na poprzedni, a każda „strzała” dodaje czas oczekiwania na przejazd tam i z powrotem. Porównaj to z projektem, w którym pierwsza odpowiedź już zawiera wszystko, czego potrzebuje strona:
HTML
↓
API response containing everything required
Druga wersja może przesyłać więcej bajtów i mimo to być znacznie szybsza. Bajty i opóźnienia to odrębne problemy. Odpowiedź o wielkości 100 KB, która przychodzi od razu, może przewyższyć wartością odpowiedź 20 KB, która wymaga czterech sekwencyjnych przejazdów, zanim strona będzie mogła wykonać jakąkolwiek użyteczną czynność, szczególnie w sieciach mobilnych, gdzie każdy taki przejazd jest kosztowny.
Dlatego gdy zauważysz, że punkt końcowy zwraca 300 KB, pierwszą reakcją jest chęć zmniejszenia objętości danych przesyłanych. Może to być sensowne, ale lepszym pytaniem jest to, dlaczego użytkownik w ogóle potrzebuje tej odpowiedzi, zanim będzie mógł interakcjonować ze stroną. Odpowiedź często ujawnia zadania, które można odłożyć lub całkowicie usunąć.
Jakość kodu JavaScript jest ważniejsza niż jego rozmiar
Kolejną pułapką jest utożsamianie rozmiaru pliku JavaScript z ilością pracy, jaką generuje. Plik o wielkości 500 KB niekoniecznie oznacza katastrofę. Decydujące jest to, co przeglądarz musi z nim zrobić:
- Pobranie go.
- Analiza jego struktury.
- Kompilacja.
- Wykonanie kodu.
- Budowanie stanu aplikacji.
- Konstruowanie drzew komponentów.
- Przyłączanie obsługi zdarzeń.
- Aktualizacja markupu wygenerowanego na serwerze.
- Ponowny obliczanie układu.
- Rysowanie końcowego wyniku.
Dwie aplikacje o podobnej wielkości pliku mogą znacznie różnić się kosztem wykonywania. Weźmy pod uwagę tabelę z 5000 wierszami – problem rzadko leży w samych danych; zazwyczaj chodzi o renderowanie 5000 interaktywnych wierszy zawierających węzły DOM. Rozwiązaniem nie jest skracanie pliku z kodem o 50 KB, lecz renderowanie tylko tych około 30 wierszy, które są obecnie widoczne. Ta technika, zwana wirtualizacją, pozwala zachować tę samą aplikację i te same dane, jednocześnie potencjalnie zmniejszając obciążenie przeglądarki.
Kiedy hydratacja staje się wąskim gardłem
Różnica ta jest szczególnie wyraźna w React i innych frameworkach komponentowych, które renderują treść na serwerze. Renderowanie na serwerze umożliwia szybkie pokazanie HTML na ekranie, ale przeglądarka musi następnie zrealizować proces hydratacji dużego drzewa komponentów, zanim cokolwiek zareaguje na dane wprowadzone przez użytkownika:
HTML arrives quickly
↓
User sees content
↓
Browser starts hydration
↓
Large amount of JavaScript executes
↓
Page becomes interactive
Strona wygląda na gotową znacznie wcześniej, niż faktycznie jest gotowa. Dlatego pomiar tylko w momencie pierwszego pojawienia się treści może wprowadzić w błąd. Panel sterowania składający się z 200 interaktywnych komponentów może generować zupełnie przyzwoity HTML, a mimo to zużywać dużo zasobów procesora podczas procesu hydratacji, co powoduje, że kliknięcia pozostają bez odpowiedzi.
Ciekawe pytanie brzmi nie to, czy plik z kodem jest zbyt duży, ale dlaczego tyle kodu musi natychmiast stać się interaktywne. Niektóre komponenty w ogóle nie wymagają JavaScriptu na stronie klienta. Niektóre interakcje można izolować w małych obszarach. Niektóre elementy mogą zostać załadowane później, a niektóre komponenty renderowane na serwerze mogą w ogóle nie wymagać procesu hydratacji. Techniki takie, które omawiamy w naszym przeglądzie częściowego pre-renderingu i renderowania równoległego, dają o wiele lepsze rezultaty niż kłótnie dotyczące zależności o wielkości 30 KB.
Skrypty od osób trzecich często przeważają nad własnym kodem
Zanim rozpoczniesz kampanię o dużych rozmiarach, sprawdź, jaka część kodu została napisana przez kogoś innego. Typowe przykłady:
- analiza danych
- widżety czatu
- heatmapy
- testy A/B
- reklama
- narzędzia wsparcia klienta
- rejestracja sesji
- wbudowania z mediów społecznościowych
- piksele marketingowe
- zarządzanie zgodą
Każdy z tych elementów może powodować dodatkowe żądania, wykonywanie skryptów, modyfikacje układu oraz aktywność sieciową. Ironią jest to, że te skrypty często nie podlegają żadnej weryfikacji, podczas gdy inżynierowie spędzają godziny na optymalizacji kodu aplikacji. Strona może ładować takie elementy:
app.js
analytics.js
chat.js
tracking.js
experimentation.js
heatmap.js
Zespół świętuje redukcję rozmiaru pliku app.js o 70 KB, mimo że strona nadal wykonywa setki kilobajtów kodu od dostawców zewnętrznych. Dlatego budżety wydajnościowe wymagają szerszego zakresu oceny. Zamiast pytać, jaki jest rozmiar pliku łącznego, należy zapytać, ile kodu musi przetworzyć urządzenie użytkownika, zanim strona stanie się przydatna. Te dwa pytania mają zupełnie różne odpowiedzi.
Zdjęcia mogą przyćmić cały budżet na JavaScript
Zbyt duże obrazy to kolejny problem, który często jest pomijany. Jedno tylko główne zdjęcie może przewyższyć wagę całego zoptymalizowanego fragmentu kodu JavaScript:
main.js 180 KB
hero.webp 1.4 MB
product.jpg 900 KB
background.png 2.1 MB
Jeśli prośba o zmianę, która usuwa zależność o rozmiarze 12 KB, zostanie przyjęta, podczas gdy obraz tła o rozmiarze 2,1 MB zostanie wysłany bez żadnych zmian, zespół zajmuje się jedynie udawaniem priorytetyzacji zamiast rzeczywistej optymalizacji. Zdjęcia wymagają takiej samej staranności co kod:
- Należy preferować nowoczesne formaty, takie jak WebP lub AVIF, tam gdzie są one obsługiwane i odpowiednie.
Oszczędzenie 15 KB kodu JavaScript niewiele znaczy, jeśli telefon nadal pobiera 2 MB obrazu, którego użytkownik ledwo zauważa.
Wiele problemów z wydajnością to problemy architektoniczne
Im głębiej się zagłębiamy, tym bardziej staje się jasne, że wiele problemów z wydajnością w ogóle nie dotyczy optymalizacji kodu. Pochodzą one od sposobu strukturyzowania aplikacji. Wyobraź sobie stronę produktu, która wymaga wszystkiego tego:
Product
Reviews
Recommendations
Inventory
Shipping estimate
User preferences
Related products
Jeśli każdy element jest pobierany oddzielnie po załadowaniu strony, można optymalizować każde żądanie, a mimo to strona pozostanie wolna. Lepszym rozwiązaniem jest ustalenie, co użytkownik musi zobaczyć najpierw. Odpowiedź początkowa może zawierać jedynie:
Product
Price
Availability
Primary image
Recenzje, rekomendacje oraz powiązane produkty mogą być dostarczane później. W tym momencie nie optymalizuje się już implementacji, lecz na nowo definiuje się, co oznacza „gotowość” strony, i to właśnie tutaj często osiąga się największe korzyści.
Mierzenie etapów, które faktycznie zauważają użytkownicy
Poważna praca nad wydajnością zastępuje pytanie „jak duży jest pakiet?” pytaniem „kiedy użytkownik może zrobić coś przydatnego?”. To pytanie prowadzi do lepszych wskaźników.
Czas do pierwszej przydatnej treści
Kiedy użytkownik widzi to, po co przyszedł? Zależy to często od konkretnego produktu: salda konta, wyników wyszukiwania, zdjęcia produktu.
Czas do interakcji
Kiedy użytkownik może w sposób niezawodny nawiązać interakcję, bez tego, by kliknięcia zostały zignorowane przez trwające operacje? Nowsze wersje Lighthouse już nie uwzględniają TTI w swojej ocenie, ale to pytanie nadal warto śledzić na własnych stronach.
Najdłuższy czas renderowania treści
Kiedy kończy się renderowanie głównej widocznej treści?
Interakcja do kolejnego renderowania
Jak szybko interfejs reaguje wizualnie po interakcji użytkownika?
Kumulatywne przesunięcie układu
Czy układ zmienia się podczas próby czytania lub klikania przez użytkownika?
Ogólny czas blokowania
Jak długo główna wątek jest zablokowany przez operacje, które uniemożliwiają przeglądarce reagowanie na dane wejściowe?
Żaden z tych elementów sam w sobie nie opisuje całej historii, ale razem znacznie lepiej przedstawiają tę sytuację niż pojedynczy zapis taki jak ten:
bundle.js = 487 KB
Wartość typu bundle opisuje daną zasobność. Wskaźniki wydajności pokazują to, czego doświadcza użytkownik.
Pętla optymalizacyjna, której można zaufać
Gdy aplikacja działa wolno i wymaga naprawy, usuwanie zależności nie powinno być pierwszym krokiem. Lepsze efekty daje zdyscyplinowana pętla.
1. Odtworzenie problemu w realistycznych warunkach
Testuj na reprezentatywnych urządzeniach i sieciach, a nie tylko na szybkim laptopie połączonym przez Wi-Fi w biurze. Wielu twoich użytkowników nie ma ani jednego, ani drugiego.
2. Pomiary w celu znalezienia powodu wolności
Określ, gdzie traci się czas. Czy głównym problemem jest jeden z tych elementów?
server response?
network?
rendering?
JavaScript execution?
layout?
images?
third-party scripts?
3. Identyfikacja największego kosztu
Unikaj naprawiania pięciu rzeczy naraz. Znajdź ten element, który najbardziej wpływa na wolność działania aplikacji.
4. Zmiana jednej rzeczy
Zrób najmniejszą możliwą zmianę architektoniczną lub implementacyjną, która rozwiąże ten problem, aby móc przypisać jej efekty.
5. Pomiierz ponownie
Jeśli poprawa nie jest widoczna w liczbach, nie zakładaj, że zadziałała.
6. Zabezpiecz osiągnięty efekt poprzez sprawdzenie regresji
Poprawy szybko zanikają. Ktoś dodaje zależność, zespół produktowy dodaje widget, komponent staje się bardziej skomplikowany, zapytanie przechodzi na tryb sekwencyjny – i po trzech miesiącach jesteśmy z powrotem na tym samym miejscu. Wydajność wymaga automatycznych mechanizmów ochrony w procesach CI i monitoringu, a nie sporadycznych, heroicznych działań naprawczych.
Rozmiar pliku wciąż ma znaczenie, choć w innym kontekście
Żadne z tych czynników nie sprawia, że rozmiar pliku nie ma znaczenia. Duże pliki zwiększają koszty pobierania, analizy, kompilacji i wykonywania, a konsekwencje są najdotkliwsze na wolnych urządzeniach i sieciach. Dzielenie kodu, usuwanie niepotrzebnego kodu, ładowanie opóźnione oraz eliminacja nieużywanych zależności są wszystkie przydatne. Chodzi o to, by stosować je wtedy, gdy dowody wskazują, że stanowią one największy problem.
Zdrowa ocena wydajności, przeprowadzana według stopnia wpływu, mogłaby wyglądać w ten sposób. Najpierw wolna odpowiedź serwera:
Problem:
900ms server response
Rozwiązane poprzez wykonywanie zapytań do backendu równolegle:
Action:
parallelize backend requestsResult:
-420ms
Następnie kosztowne ładowanie interfejsu:
Problem:
large dashboard hydration
Rozwiązane poprzez odroczenie ładowania komponentów, które nie muszą być natychmiast interaktywne:
Action:
defer non-critical interactive componentsResult:
-280ms main-thread work
Potem zbyt duża grafika główna:
Problem:
hero image is 1.8 MB
Rozwiązane poprzez dostarczanie treści w responsywnych formatach:
Action:
responsive WebP/AVIF deliveryResult:
-1.2 MB transferred
Dopiero po tym wszystkim na czele listy znajduje się poważna zależność od JavaScriptu:
Problem:
large JavaScript dependency
Jeszcze jej zastąpienie pozwala zaoszczędzić znaczną ilość zasobów:
Action:
replace dependencyResult:
-60 KB
To rozwiązanie nadal jest dobrym pomysłem. Po prostu powinno znaleźć się na czwartym miejscu w kolejce, a nie na pierwszym.
Główne wnioski
- Optymalizuj pod kątem czasu oczekiwania, a nie pod kątem jak najmniejszego rozmiaru pliku. Rozmiar bundle’u to tylko jeden z wielu wskaźników.
- Zanim przejdziesz do analizatora bundle’ów, przyjrzyj się serwerowi i procesowi przetwarzania żądań; opóźnienia i liczne przejścia tam i z powrotem często kosztują więcej niż sam rozmiar pliku.
- Mierz efektywność JavaScriptu pod kątem pracy, jaką wykonywać musi: analizy, wykonania, renderowania i inicjalizacji, a nie tylko jego rozmiaru.
- Prowadź audyt skryptów i obrazków od dostawców z taką samą starannością jak swój własny kod.
- Ponownie zdefiniuj pojęcie „gotowości” dla każdej strony, aby kluczowe treści dotarły pierwsze, a reszta nastąpiła później.
Literatura pokrewna
- Co naprawdę sprawia, że deweloperzy front-endu są cenni w erze AI — Wyjaśnia, dlaczego zrozumienie sytuacji, osąd i myślenie na poziomie systemu są teraz ważniejsze niż biegłość w konkretnych frameworkach, gdy AI przejmuje rutynowe zadania związane z kodowaniem front-endu.
- Poza P95: Pomiar opóźnienia, którego faktycznie doświadczają użytkownicy — Dlaczego dobry wskaźnik P95 może współistnieć z wolnym produktem, jak czas oczekiwania w kolejce i inne czynniki ukrywają się przed panelami kontrolnymi oraz jak pomiar czasu na każdy krok kończy spory o przyczyny opóźnień.
- Co JSON.stringify cicho opuszcza, przekształca i odmawia serializacji — Dowiedz się, które wartości JavaScript są pomijane lub modyfikowane przez JSON.stringify, jak toJSON, zamienniki i funkcje odwracające to naprawiają oraz kiedy lepszym narzędziem jest structuredClone.
- Gdzie napisana jest funkcja określa to, co widzi: leksykalny zakres w JavaScript — Zrozum, jak JavaScript rozwiązuje nazwy zmiennych poprzez środowiska leksykalne, dlaczego miejsce wywołania nigdy nie ma znaczenia przy wyszukiwaniu oraz jak to wpływa na obsługiwanie zdarzeń w React.
- Co optymalizuje kompilator React i co pozostawia do załatwienia — Dowiedz się, jakie zadania związane z wydajnością automatyzuje kompilator React, dlaczego powolne API i duże pliki pozostają twoją odpowiedzialnością oraz jak bezpiecznie wdrożyć go w istniejącej bazie kodu React.
- Demistyfikacja różnicowania w Virtual DOM: Co porównuje React i dlaczego to przynosi korzyści — Zrozum, czym naprawdę jest Virtual DOM w React, jak proces synchronizacji porównuje dwa drzewa elementów, jakie zmiany zachodzą na etapie zapisu oraz skąd pochodzi poprawa wydajności.
- Reflow, Repaint, Composite: Ile kosztuje brauserowi każda zmiana CSS — Prześledź proces HTML i CSS w DOM, CSSOM, układzie, malowaniu i kompozycji, a także dowiedz się, dlaczego zmiany szerokości kosztują więcej niż zmiany koloru oraz jak uniknąć problemów z układem.