Strona główna / Artykuły / Zakończenie obsługi wodospadów w SvelteKit za pomocą funkcji Parallel load

Zakończenie obsługi wodospadów w SvelteKit za pomocą funkcji Parallel load

Dowiedz się, dlaczego sekwencyjne użycie funkcji awaits spowalnia strony, oraz jak SvelteKit wykorzystuje funkcje load, Promise.all, ostrożne wywołania parent() oraz obietnice typu streamed do eliminacji efektu wodospadu.

2629 słów

Strona może wydawać się wolna przy szybkim połączeniu i szybkim serwerze, gdy jej żądania są wykonywane jedno po drugim zamiast równocześnie. Każda podróż tam i z powrotem czeka na zakończenie poprzedniej, więc czas dostarczenia pierwszej istotnej treści stanowi sumę wszystkich żądań, a nie czas trwania najwolniejszego z nich. Ten przewodnik wyjaśnia, skąd pochodzą te sekwencje żądań, oraz pokazuje, jak strukturyzować funkcje load w SvelteKit, aby kluczowe dane dotarły równolegle, układ i ładowanie strony nie blokowały się nawzajem, a wolne, nieważne dane trafiały dopiero po pierwszym renderowaniu.

Jak wygląda sekwencja żądań

Otwórz panel Sieci w narzędziach deweloperskich przeglądarki na typowym interfejsie React lub Vue renderowanym przez klienta i załaduj stronę, która wymaga kilku fragmentów danych. Wykresy te często przypominają schody: pierwsza prośba zostaje zrealizowana, dopiero wtedy rozpoczyna się druga, a trzecia czeka na drugą. Każdy krok wiąże się z pełnym ruchem w sieci w obie strony.

Kod stojący za tym „schodami” zazwyczaj wygląda niewinnie. Komponent jest montowany w przeglądarce, a dopiero potem okazuje się, czego potrzebuje. Wewnątrz funkcji typu effect lub hook mount wykonywane są operacje takie jak const user = await getUser(), następnie const posts = await getPosts() i wreszcie const comments = await getComments(). Trzy linie kodu, trzy wywołania await – nic oczywiście nie jest nie tak.

Kluczowe jest to, co oznacza await. Wstrzymuje on wykonywanie funkcji do chwili, aż obietnica zostanie spełniona, więc żądanie posts nie może zostać wysłane, dopóki pełna odpowiedź od user nie nadejdzie, a comments czeka z kolei na posts. Załóżmy, że każde wezwanie trwa około 300 ms, biorąc pod uwagę czas obsługi DNS, TLS, serwera oraz transferu danych. Strona nie ma więc nic przydatnego do wyświetlenia przez około całkowity czas trwania tych trzech operacji, a nie tylko czas najdłuższej z nich.

Nic się nie psuje, żadny test nie zawodzi, a każdy komponent robi dokładnie to, co mu polecono. Użytkownicy po prostu patrzą na ikonę spinującą się przez około trzy razy dłużej, niż to konieczne. To właśnie ta niewidzialność sprawia, że struktury typu „waterfall” są tak powszechne.

Dlaczego struktury typu „waterfall” wciąż się pojawiają

Największą liczbę takich struktur powodują trzy zwyczaje:

  • Każdy komponent pobiera własne dane. W aplikacjach renderowanych po stronie klienta rodzic musi najpierw zakończyć ładowanie, zanim dziecko będzie mogło zostać uruchomione, a dziecko nie może rozpocząć własnego żądania, dopóki nie otrzyma właściwości od rodzica. Drzewo komponentów staje się łańcuchem żądań.
  • Czekanie wiersz po wierszu z przyzwyczajenia. Sekwencyjne instrukcje await są czytelne, i to właśnie dlatego ukrywają fakt, że serializują żądania, które nigdy nie były od siebie zależne.
  • Nie ma jednolitego obrazu potrzeb danych strony. Gdy kod pobierania danych jest rozproszony między trzema plikami, trudno zauważyć, że wszystkie trzy żądania mogły zostać rozpoczęte w tym samym momencie.

