Strona główna / Artykuły / Odkrywanie zasobów, a nie ich transfer: wcześniejsze ładowanie aplikacji React przy użyciu HTTP/3

Odkrywanie zasobów, a nie ich transfer: wcześniejsze ładowanie aplikacji React przy użyciu HTTP/3

Dowiedz się, dlaczego HTTP/3 sprawia, że późne odkrywanie zasobów staje się prawdziwym wąskim gardłem w aplikacjach React, oraz jak funkcje preloadModule, preinit, Early Hints i chunking to rozwiązują.

4462 słów

Uaktualnienie serwera do HTTP/3 przyspiesza transfer danych, jednak wiele aplikacji React prawie w ogóle nie odczuwa większej szybkości. Zwykłą przyczyną jest to, że powolnym etapem w całym procesie nigdy nie był sam transfer – chodziło o moment, w którym przeglądarka dowiedziała się o istnieniu danej zasoby. Ten przewodnik rozróżnia te dwa problemy, pokazuje, w jakich przypadkach QUIC faktycznie pomaga, oraz przedstawia narzędzia, które przyspieszają proces wykrywania zasobów: API zasobów w React 19, wcześniejsze ładowanie modułów opartych na intencjach, komunikaty 103 Early Hints, streaming SSR oraz strategia dzielenia danych odpowiednia dla transportu multiplexowanego.

Kaskada, która wygląda dobrze, ale nadal jest powolna

Załóżmy, że zespół analizuje panel analityczny przez połączenie 4G. Każdy kluczowy fragment został już wcześniej załadowany, struktura sieci wygląda uporządkowanie, a mimo to czas reakcji aplikacji wciąż znacznie przewyższa założone wartości. Każdy fragment jest szybko pobierany po rozpoczęciu transferu, więc HTTP/3 wyraźnie pełni swoją rolę.

Gdy przyjrzymy się bliżej, wzorzec się zmienia. Pobieranie danych przebiega szybko, ale żądania są wysyłane późno. React musi najpierw pobrać pliki, uruchomić aplikację i wyrenderować jej zawartość, zanim dotrze do granicy obsługi elementów w trybie opóźnionym, i dopiero wtedy przeglądarka dowiaduje się, że potrzebny jest plik Dashboard.js. Setki milisekund upływa, zanim zostanie poproszony o pierwszy bajt tego fragmentu. Opóźnienie występuje na etapie przed siecią, w kroku, który większość list kontrolnych dotyczących wydajności rzadko wymienia osobno: odkrywanie zasobów, w przeciwieństwie do ich dostarczania.

Dostarczanie i odkrywanie to dwa różne problemy

Pomaga podzielić „ładowanie zasobu” na dwa pytania:

  • Szybkość transferu: po złożeniu żądania, jak szybko bajty przemieszczają się od serwera do przeglądarki?
  • Czas odkrycia: w którym momencie przeglądarka zdaje sobie sprawę, że w ogóle potrzebuje tych bajtów?

Font internetowy odwołany z pliku stylów sprawia, że różnica staje się oczywista. Łańcuch wygląda w ten sposób:

HTML → CSS → @font-face rule → font request

Niezależnie od szybkości połączenia, żądanie czcionki nie może zostać wysłane, dopóki nie dotrze HTML, nie zostanie pobrań i zinterpretowany CSS, a reguła @font-face nie zostanie znaleziona i dopasowana do wyświetlanego tekstu. To jest opóźnienie spowodowane odkryciem tych elementów. Tag <link rel="preload"> nie przyspiesza pobierania czcionki nawet o milisekundę; pozwala jedynie przeglądarce wysłać żądanie wcześniej. Prawie wszystko w pozostałej części tego przewodnika to wariacje na ten temat.

Które narzędzie odpowiada na jakie pytanie

