Strona główna / Artykuły / Poza rozmiarem paczki: odkrywanie tego, co naprawdę spowalnia twoją aplikację internetową

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.

3024 słów

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ć:

  1. Pobranie go.
  2. Analiza jego struktury.
  3. Kompilacja.
  4. Wykonanie kodu.
  5. Budowanie stanu aplikacji.
  6. Konstruowanie drzew komponentów.
  7. Przyłączanie obsługi zdarzeń.
  8. Aktualizacja markupu wygenerowanego na serwerze.
  9. Ponowny obliczanie układu.
  10. 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.
  • Używaj rozmiarów responsywnych zamiast jednego stałego wymiaru.
  • Nigdy nie wysyłaj obrazów w rozdzielczości dostosowanej do komputerów na telefony.
  • Ładowaj obrazy znajdujące się poniżej linii widoku opóźnionie.
  • Załaduj z góry tylko te nieliczne obrazy, które są naprawdę kluczowe.
  • Zastąp ogromne obrazy tła tymi mniejszymi, które spełniają ten sam cel.
  • Wybieraj poziomy kompresji w zależności od sposobu, w jaki obraz jest faktycznie wyświetlany.
  • 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.
  • Praca w pętli polegającej na reprodukcji problemu, pomiarze, zmianie jednej rzeczy, ponownym pomiarze oraz ochronie każdego osiągnięcia poprzez sprawdzenie regresji.
  • Najważniejsze pytanie w pracy nad wydajnością brzmi: co sprawia, że użytkownik musi czekać i dlaczego.
  • Literatura pokrewna