Pisanie szybszego kodu nie pomaga. Pomaga przeniesienie operacji pobierania danych w inne miejsce i o innej porze: przed utworzeniem jakiegokolwiek komponentu, w jednym spójnym miejscu. W SvelteKit tym miejscem jest funkcja load. Aby zobaczyć neutralne pod względem frameworka opcje dotyczące równoczesności, zapoznaj się z porównaniem na blogu dotyczącym Promise.all, Promise.race i sekwencyjnych awaits.

Jak funkcje load w SvelteKit zmieniają standard

Każda trasa w SvelteKit może eksportować funkcję load, która jest wykonywana przed renderowaniem komponentu strony. Komponenty nie muszą już szukać danych po zamontowaniu; trasa najpierw zbiera wszystko i przekazuje to stronie jako pojedynczą właściwość data.

Istnieją dwie wersje, a wybór ma znaczenie:

  • Uniwersalne obciążenie w pliku +page.js jest wykonywane na serwerze podczas renderowania po stronie serwera oraz w przeglądarce podczas nawigacji po stronie klienta. Nadaje się do wywołań publicznych API oraz do zwracania wartości, które nie są serializowalne.
  • Obciążenie serwera w pliku +page.server.js jest wykonywane wyłącznie na serwerze. Jego kod nigdy nie jest wysyłany do przeglądarki, więc może bezpiecznie korzystać z klientów bazy danych, prywatnych zmiennych środowiskowych oraz tokenów autoryzacyjnych.

Struktury layoutu działają według tego samego wzoru za pomocą plików +layout.js i +layout.server.js.

W większości rzeczywistych aplikacji wszystko, co dotyczy bazy danych, usługi wewnętrznej lub informacji poufnych, powinno znajdować się w pliku +page.server.js. Jego minimalna forma wygląda następująco:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  const res = await fetch('/api/user');
  const user = await res.json();
  return {
    user
  };
}

Trzy kwestie dotyczące tego fragmentu kodu warto zapamiętać już na wstępie.

Funkcja obciążenia kończy się przed renderowaniem strony

Gdy +page.svelte zaczyna renderować się, user jest już zwykłym obiektem. Nie ma żadnego hooka mount, żadnego efektu ładowania, żadnego momentu, w którym wartość byłaby undefined.

Użyj funkcji fetch dostarczonej przez SvelteKit

Argument fetch nie jest tym globalnym. SvelteKit dostarcza ulepszoną wersję, która przyjmuje względne adresy URL, takie jak /api/user, przekazuje pliki cookie i nagłówki z przychodzącej żądania podczas renderowania po stronie serwera, a gdy celuje w inny szlak w tej samej aplikacji, wywołuje bezpośrednio odpowiedni obsługę zamiast wysyłać rzeczywiste żądanie HTTP. Importowanie globalnego fetch oznacza utratę wszystkich tych funkcji.

Wartość zwrócona staje się właściwością danych strony

To, co zwraca funkcja, jest dostępne dla komponentu strony jako data. W komponencie Svelte 5 odczytuje się je za pomocą let { data } = $props(); (starszy kod Svelte 4 używa export let data;), a następnie wyświetla się data.user.name w markupu.

Model myślowy jest prosty: najpierw pobrać dane, potem je wyświetlić i otrzymać ukończone wartości zamiast obietnic, którymi trzeba zarządzać wewnątrz komponentu.

Jednak przeniesienie operacji pobierania do funkcji load samo w sobie nie eliminuje problemu. Trzy kolejne wywołania await wewnątrz load powodują ponowne utworzenie tej samej struktury na serwerze. Prawdziwe rozwiązanie pochodzi dalej.

Rozpoczynanie wszystkich niezależnych żądań jednocześnie

Rozwiązaniem jest natychmiastowe wysłanie wszystkich niezależnych żądań i wspólne czekanie na ich zakończenie:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  // Kick off all three requests immediately - none of them
  // are awaited yet, so none of them block each other.
  const userPromise = fetch('/api/user').then(r => r.json());
  const postsPromise = fetch('/api/posts').then(r => r.json());
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  // NOW wait for all of them to finish, in parallel.
  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise
  ]);
  return { user, posts, comments };
}