Każda omawiana poniżej technologia dotyczy innego etapu procesu ładowania:

  • preconnect: z jakich źródeł przeglądarka powinna nawiązać połączenie przed wysłaniem jakiegokolwiek żądania?
  • preload: który konkretny plik przeglądarka sama odkryje zbyt późno?
  • preloadModule i modulepreload: które fragmenty modułów ES powinny zostać pobrałe i skompilowane z wyprzedzeniem przed wykonaniem?
  • preinit i preinitModule: które zasoby muszą zostać nie tylko pobrałe, ale także wcześnie zastosowane lub wykonywane?
  • prefetch: co prawdopodobnie będzie potrzebne podczas następnej nawigacji, przy niskiej priorytecie?
  • 103 Wczesne wskazówki: co serwer już wie, zanim HTML będzie gotowe?
  • Strumieniowanie SSR: w jaki sposób serwer może stopniowo ujawniać treść oraz zasoby, które się za nią kryją?
  • HTTP/3 i QUIC: gdy już istnieje żądanie, jak efektywnie przekazywane są jego bajty?
  • Tylko ostatni punkt dotyczy transporту. Wszystko inne odnosi się do odkrywania informacji, planowania czasowego lub harmonogramowania, a ta proporcja daje wierny obraz tego, gdzie zazwyczaj znajdują się pozostałe zalety.

    Gdzie multiplexing w HTTP/2 zawodzi

    W protokole HTTP/1.1 przeglądarki osiągały paralelizm poprzez otwieranie kilku połączeń TCP na każdy host, zazwyczaj do sześciu, przy czym każde połączenie przesyłało jednocześnie tylko jeden zasób. HTTP/2 zastąpił to pojedynczym połączeniem, które łączy wiele strumieni:

    HTTP/1.1                     HTTP/2
    ────────────────             ────────────────────
    TCP conn 1 → JS              One connection
    TCP conn 2 → CSS               ├── Stream A: JS
    TCP conn 3 → Font              ├── Stream B: CSS
    TCP conn 4 → Image             ├── Stream C: Font
                                   └── Stream D: Image
    

    To było prawdziwe postępowanie, ale HTTP/2 nadal opierał się na TCP, który gwarantuje ściśle uporządkowaną dostawę pojedynczego strumienia bajtów. Nie wie on, że niektóre bajty należą do strumienia JavaScript, a inne do strumienia czcionek. Jeśli brakuje jednego pakietu, TCP zatrzymuje całą kolejną transmisję aż do jego ponownego wysłania, nawet wtedy, gdy utracony pakiet należał do strumienia C, a pozostałe trzy strumienie nie miały z nim nic wspólnego.

    To jest blokada typu HOL (head-of-line) w TCP. W stabilnych sieciach jest rzadko zauważalna; w niestabilnych łączach mobilnych jest główną przyczyną, dla której multiplexing w HTTP/2 nigdy w pełni nie spełnił swoich obietnic.

    Jakie zmiany wprowadza QUIC w ramach HTTP/3

    HTTP/3 zastępuje TCP protokołem QUIC, który działa na bazie UDP i sam zajmuje się szyfrowaniem:

    HTTP/2          HTTP/3
    ────────        ────────
    HTTP/2          HTTP/3
      ↓               ↓
     TCP            QUIC
      ↓               ↓
     TLS            UDP
      ↓               ↓
     IP             IP
    

    Niezależne strumienie eliminują blokady na poziomie transportu

    Ponieważ QUIC zarządza strumieniami wewnątrz protokołu transportowego, każdy z nich jest odbudowywany niezależnie. Utracony pakiet blokuje jedynie ten strumień, do którego należał:

    Stream A ──────────────────── ✓
    Stream B ──────────────────── ✓
    Stream C ──────── X ─ retry
    Stream D ──────────────────── ✓
    

    Strumienie A, B i D nadal przepływają, podczas gdy C czeka na swoją ponowną transmisję. Zmierzony efekt jest największy dokładnie tam, gdzie TCP doznał największych strat. Badanie Catchpoint opublikowane w lipcu 2025 roku, przeprowadzone w sześciu krajach, pokazało, że na łączach o dużej utracie danych czas od pierwszego bajtu zmniejszył się o 41,8%. Wewnętrzne testy w Wix wykazały 33% szybsze ustanawianie połączenia oraz 20% poprawę wartości p75 LCP. Na stabilnym łączu szerokopasmowym przewaga nad HTTP/2 spada do około 5%. Ta asymetria sama w sobie jest informacyjna: korzyści występują tam, gdzie wcześniej powodował problemy blokowanie HOL. Traktuj te dane jako fragmenty tych badań, a nie gwarancje dotyczące twojego ruchu sieciowego – zmierz wyniki dla swoich użytkowników.

    Mniej przejść tam i z powrotem przed pierwszym bajtem

    Z użyciem HTTP/2 przez TCP nowe połączenie wymaga kosztownego procesu ustanowienia połączenia TCP oraz odrębnych negocjacji TLS, co skutkuje dwoma przejazdami danych przed rozpoczęciem przesyłania jakichkolwiek danych aplikacyjnych. QUIC łączy wymianę informacji TLS 1.3 z procesem ustanawiania połączenia i realizuje oba kroki w ramach jednego przejazdu danych. W przypadku powracających użytkowników mechanizm 0-RTT umożliwia przesyłanie szyfrowanych danych żądania razem z pakietem otwierającym. Na łączu międzykontynentalnym o czasie reakcji 150 ms daje to oszczędność od 150 do 300 ms przy każdym nowym połączeniu. Należy pamiętać, że dane przesyłane w trybie 0-RTT mogą zostać odtworzone, dlatego serwery zazwyczaj akceptują je tylko w przypadku żądań idempotentnych, takich jak pobieranie statycznych zasobów.

    Połączenia, które przetrwają zmianę routera sieciowego

    TCP identyfikuje połączenie za pomocą kombinacji adresów IP źródłowego i docelowego oraz portów. Gdy telefon przechodzi z sieci Wi-Fi na komórkową, jego adres się zmienia i połączenie TCP zostaje przerwane. Zamiast tego QUIC wykorzystuje niewidzialny identyfikator połączenia, dzięki czemu sesja może zostać przeniesiona na nową ścieżkę. Fragment trasy, który jest wcześniej pobierany, nie musi być rozpoczynany od nowa tylko dlatego, że pociąg pasażerski opuścił stację.

    Czy HTTP/3 czyni wcześniejsze ładowanie zbędnym?

    Nie, a zrozumienie dlaczego stanowi sedno całej tej kwestii. HTTP/3 optymalizuje sposób przenoszenia zasobów; wcześniejsze ładowanie optymalizuje moment, w którym są one żądane. Obie metody działają w różnych etapach procesu:

    Browser
      │
      │  ← "I don't know I need this yet"
      ↓
    Resource discovery    ← preload operates here
      │
      ↓
    Request
      │
      ↓
    QUIC transport        ← HTTP/3 operates here
      │
      ↓
    Server
    

    Protokół transportowy nie może pobrać czegoś, o co nikt jeszcze nie prosił. W rzeczywistości szybszy transport sprawia, że późne odkrycie problemu staje się bardziej widoczne. Załóżmy, że czas przesyłania danej części spada z 300 ms do 80 ms. Opóźnienie odkrycia wynoszące 400 ms, które wcześniej było częściowo ukryte w całkowitym czasie, teraz stanowi większość tego czasu. Wąskie gardło po prostu się przesunęło, a nie zniknęło.

    Dlaczego React ukrywa zależności przed przeglądarką

    Zwykłe zasoby HTML, takie jak źródła plików <img> i arkusze stylów <link>, są znajdowane wcześnie, ponieważ parser przeglądarki (oraz jej skaner specjalistycznych wcześniejszych załadunków) dostrzega je podczas czytania dokumentu. React renderowany po stronie klienta wprowadza znacznie dłuższy łańcuch zdarzeń, zanim niektóre zależności staną się widoczne:

    HTML → main.js → React executes → render → lazy() → discover Dashboard.js → download → render
    

    Przy użyciu React.lazy() import odbywa się po wykonaniu kodu JavaScript. Przeglądarka nie może dowiedzieć się o istnieniu pliku Dashboard.js, dopóki główny plik nie zostanie pobraany, zinterpretowany, skompilowany i uruchomiony, a React nie wyrenderuje wystarczająco dużo treści, by dotrzeć do komponentu ładowanego w tle. Przy pierwszej wizycie z wolnego telefonu może to oznaczać kilka sekund oczekiwania przed rozpoczęciem żądania tego fragmentu kodu.

    const Dashboard = lazy(() => import("./Dashboard"));
    // The browser has no idea Dashboard.js exists
    // until this renders. And it only renders after
    // React has fully bootstrapped.
    

    Suspense poprawia czas oczekiwania, a nie szybkość odkrycia komponentu

    Częstym błędnym przekonaniem jest to, że umieszczenie komponentu wewnątrz Suspense rozwiązuje ten problem. Nie zmienia to momentu, w którym żądanie fragmentu kodu jest wysyłane:

    <Suspense fallback={<Loading />}>
      <Dashboard />
    </Suspense>
    

    To, co oferuje Suspense, to koordynacja: dopóki komponent nie zostanie rozwiązany, React wyświetla alternatywę zamiast blokować cały drzewo. Jest to przydatne dla wrażenia jakości, ale nie stanowi mechanizmu do przewidywania zasobów. Zapytanie nadal rozpoczyna się w tym samym późnym momencie. HTTP/3 będzie potem efektywnie przesyłać dane, jednak nie ma wpływu na to, jak długo zajęło dotarcie do nich.

    API zasobów w React 19 i to, co faktycznie robi każde z nich

    React 19 dostarcza rodzinę funkcji w react-dom, które pozwalają komponentom dostarczać informacje o zasobach do planeru przeglądarki dokładnie w momencie renderowania, gdy pojawia się taka potrzeba. Są to coś więcej niż proste otoczki wokół tagów HTML: React je unikalizuje, a podczas renderowania na serwerze może wysyłać je do nagłówka dokumentu, aby przeglądarka mogła je zobaczyć wcześniej.

    preconnect: rozgrzewanie źródła

    Użyj preconnect, gdy wiadomo, że wkrótce nastąpi żądanie między różnymi źródłami. Pozwala to na wcześniejsze rozpoczęcie rozwiązywania nazwy DNS, nawiązywania połączenia oraz procedury TLS handshake.

    import { preconnect } from "react-dom";
    // Call this when you know a cross-origin
    // request is coming - not just "might be coming."
    preconnect("https://cdn.example.com");
    

    Zarezerwuj to dla źródeł, z którymi na pewno nawiążesz połączenie. Każde rozgrzane połączenie wymaga zasobów zarówno na stronie klienta, jak i serwera, a to, które nie jest używane, po prostu zostaje wyrzucone.

    preload: wcześniejsze pobieranie określonego pliku

    preload poleca przeglądarce rozpoczęcie pobierania znanego zasobu bez jego natychmiastowej eksploatacji. Klasycznym przykładem są czcionki, które w przeciwnym razie pozostają ukryte za procesem parsowania CSS:

    import { preload } from "react-dom";
    // Font hidden behind CSS - the browser won't
    // find this until it processes @font-face.
    // Preload surfaces it earlier.
    preload("/fonts/inter.woff2", {
      as: "font",
      crossOrigin: "anonymous",
    });
    

    Zwróć uwagę na opcję crossOrigin: „anonymous”. Czcionki zawsze są żądane w trybie CORS, więc preload czcionek bez tej opcji generuje żądanie, które nie odpowiada temu rzeczywistemu, w wyniku czego przeglądarka pobiera plik dwa razy.

    preloadModule: pobieranie i kompilowanie modułu ES

    preloadModule realizuje tę samą funkcję dla modułów ES, ale idzie o krok dalej – moduł jest pobierany, analizowany i kompilowany, a następnie przechowywany w mapie modułów, dzięki czemu może zostać wykorzystany natychmiast po żądaniu go przez import().

    import { preloadModule } from "react-dom";
    // Use this for lazy route chunks you know
    // are likely to be needed soon.
    preloadModule("/assets/Dashboard-abc123.js");
    

    To idealne rozwiązanie dla fragmentów ścieżek typu lazy, które najprawdopodobniej będą potrzebne wkrótce.

    preinit i preinitModule: pobieranie i użycie

    preinit oraz preinitModule to bardziej zaawansowane warianty. Pobierają one zasób i jednocześnie sprawiają, że ten staje się aktywny – plik stylów jest wstawiany i stosowany, a skrypt jest wykonywany po jego przybyciu.

    import { preinit } from "react-dom";
    // You don't just want this downloaded -
    // you want it applied before render.
    preinit("/styles/app.css", { as: "style" });
    

    Różnica pomiędzy tymi dwoma podejściami ma największe znaczenie w CSS. Ładowany z góry plik stylów jest pobierany, ale nie stosowany. Jeśli taki plik jest konieczny przed pierwszym narysowaniem elementu, choć pobieranie nastąpiło wcześniej, renderowanie nadal czeka, aż coś faktycznie go włączy. Funkcja preinit obejmuje oba te kroki. W przypadku skryptów obowiązuje odwrotna zasada ostrożności: należy używać wyłącznie kodu preinit, który można bezpiecznie uruchomić natychmiast.

    Uruchamianie preloadModule na podstawie intencji użytkownika

    Wywoływanie preloadModule dla każdej trasy przy starcie marnuje przepustowość. Najlepszym momentem jest wtedy, gdy intencja użytkownika staje się widoczna, co zazwyczaj oznacza wejście wskaźnika lub skupienie klawiatury na łączu nawigacyjnym tuż przed kliknięciem.

    Część poniżej realizuje to połączenie. Wyświetla zwykły element anchor, dzięki czemu link nadal funkcjonuje bez JavaScripta, wywołuje preloadModule zarówno przy onMouseEnter, jak i onFocus, aby korzystać z tego również użytkownicy klawiatury, a faktyczną nawigację przekazuje funkcji navigate z React Router:

    import { preloadModule } from "react-dom";
    import { useNavigate } from "react-router-dom";
    
    function NavLink({ to, chunkPath, children }) {
      const navigate = useNavigate();
      return (
        <a
          href={to}
          onMouseEnter={() => preloadModule(chunkPath)}
          onFocus={() => preloadModule(chunkPath)}
          onClick={(e) => {
            e.preventDefault();
            navigate(to);
          }}
        >
          {children}
        </a>
      );
    }
    
    // Usage
    <NavLink to="/dashboard" chunkPath="/assets/Dashboard-abc123.js">
      Dashboard
    </NavLink>
    

    Czas od przejścia kursorem nad elementem do jego kliknięcia wynosi zazwyczaj od 100 do 400 ms. Przez protokół HTTP/3 plik o średniej wielkości często może zostać załadowany w tym oknie czasowym: przy prędkości 10 Mbps plik o wielkości 150 KB potrzebuje około 120 ms. Zanim nastąpi kliknięcie, moduł jest już skompilowany w mapie modułów, ograniczenia związane z ładowaniem opóźnionym są natychmiast rozwiązywane, a mechanizm awaryjny Suspense w ogóle się nie pojawia.

    To, co osiąga obsługa onMouseEnter, jest czymś, czego żaden protokół transmisji nie potrafi: przekształca sygnał zamiaru w informacje o zasobie jeszcze przed złożeniem prośby o nawigację. Następnie HTTP/3 efektywnie zarządza transferem. Każda warstwa wykonuje swoją pracę.

    Dwie praktyczne uwagi. Po pierwsze, chunkPath musi być nazwą pliku z haszem wygenerowaną faktycznie przez narzędzie do pakowania, więc w rzeczywistym projekcie powinna pochodzić z pliku manifestu budowania, a nie być wpisywana ręcznie. Po drugie, urządzenia dotykowe nie mają funkcji hover, więc jeśli ważna jest dla Ciebie nawigacja w urządzeniach mobilnych, rozważ użycie onTouchStart lub mechanizmów opartych na obszarze widoku.

    Prefetching na poziomie narzędzia do pakowania

    Dla masowego, mało ważnego prefetchingu w czasie bezczynności, webpack obsługuje specjalny komentarz umieszczony wewnątrz dynamicznego importu:

    const Dashboard = lazy(
      () => import(/* webpackPrefetch: true */ "./Dashboard")
    );
    

    Należy pamiętać, że webpackPrefetch generuje wskazówkę w postaci tagu <link rel="prefetch">, którą przeglądarka traktuje jako zadanie o niskim priorytecie wykonywane w czasie wolnym przed potencjalną przyszłą nawigacją. Jest to inny sygnał niż wysokopriorytetowe metody preload lub modulepreload używane do załadowania zasobów potrzebnych natychmiast.

    Vite stosuje bardziej automatyczne podejście i sam generuje linki typu modulepreload. W przypadku webpack konieczny jest specjalny komentarz lub plugin. Natywna forma tej wskazówki w HTML wygląda następująco:

    <!-- Vite generates these for lazy chunks automatically -->
    <link rel="modulepreload" href="/assets/Dashboard-abc123.js">
    <link rel="modulepreload" href="/assets/vendor-react-def456.js">
    

    Ściśle mówiąc, generowany przez Vite HTML zawiera łącza modulepreload dla głównego chunka oraz jego statycznych importów. W przypadku dynamicznie importowanych chunków narzędzie pomocnicze Vite wstawia łącza preload dla ich zależności w momencie wywołania import(), dzięki czemu chunk i jego importy ładowają się równolegle, a nie sekwencyjnie. Aby sprawdzić dokładne zachowanie, zapoznaj się z dokumentacją dotyczącą budowania aplikacji w Twojej wersji Vite.

    W porównaniu z zwykłym atrybutem rel="preload", modulepreload umożliwia przeglądarce analizowanie i kompilowanie modułu zaraz po jego otrzymaniu, zamiast czekać aż do momentu wykonywania kodu. Przez protokół HTTP/3 kilka takich informacji jest przesyłanych niezależnymi strumieniami QUIC, więc utrata pakietu w chunku dostawcy nie powoduje opóźnienia w ładowaniu chunku z danymi.

    HTTP/2 próbował rozwiązać problem wykrywania zasobów po stronie serwera za pomocą Server Push: serwer wysyłał zasoby, których przeglądarka jeszcze nie zażądała. Cel przeniesienia tej funkcji na wcześniejszy etap był słuszny, ale realizacja się nie powiodła. Serwer nie miał żadnego niezawodnego sposobu, by stwierdzić, czy przeglądarka ma już zasób w pamięci podręcznej, więc często wysyłał duplikaty, marnował przepustowość i konkurował o zasoby z tymi, które sama przeglądarka uważała za ważniejsze. Chrome ostatecznie zrezygnował z obsługi Server Push.

    Model, który go zastąpił, dzieli obowiązki w sposób bardziej rozsądny: serwer dostarcza informacje, a przeglądarka decyduje, co i kiedy pobrać.

    103 Early Hints umożliwia wdrożenie tej funkcjonalności w praktyce. Podczas gdy serwer nadal przygotowuje główną odpowiedź, wysyła tymczasowy status 103 zawierający nagłówki Link. Przeglądarka może natychmiast zacząć pobieranie tych zasobów, a zanim dotrze ostateczna odpowiedź 200 OK z HTML, niektóre z nich mogą już być gotowe.

    Browser                    Server
      │                          │
      │──── GET / ─────────────→ │
      │                          │ (generating HTML...)
      │ ←─── 103 Early Hints ─── │
      │   Link: </assets/main.js>; rel=modulepreload
      │   Link: </assets/vendor.js>; rel=modulepreload
      │                          │
      │ (fetching chunks now...) │ (still generating...)
      │                          │
      │ ←─── 200 OK + HTML ───── │
      │   (chunks already downloading or done)
    

    W chwili pisania ten tekst NGINX posiada wbudowaną obsługę Early Hints od wersji 1.29.0 z czerwca 2025 roku, natomiast Cloudflare udostępnia ją jako opcję w swoim panelu konsolowym. W Node.js obiekt odpowiedzi oferuje metodę writeEarlyHints(), którą można wywołać w niestandardowym serwerze lub middleware przed wysłaniem rzeczywistej odpowiedzi:

    // In a custom server or middleware
    res.writeEarlyHints({
      link: [
        "</assets/main.js>; rel=modulepreload; as=script",
        "</assets/vendor.js>; rel=modulepreload; as=script",
        "</assets/Dashboard.js>; rel=modulepreload; as=script",
      ],
    });
    
    // Then proceed with normal response
    res.status(200).send(html);
    

    W tym fragmencie do wysłania ostatecznej odpowiedzi użyto metody w stylu Express res.status().send(). W przypadku prostego serwera Node.js http należałoby zamiast tego użyć res.writeHead() i res.end(). Wskazówki typu Early Hints są przydatne tylko wtedy, gdy serwer faktycznie zajmuje czas na przetwarzanie danych, takie jak zapytania do bazy danych czy renderowanie treści, podczas których w przeciwnym razie przeglądarka pozostałaby bezczynna.

    Mierzenia przeprowadzone przez corewebvitals.io, wykorzystujące dane z Chrome DevTools, pokazały, że umieszczenie kluczowego pliku CSS w ramach Early Hints sprawia, że element LCP pojawia się mniej więcej o 35% szybciej niż przy tradycyjnym wcześniejszym załadowaniu pliku wewnątrz HTML. W aplikacjach React taka korzyść polega na tym, że główny plik aplikacji jest ładowany, gdy serwer nadal pracuje, a nie dopiero po dostarczeniu HTML.

    Łączenie streaming SSR, Early Hints i HTTP/3

    Renderowanie serwera typu streaming w React dostarcza kolejnego narzędzia. Zamiast czekać z odpowiedzią aż cała strona będzie gotowa, serwer wysyła HTML etapami:

    HTML shell → Suspense fallback → more HTML → resolved content → hydration
    

    Podczas renderowania serwer wie, które obszary Suspense mają zostać wyrenderowane oraz od jakich fragmentów zależą. Tę informację można przekazać przeglądarce za pomocą Early Hints przed rozpoczęciem strumieniowania HTML. Uproszczony harmonogram:

    0ms  ── Browser sends request
         ── Server starts rendering
    1ms  ── Server knows Dashboard boundary will render
         ── Server sends 103 Early Hints: Dashboard.js
         ── Browser starts fetching Dashboard.js
    50ms ── Server streams HTML shell
         ── Browser starts parsing
    120ms── Server streams Dashboard content
         ── Dashboard.js already downloaded
         ── Hydration starts immediately
    

    Porównaj tę samą aplikację bez Early Hints, w której proces odkrywania zależy od klienta:

    0ms  ── Browser sends request
    50ms ── Server streams HTML shell
         ── Browser starts parsing
         ── Browser discovers <script> tags
         ── main.js starts downloading
    180ms── React executes
         ── Hits Dashboard lazy boundary
         ── Dashboard.js request starts (now)
    300ms── Dashboard.js downloads
         ── Hydration starts
    

    HTTP/3 skraca każdy segment w obu ścieżkach przesyłania danych. To, co zmieniają Early Hints, to moment rozpoczęcia żądania do Dashboardu, co stanowi odrębną i często większą oszczędność zasobów. Jeśli renderowanie w twoim frameworku jest już obsługiwane przez inne elementy bazowe, artykuł na temat elementów bazowych SSR w React 19.2, takich jak Activity i częściowe uprzednie renderowanie szczegółowo opisuje, w jaki sposób powstają granice strumieniowania danych.

    Ponowne przemyślenie granulacji fragmentów w transporcie multiplexowanym

    Długo utrzymywanym zaleceniem dotyczącym minimalizacji liczby żądań HTTP była ograniczona do sześciu połączeń w standardzie HTTP/1.1 oraz fakt, że multiplexing w HTTP/2 działa jedynie częściowo równolegle w ramach jednego strumienia TCP. Dzięki niezależnym strumieniom QUIC liczba żądań przestaje być tak istotnym czynnikiem jak wcześniej, więc można je dzielić bardziej agresywnie bez ponoszenia tych samych kosztów za każde żądanie.

    Rozsądny domyślny podział wygląda następująco:

    • React i ReactDOM: dedykowany, stabilny plik od dostawcy. Rzadko się zmienia, więc może być przechowywany w pamięci cache przez długi czas.
    • Biblioteka Router: własny plik, z tego samego powodu dotyczącego stabilności.
    • Ciężkie biblioteki third-party, takie jak narzędzia do tworzenia wykresów czy edytorzy: po jednym pliku na bibliotekę, dzięki czemu aktualizacja jednej nie unieważnia pozostałych.
  • Komponenty trasy: po jednym fragmentie na trasę za pomocą React.lazy(), dzięki czemu nawigacja ładuje tylko to, co jest potrzebne.
  • Wspólne narzędzia: jeden wspólny fragment wygenerowany przez bundler, ładowany raz i używany we wszystkich trasach.
  • Funkcje administracyjne lub rzadko używane: oddzielne fragmenty ładowane opóźnionie, których użytkownicy nigdy nie otwierających tych ekranów w ogóle nie pobierają.
  • Korzyścią jest precyzyjne zarządzanie pamięcią cache. Edycja pliku Dashboard.tsx powinna usunąć dane z cache dotyczące tego konkretnego fragmentu trasy, podczas gdy plik bundlera pozostanie w cache – co jest możliwe tylko dzięki drobnej segmentacji. Przez HTTP/3 8 do 15 fragmentów ładowanych jest w niezależnych strumieniach bez blokad HOL pomiędzy nimi.

    Vite radzi sobie z większością tego bez konfiguracji. W webpackie splitChunks.cacheGroups realizuje tę samą zasadę. Poniższa konfiguracja tworzy chunk dla bibliotek React, chunk dla routera oraz chunk z kodem do wykresów używanym wyłącznie asynchronicznie; wartości priority określają, która grupa ma pierwszeństwo, gdy moduł spełnia kilka kryteriów, a chunks: „async” zapobiega ładowaniu kodu do wykresów podczas pierwszej inicjalizacji:

    // webpack.config.js
    module.exports = {
      optimization: {
        splitChunks: {
          cacheGroups: {
            reactVendor: {
              test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
              name: "vendor-react",
              chunks: "all",
              priority: 40,
            },
            routerVendor: {
              test: /[\\/]node_modules[\\/](react-router|react-router-dom)[\\/]/,
              name: "vendor-router",
              chunks: "all",
              priority: 30,
            },
            chartsVendor: {
              test: /[\\/]node_modules[\\/](recharts|d3)[\\/]/,
              name: "vendor-charts",
              chunks: "async",
              priority: 20,
            },
          },
        },
      },
    };
    

    Istnieje pewna uwaga. Bardzo małe fragmenty kodu powodują dodatkowe obciążenie związane z analizą i kompilacją każdego pliku w przeglądarce. Ponadto nie wszyscy użytkownicy korzystają z protokołu HTTP/3 – powszechne są firmy z firewallami blokującymi UDP na porcie 443, przez co ci użytkownicy muszą korzystać z HTTP/2, gdzie część kosztów związanych z liczbą żądań znika. Zmierz efekty przed podziałem kodu na jeszcze mniejsze części niż poziom ścieżek. Najlepszą granularnością jest zazwyczaj podział na poziom ścieżek lub dużych bibliotek; podział na poszczególne komponenty zwykle nie jest odpowiedni. Jeśli używasz Next.js, artykuł o mechanizmach zarządzania fragmentami kodu w Turbopack omawia odpowiednie ustawienia w tym kontekście.

    Dlaczego wcześniejsze ładowanie wszystkiego nadal przynosi negatywne skutki

    Multiplexing umożliwia wielu strumieniom dzielenie się jednym połączeniem, ale nie zapewnia im żadnego nieskończonego przepustowości ani nie czyni ich równie ważnymi. Dwadzieścia wskazówek modulepreload nadal korzysta z jednego kanału; konflikty są po prostu rozproszone między większą liczbą strumieni.

    Zatem istotne pytanie brzmi nie „co można załadować z góry?”, lecz „którzy ważni dostawcy zasobów zostaną zauważeni przez przeglądarkę zbyt późno?”. Jako punkt wyjścia rozważ:

    • Krytyczny font: preload.
    • Krytyczny plik stylów: preload, lub preinit, gdy musi zostać zastosowany przed renderowaniem.
    • Krytyczny moduł JavaScript: preloadModule.
    • Ważny źródło z CDN lub API innego originu: preconnect.
  • Zdjęcie główne widoczne przy załadunku: preload z użyciem fetchpriority="high".
  • Zdjęcia dalej na stronie: brak wcześniejszego załadunku.
  • Część treści ładowana opóźnionie: preloadModule, uruchamiane na podstawie intencji użytkownika.
  • Skrypty analityczne: brak wcześniejszego załadunku.
  • Widget czatu: ładowany opóźnionie, bez wcześniejszego załadunku.
  • Kod dostępny tylko dla administratorów: brak wcześniejszego załadunku.
  • Prawdopodobna następna nawigacja: prefetch o niskiej priorytecie.
  • Zasoby krytyczne, o których serwer już wie: 103 Early Hints.
  • Każdą pozycję należy traktować jako „do rozważenia”, a nie jako regułę. Nie istnieje uniwersalna lista zasobów do wcześniejszego załadunku – są to jedynie te zasoby, które są ważne i zostają odkryte późno w konkretnym aplikacji.

    To samo ograniczenie dotyczy fetchpriority. Przeglądarki już priorytetyzują zasoby za pomocą zaawansowanych heurystyk, a oznaczenie wszystkiego jako high jest równoznaczne z brakiem jakichkolwiek oznaczeń. Zmień domyślne ustawienia tylko wtedy, gdy pomiary pokazują, że przeglądarka robi błąd.

    Pięć pytań do przeanalizowania strategii ładowania

    Podczas audytu sposobu, w jaki aplikacja React ładuje swoje zasoby, rozważ następujące pytania w kolejności:

    1. W którym momencie odkrywany jest ten zasób? Jeśli uczciwa odpowiedź brzmi „po uruchomieniu JavaScripta” lub „po renderowaniu przez React”, prawdopodobnie istnieje możliwość jego wcześniejszego ujawnienia.
    2. Kiedy użytkownik go potrzebuje? Coś może być ważne, choć nie jest koniecznie potrzebne natychmiast. To rozróżnienie decyduje o użyciu preload (teraz) lub prefetch (w czasie wolnym).
  • Czy tę wiedzę można przekazać wcześniej? Mniej więcej w porządku rosnącym trudności: preconnect, następnie preload lub preloadModule, potem preinit, dalej 103 wskazówki wczesne, a na końcu streaming SSR z wskazówkami generowanymi przez serwer na podstawie tego, co renderuje.
  • Z czym to się konkuruje? Każda wskazówka ma koszt alternatywny. Przedwczesne załadowanie fragmentu Dashboard oznacza, że coś innego dostaje mniej uwagi, więc należy wiedzieć, co to jest.
  • Czy ograniczeniem jest sieć czy procesor? Przedwczesne załadowanie pliku o wielkości 2 MB przyspiesza jego pobieranie, ale nie wpływa na czas analizy, kompilacji ani wykonywania. Jeśli ograniczeniem jest główny wątek, wcześniejsze odkrycie zmienia jedynie to, gdzie użytkownik czeka, a nie jak długo.
  • Jak ze sobą współgrają te warstwy

    Gdy spojrzeć na całość, ładowanie nowoczesnej aplikacji React obejmuje sześć warstw, z których każda ma swój własny zakres działania:

    Application intent (React knows which routes and components are needed)
           ↓
    Resource APIs (preconnect / preload / preloadModule / preinit)
           ↓
    Server-side surfacing (103 Early Hints / streaming SSR)
           ↓
    Browser resource scheduler (priority, cache, bandwidth estimation)
           ↓
    HTTP/3 / QUIC transport (independent streams, 0-RTT, connection migration)
           ↓
    Network
    

    Stary podejście polegało na wysyłaniu zasobów do klienta w jak największym stopniu: Server Push, wcześniejsze ładowanie wszystkiego oraz łączenie plików w celu zmniejszenia liczby żądań. Obecne podejście polega na przekazywaniu przeglądarce bardziej złożonych informacji na każdej warstwie i pozwalaniu jej na podejmowanie decyzji dotyczących planowania. HTTP/3 zapewnia transport, który może efektywnie realizować te decyzje. Wczesne wskazówki przekazują informacje od serwera do przeglądarki jeszcze przed utworzeniem pliku HTML. API zasobów React umożliwiają aplikacji określenie swoich intencji w momencie, gdy komponent zostanie wygenerowany.

    Żaden warstwa nie może zastąpić innej. HTTP/3 nie może pobrać tego, czego jeszcze nie odkryto. Wskazówki wczesne są bezużyteczne, gdy serwer nie wie, które ścieżki są renderowane. A preloadModule nie może uratować monolitycznego pliku o wielkości 2 MB, który wymaga 800 ms na skompilowanie w telefonie średniej klasy.

    Główne wnioski

    Największym wpływem HTTP/3 na wcześniejsze ładowanie nie jest przyspieszenie transferów; chodzi raczej o to, że szybsze transfery sprawiają, iż późne odkrywanie informacji staje się stosunkowo droższe. Gdy czas transferu spada z 400 ms do 80 ms, opóźnienie w odkrywaniu informacji, które wcześniej było ukryte w tym czasie, staje się dominującym kosztem, a strategia dostosowana do dawnych ograniczeń teraz nie jest skuteczna.

    • Przedładowuj zasoby, ponieważ w przeciwnym razie przeglądarka odkryje je zbyt późno, a nie tylko dlatego, że są ważne. Ważne zasoby odkryte na czas nie potrzebują żadnych wskazówek, natomiast nieważne odkryte późno nie zasługują na nie.
    • Suspense poprawia to, co widzi użytkownik podczas oczekiwania; nie sprawia jednak, że pliki będą ładowane szybciej.
    • Wybierz najsłabszą skuteczną wskazówkę: preconnect dla źródeł, preload dla plików, preloadModule dla modułów, preinit, gdy coś musi zostać zastosowane lub uruchomione.
    • Uruchamiaj przedładowywanie fragmentów według tras na podstawie sygnałów intencji, takich jak hover i focus, a także wykorzystuj Early Hints oraz streaming SSR, aby pokazać to, co serwer już wie.
    • Dziel strukturę na sekcje według tras i dużych bibliotek, pamiętaj o opcji fallback HTTP/2 oraz zmierz efekty przed dalszą specyfikacją.

    HTTP/3 nie uczynił preloadingu przestarzałym. Ujednolicił natomiast cel tego rozwiązania.

    Literatura pokrewna