Dlaczego to jest szybsze

Czynnikiem decydującym jest czas pomiędzy wysłaniem żądania a oczekiwaniem na jego wynik. Wywołanie fetch(...) wysyła żądanie natychmiast; nie czeka na await. Dodanie .then() jedynie określa, co robić z odpowiedzią po jej otrzymaniu. Zatem w powyższym kodzie:

  • pierwszy wiersz wysyła żądanie do /api/user;
  • drugi wiersz wysyła żądanie do /api/posts, podczas gdy pierwsze wciąż jest w trakcie przesyłania;
  • trzeci wiersz wysyła żądanie do /api/comments, podczas gdy pozostałe dwa są w trakcie przesyłania;
  • tylko Promise.all faktycznie zawiesza działanie i wznowia je, gdy tylko najsłabsze z tych trzech żądań zostanie zakończone.

Całkowity czas spada z sumy trzech żądań do mniej więcej długości trwania naj wolniejszego z nich. Żądania, serwer i dane pozostają niezmienione; zmienia się jedynie umiejscowienie instrukcji await. W przykładowym scenariuszu, w którym każde żądanie trwa około 300 ms plus dodatkowe opóźnienia, to sprawia, że strona staje się użyteczna po około 960 ms zamiast po około 360 ms, a różnica ta wzrasta w wolnych sieciach lub przy intensywnym wykorzystaniu API.

Niewiele zmian w stronie obciążonej danymi przynosi tak duży efekt przy tak małym wysiłku. Nie trzeba dodawać żadnych bibliotek ani przeprojektowywać architektury – wystarczy tylko przeorganizować kilka wierszy kodu.

Na co zwrócić uwagę przy użyciu Promise.all

Promise.all odrzuca żądanie natychmiast, gdy choćby jedno z jego obietnic odrzuci się. Jeśli nieudane żądanie comments nie powinno sparaliżować całej strony, użyj Promise.allSettled lub przypisz każdej obietnicy własną funkcję .catch(), która zwraca wartość awaryjną. Należy również pamiętać, że r.json() nie sprawdza pola r.ok; odpowiedź 404 lub 500 z ciałem błędu w formacie JSON zostanie przetłumaczona i zwrócona tak, jakby była danymi, dlatego sprawdzaj status, gdy od niego zależy poprawność.

Ukryty proces przetwarzania danych pomiędzy układami a stronami

Rozwijający, którzy poznali sztuczkę z Promise.all, często nadal wprowadzają drugi rodzaj takiego procesu, który obejmuje pliki: określa on, w jaki sposób ładowanie danych układu wpływa na ładowanie danych strony znajdującej się poniżej niego.

Domyślnie SvelteKit uruchamia funkcje load z plików +layout.server.js i +page.server.js równocześnie, podobnie jak w przykładzie paralelnym powyżej. Wyjściem z tej sytuacji jest funkcja parent(), która umożliwia funkcji load strony dostęp do danych zwróconych przez leżące powyżej niej layouti. Czasami jest to dokładnie to, czego potrzebujesz, na przykład gdy zapytanie strony wymaga identyfikatora użytkownika, który został pobrały tylko layouti. Jeśli jednak zostanie ona wywołana zbyt wcześnie, to sparaliżuje działanie wszystkich funkcji uruchamianych później:

// src/routes/dashboard/+page.server.js

// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
  const { user } = await parent(); // blocks here until layout's load finishes
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  return { posts };
}

Jeśli żądanie dotyczące postów rzeczywiście wymaga user.id, taka kolejność jest nieunikniona i w pełni przyjęta; stanowi rzeczywistą zależność. Problem pojawia się, gdy strona potrzebuje również danych niewiążących się z układem. Oczekiwanie na parent() w pierwszym wierszu opóźnia każde późniejsze polecenie, w tym te niezależne żądania, aż układ zostanie ukończony.

Rozwiązaniem jest zmiana kolejności: najpierw wykonać prace niezależne, a na parent() poczekać dopiero w momencie, gdy jest potrzebna jego wartość.

// src/routes/dashboard/+page.server.js

// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  const { user } = await parent(); // runs concurrently with the fetch above
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  const comments = await commentsPromise;
  return { user, posts, comments };
}

Tutaj żądanie dotyczące komentarzy jest wysyłane przed tym, jak strona czeka na ukończenie układu, więc oba się nakładają. Żądanie dotyczące postów nadal musi czekać na user.id, co jest słuszne, a obietnica dotycząca komentarzy zazwyczaj jest już spełniona w momencie jej oczekiwania na końcu.

Zasada ogólna: traktuj await parent() jak każde inne await. Umieść go dokładnie tam, gdzie potrzebna jest wartość, nigdy refleksyjnie na początku funkcji, oraz uruchom wszelkie żądania, które nie zależą od danych parent, przed nim.

Strumieniowanie danych nieważnych

Promise.all nie zawsze jest właściwym rozwiązaniem. Jeśli jedno żądanie jest wolne, a jego dane nie są potrzebne od razu po otwarciu strony przez użytkownika, czekanie na nie sprawia, że cała strona staje się tak wolna jak jej najmniej ważna część. Na stronie produktu nazwa, cena i obrazy są kluczowe; sekcja z recenzjami kilka ekranów niżej nie jest.

Dla takiego przypadku SvelteKit obsługuje strumieniowanie. Zwróć obietnicę z serwera metodą load bez jej oczekiwania, a SvelteKit natychmiast wyśle zrenderowaną stronę, a następnie dostarczy wartość obietnicy do przeglądarki po jej realizacji.

// src/routes/product/[id]/+page.server.js

export async function load({ fetch, params }) {
  // Critical: awaited, blocks the initial render - but it's fast.
  const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
  // Non-critical: NOT awaited. This is handed to the page as a
  // pending Promise, and SvelteKit streams it in once it resolves.
  const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
  return {
    product,          // resolved value
    reviews: reviewsPromise  // still-pending promise
  };
}

Produkt jest oczekiwany, ponieważ wymaga go początkowa renderizacja, a żądanie jest szybkie. Żądanie opinii zostaje rozpoczęte, ale nie jest oczekiwane, więc strona otrzymuje obietnicę w stanie oczekiwania pod data.reviews.

Na stronie blok {#await} w Svelte obsługuje oba stany. Wewnątrz {#await data.reviews} renderuje się lekki element zastępczy, np. linijkę „Ładowanie opinii...”, a w gałęzi {:then reviews} przechodzimy przez wyniki. Opcjonalna gałąź {:catch error} wyświetla komunikat w przypadku niepowodzenia żądania.

Użytkownik widzi szczegóły produktu niemal natychmiast, obszar z opiniami pokazuje mały stan ładowania, a rzeczywiste opinie zastępują go w momencie ich przybycia. Nie ma dodatkowego kodu do pobierania danych po stronie klienta ani hooka mount.

Ograniczenia związane ze strumieniowaniem

Miej na uwadze te ograniczenia:

  • Striming działa dzięki funkcjom obsługi obciążenia serwera. Obietnica jest tworzona na serwerze i przekazywana strumieniowo z pliku +page.server.js lub +layout.server.js. Uniwersalny plik +page.js również może zwracać obietnicę, ale nie jest przekazywany strumieniowo z serwera w ten sam sposób.
  • Twój adapter i usługa hostingu muszą obsługiwać odpowiedzi przekazywane strumieniowo. Adaptery Node i Vercel to robią, podobnie jak większość nowoczesnych platform, ale sprawdź to w przypadku mniej powszechnych rozwiązań. Proxy’i, które buforują odpowiedzi, mogą również bezpowrotnie zniwelować tę korzyść.
  • Zarządzaj odrzuceniami. Obietnica przekazywana strumieniowo, która zostanie odrzucona bez ścieżki {:catch} lub obsługi .catch(), może powodować nierozwiązane odrzucenie na serwerze. Dla obietnic nieważnych zastosuj rozwiązanie awaryjne.
  • Tytuły i pliki cookie są ustalone po rozpoczęciu transmisji. Wszystko, co musi ustawić plik cookie lub kod stanu, musi zostać zakończone przed rozpoczęciem odpowiedzi.
  • Treść transmisyjna wymaga JavaScripta w przeglądarce. Bez niego użytkownicy będą widzieć jedynie stan ładowania, dlatego nie należy transmitować niczego, co jest konieczne dla wyszukiwarek lub użytkowników bez JavaScripta.
  • Pełny cykl życia żądania

    Dzięki równoległym krytycznym żądaniom, celowemu użyciu parent() oraz transmisji danych nieważnych, żądanie do trudnej do przetworzenia ścieżki przechodzi przez następujące etapy:

    1. Przeglądarka wysyła żądanie do ścieżki.
    2. SvelteKit uruchamia na serwerze funkcje layoutu oraz load strony przypisanej do tej ścieżki.
    3. Wszystkie niezależne dane, takie jak użytkownicy, wpisy i komentarze, są pobierane równolegle.
  • Serwer generuje kompletny plik HTML z już włączonymi danymi, dzięki czemu początkowy wygląd strony nie pokazuje stanu ładowania.
  • Brauser otrzymuje jedną gotową odpowiedź zamiast realizować serię połączeń sieciowych.
  • Svelte aktywuje interaktywność w już wyrenderowanej strukturze, podczas gdy wszystkie obietnice typu stream wypełniają swoje sekcje w miarę realizacji.
  • Porównaj to z wersją kliencką od samego początku. Zamiast aby brauser wykonywał trzy sekwencyjne połączenia po załadowaniu strony, serwer wykonuje je równolegle przed wysłaniem jakichkolwiek danych, zazwyczaj przy znacznie niższej opóźnieniu w porównaniu do czasu potrzebnego brauserowi użytkownika.

    Listwa kontrolna przed wdrożeniem dla tras o dużym obciążeniu danymi

    Rozważ te pytania przed wdrożeniem trasy wymagającej kilku źródeł danych:

    • Czy dane potrzebne do pierwszego wyświetlenia są pobierane w komponencie podczas montowania? Przenieś je do funkcji load, zazwyczaj tej na serwerze.
    • Czy wewnątrz load znajduje się kilka niezależnych wyrażeń await jedno po drugim? Najpierw zrób wszystkie żądania, a następnie poczekaj na nie za pomocą Promise.all lub Promise.allSettled.
    • Czy await parent() jest pierwszym wierszem funkcji load strony? Przenieś je tam, gdzie faktycznie wykorzystywane są dane od rodzica, i uruchom niepowiązane żądania przed nimi.
    • Czy jakiekolwiek dane nie są istotne dla pierwszego narysowania? Zwróć je jako obietnicę bez oczekiwania i wyświetl je za pomocą {#await}, z blokiem catch.
  • Czy fetch pochodzi z argumentów load? Tylko ta wersja poprawnie rozwiązuje względne adresy URL i przekazuje pliki cookie podczas renderowania na serwerze.
  • Podsumowanie

    Struktury typu „wodospad” nie są dowodem na niedbały kod. Są to naturalny skutek rozproszenia operacji pobierania danych w drzewie komponentów. Funkcje load w SvelteKit mają mniejsze znaczenie dlatego, że działają na serwerze, a większe dlatego, że gromadzą wszystkie żądania potrzebne do strony w jednym widocznym miejscu, gdzie można zdecydować, które mają być wykonywane razem, które rzeczywiście od siebie zależą, a które mogą nadejść później. Zwyczaj budowania takich struktur jest prosty: przestań oczekiwać automatycznie i zacznij oczekiwać celowo. Zastosowanie tego podejścia do każdej trasy oszczędza użytkownikom czas przy każdym wolnym połączeniu i intensywnie pracującym backendzie.

    Pozycje pokrewne