- Opublikowano
Usunęliśmy jedną linijkę kodu i nasz SSR przyspieszył trzykrotnie
- Autorzy
Strona, która renderowała się 900ms
Mieliśmy stronę produktu, która podczas renderowania na serwerze wykonywała trzy wywołania API: szczegóły produktu, produkty powiązane i globalne dane nawigacji. Każde zajmowało mniej więcej 150ms. Prosta matematyka mówi, że przy równoległym wykonaniu daje to 450ms.
Logi serwera mówiły 900ms.
Nie od czasu do czasu. Zawsze. Każdy render SSR naszej strony produktu czekał na zakończenie jednego wywołania API, zanim ruszył z kolejnym. Trzy niezależne pobrania danych wykonywane ściśle po kolei — przez jedną linijkę kodu zakopaną w composable'u, przez który przechodziło każde zapytanie w aplikacji.
Znaleźliśmy tę linijkę, skasowaliśmy ją i każda strona w obu naszych aplikacjach przyspieszyła. Żadnych nowych bibliotek. Żadnej przebudowy architektury. Żadnych kompromisów. Po prostu jedna linijka, która nie pozwalała wbudowanej w Vue równoległości wykonać swojej roboty.
Jak zbudowaliśmy warstwę pobierania danych
Nasza platforma e-commerce używa TanStack Query (Vue) do całego pobierania danych. Każdy komponent, który potrzebuje danych z serwera, wywołuje composable useCustomQuery — opakowanie na useQuery z TanStacka, dopięte do naszych konwencji: przekazywanie cookies dla autoryzacji w SSR, wstrzykiwanie locale, walidacja schematem Zoda i obsługa błędów.
Sygnatura composable'a wyglądała tak:
export const useCustomQuery = async <T = unknown>(
url: NitroFetchRequest,
queryRaw: Ref<object> | undefined,
queryOptions: QueryOptions<T> | undefined,
schema: z.Schema<object> | undefined,
) => {
// ... setup code ...
const response = useQuery<T, QueryError>({
queryKey: queryKey.value,
queryFn: ({ signal }) => {
return $fetch<T>(url, {
method: 'get',
signal,
headers: useRequestHeaders(['cookie']),
query: { ...query?.value ?? {}, lang: locale.value },
})
},
...queryOptions,
enabled: isEnabled,
})
// THE LINE
if (!queryOptions?.lazy && isEnabled.value) {
await response.suspense()
}
// ... return response ...
}
Winowajcą jest to wywołanie await response.suspense(). Żeby jednak zrozumieć, dlaczego wymusza sekwencyjne wykonanie, trzeba najpierw wiedzieć, jak naprawdę działa renderer SSR w Vue.
Jak Vue renderuje komponent w SSR
Renderer serwerowy Vue przeprowadza każdy komponent przez konkretny cykl życia. Z punktu widzenia pobierania danych interesuje nas hook onServerPrefetch — hook cyklu życia, który odpala się wyłącznie podczas SSR i został zaprojektowany właśnie do ładowania danych, zanim wygeneruje się HTML komponentu.
I tu kluczowa obserwacja: renderer SSR Vue zbiera wszystkie hooki onServerPrefetch zarejestrowane w obrębie jednego komponentu i odpala je przez Promise.all. Czyli jeśli komponent rejestruje trzy callbacki onServerPrefetch, wszystkie trzy startują jednocześnie. Renderer czeka, aż każdy z nich się zresolwuje, a potem renderuje template komponentu z pobranymi danymi.
Integracja TanStack Query z Vue automatycznie rejestruje hook onServerPrefetch dla każdego wywołania useQuery. Kiedy tworzysz zapytanie, TanStack rejestruje hook prefetchujący, który woła na nim suspense() — dokładnie tę metodę, która resolwuje się w momencie, gdy dane zapytania są dostępne. Dzieje się to wewnętrznie, bez ani jednej linijki kodu z twojej strony.
Czyli teoretycznie: komponent ma trzy wywołania useQuery, TanStack rejestruje trzy hooki onServerPrefetch, Vue je zbiera, a Promise.all puszcza wszystkie trzy pobrania równolegle.
Teoretycznie.
Dlaczego await wszystko psuł
Nasz composable useCustomQuery był async. Wywołanie await response.suspense() siedziało w samym środku funkcji setup. Oto co się działo, gdy komponent korzystał z dwóch zapytań:
// Inside a Vue component's <script setup>
const { data: banners } = await useBanners(bannersParams)
const { data: globalData } = await useGlobal()
Ponieważ useBanners jest async i zawiera await response.suspense(), funkcja setup komponentu zatrzymuje się na pierwszym await. Zapytanie o banery rusza z pobieraniem. Cała funkcja setup wisi, dopóki odpowiedź z banerami nie wróci. Dopiero wtedy wykonanie przechodzi do drugiej linijki, gdzie useGlobal() startuje ze swoim pobieraniem.
Hooki onServerPrefetch, które TanStack zarejestrował dla tych zapytań, nie mają tu nic do rzeczy — zanim renderer Vue zdąży je zebrać i odpalić, dane są już pobrane. Inline'owy await całkowicie ubiegł mechanizm równoległości.
Tak wygląda oś czasu wykonania z inline'owym awaitem:
Time 0ms: useBanners() called, starts fetch
Time 0ms: Setup function suspends (await)
Time 150ms: Banners response arrives
Time 150ms: Setup function resumes
Time 150ms: useGlobal() called, starts fetch
Time 150ms: Setup function suspends (await)
Time 300ms: Global data response arrives
Time 300ms: Setup function resumes
Time 300ms: onServerPrefetch hooks run (nothing to do, data already fetched)
Time 300ms: Template renders
A tak wygląda bez niego — gdy zapytania oddają robotę do onServerPrefetch:
Time 0ms: useBanners() called, query created (no await, returns immediately)
Time 0ms: useGlobal() called, query created (no await, returns immediately)
Time 0ms: Setup function completes
Time 0ms: Vue collects onServerPrefetch hooks
Time 0ms: Promise.all([bannersSuspense(), globalSuspense()]) starts
Time 0ms: Both fetches running in parallel
Time 150ms: Both responses arrive (parallel, not sequential)
Time 150ms: Template renders
Różnica: 300ms kontra 150ms przy dwóch zapytaniach. Przy stronach z trzema czy czterema zapytaniami oszczędność się mnoży.
Furtka „lazy”, z której już korzystaliśmy
W pewnym sensie wiedzieliśmy o tym problemie. Nasz typ QueryOptions miał właściwość lazy:
export type QueryOptions<TQueryFnData = unknown, TError = QueryError, TData = TQueryFnData> =
Omit<UseQueryOptions<TQueryFnData, TError, TData>, 'queryKey' | 'queryFn'> & {
lazy?: boolean;
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
// ... other options
};
Gdy lazy było ustawione na true, composable pomijał wywołanie await response.suspense() i pozwalał zapytaniu zjechać do onServerPrefetch, czyli wykonać się równolegle.
Problem w tym, że domyślną wartością lazy było false. Każde zapytanie było domyślnie sekwencyjne, a deweloper musiał jawnie włączyć równoległość, przekazując flagę konfiguracyjną. Z czasem uzbierało nam się 46 miejsc wywołania w obu aplikacjach i większość z nich dostała flagę lazy: true dopiero po tym, jak analizy wydajności odsłoniły sekwencyjne wąskie gardło.
W całym naszym kodzie wyglądało to tak:
// Pattern we saw everywhere -- 46 times
const { data, status } = await useBanners(bannersParams, {
lazy: true,
})
const { data: catalogues, status } = await useCatalogues({
lazy: true,
})
const { data, status } = await useCollections({
lazy: true,
})
Każde bez wyjątku miejsce wywołania przekazywało lazy: true. Domyślne zachowanie „non-lazy” tak naprawdę nigdy nikomu nie było potrzebne. Mieliśmy flagę, którą musiał ustawić każdy konsument, i ani jednego przypadku, w którym domyślne zachowanie byłoby tym poprawnym.
Poprawka: cztery skasowane linijki
Poprawka polegała na całkowitym usunięciu bloku await response.suspense() z useCustomQuery oraz właściwości lazy z typu QueryOptions.
Przed:
export type QueryOptions<TQueryFnData = unknown, TError = QueryError, TData = TQueryFnData> =
Omit<UseQueryOptions<TQueryFnData, TError, TData>, 'queryKey' | 'queryFn'> & {
lazy?: boolean;
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
select?: (data: TQueryFnData) => TData;
// ...
};
// Inside useCustomQuery:
if (!queryOptions?.lazy && isEnabled.value) {
await response.suspense()
}
Po:
export type QueryOptions<TQueryFnData = unknown, TError = QueryError, TData = TQueryFnData> =
Omit<UseQueryOptions<TQueryFnData, TError, TData>, 'queryKey' | 'queryFn'> & {
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
select?: (data: TQueryFnData) => TData;
// ...
};
// The await block is gone. onServerPrefetch handles SSR data fetching.
Potem posprzątaliśmy wszystkie 46 miejsc wywołania, usuwając zbędną już opcję lazy: true:
// Before
const { data, status } = await useBanners(bannersParams, {
lazy: true,
})
// After
const { data, status } = await useBanners(bannersParams)
Hook onServerPrefetch, zarejestrowany w composable'u już wcześniej, przejął całe pobieranie danych w SSR:
onServerPrefetch(async () => {
if (isEnabled.value) {
await response.suspense()
await throwIfPageError(response.error.value)
if (response.data.value && queryOptions?.onSuccess) {
await nuxtApp.runWithContext(() => queryOptions.onSuccess!(response.data.value as T))
}
}
})
Ten hook już tam był. I już robił dokładnie to, co trzeba — wołał suspense(), obsługiwał błędy i callbacki sukcesu podczas SSR. Inline'owy await po prostu ścigał się z nim do mety i wygrywał za każdym razem, przez co hook nigdy nie trafiał do równoległej paczki Promise.all.
A co z awaitem w miejscu wywołania?
Można zauważyć, że miejsca wywołania nadal używają await:
const { data, status } = await useBanners(bannersParams)
Composable wciąż jest funkcją async — zwraca Promise. Ale skoro await response.suspense() zniknął, jedyną asynchroniczną robotą w środku jest setup: utworzenie zapytania i rejestracja hooków. await w miejscu wywołania resolwuje się niemal natychmiast, bo nie czeka inline'owo na żaden request sieciowy.
Właściwe pobieranie danych dzieje się później, gdy renderer SSR Vue odpala wszystkie zebrane hooki onServerPrefetch równolegle przez Promise.all.
To rozróżnienie ma znaczenie: await na composable'u służy do uporządkowania quasi-synchronicznego setupu. Hook onServerPrefetch służy do faktycznego, asynchronicznego ładowania danych. Mieszanie jednego z drugim — czyli wrzucanie requestów sieciowych w asynchroniczną ścieżkę setupu — zabija równoległość.
Efekty w 46 miejscach wywołania
Zmiana objęła 46 plików w obu aplikacjach: głównym sklepie e-commerce i pobocznej stronie marketingowej. Tak to się rozłożyło na typy stron:
| Typ strony | Zapytań na stronę | Przed (sekwencyjnie) | Po (równolegle) | Oszczędność |
|---|---|---|---|---|
| Strona główna | 2 | ~300ms | ~150ms | 50% |
| Strona produktu | 3 | ~450ms | ~150ms | 67% |
| Listing kolekcji | 2 | ~300ms | ~150ms | 50% |
| Landing kolekcji | 1 | ~150ms | ~150ms | 0% |
| Wyniki wyszukiwania | 2 | ~300ms | ~150ms | 50% |
| Strona kategorii | 2 | ~300ms | ~150ms | 50% |
| Artykuł w aktualnościach | 2 | ~300ms | ~150ms | 50% |
| Strony dashboardu | 1-2 | ~150-300ms | ~150ms | 0-50% |
Strony z jednym zapytaniem nie zyskały nic — nie ma tam czego zrównoleglać. Strony z dwoma zapytaniami skróciły czas pobierania danych mniej więcej o połowę. Największy skok zaliczyła strona produktu, z trzema zapytaniami.
Bilans diffa wyszedł na minus: 41 linii dodanych, 114 usuniętych. Skasowaliśmy więcej kodu, niż napisaliśmy. Opcja lazy, jej definicja w typie, warunkowy blok await i 46 wystąpień lazy: true w obu aplikacjach — wszystko poszło do kosza.
Dlaczego zostaliśmy przy onServerPrefetch zamiast innych podejść
Vue oferuje kilka sposobów pobierania danych podczas SSR. Oto dlaczego onServerPrefetch był dla nas właściwym wyborem i dlaczego alternatywy odpadły.
useAsyncData (wbudowane w Nuxt)
useAsyncData z Nuxta ogarnia pobieranie danych w SSR i hydratację automatycznie. Tyle że duplikuje to, co robi już TanStack Query — cache'owanie, deduplikację, odświeżanie w tle, inwalidację cache'u. Używanie obu naraz oznaczałoby utrzymywanie dwóch warstw cache'u z różnymi strategiami inwalidacji. Wybraliśmy TanStack Query właśnie dla zarządzania cache'em i przy nim zostaliśmy.
Ręczne Promise.all w setupie
Moglibyśmy ręcznie zebrać wszystkie promisy zapytań i poczekać na nie razem:
// This works but is fragile
const [banners, global] = await Promise.all([
useBanners(params),
useGlobal(),
])
To podejście wymusza, żeby każdy komponent wiedział, które zapytania da się puścić równolegle. Psuje kompozycyjność — gdy zagnieżdżony composable dorzuca nowe zapytanie, trzeba zaktualizować tablicę w Promise.all rodzica. I nie działa w poprzek granic komponentów.
onServerPrefetch (nasz wybór)
Podejście z onServerPrefetch nie wymaga żadnej koordynacji między zapytaniami. Każdy composable rejestruje swój hook niezależnie. Vue zbiera wszystkie hooki z komponentu i automatycznie odpala je równolegle. Dodanie nowego zapytania do komponentu nie wymaga zmiany ani linijki istniejącego kodu. Usunięcie zapytania nie zostawia dziury w tablicy Promise.all.
Kluczowa zaleta: równoległość jest automatyczna i kompozycyjna. Żadne zapytanie nie wie o pozostałych zapytaniach w tym samym komponencie i nie musi ich obchodzić.
Subtelność granic między komponentami
Jest tu ważny niuans: Promise.all obejmuje wyłącznie hooki onServerPrefetch w obrębie tego samego komponentu. Jeśli hooki prefetch ma zarówno rodzic, jak i dziecko, najpierw odpalają się hooki rodzica, a hooki dziecka dopiero po wyliczeniu template'u rodzica.
Oznacza to, że poniższy układ NIE zrównolegla się między komponentami:
<!-- Parent.vue -->
<script setup>
const { data: nav } = await useNavigation() // Hook registered in Parent
</script>
<template>
<ChildComponent /> <!-- Child's hooks run after Parent renders -->
</template>
<!-- ChildComponent.vue -->
<script setup>
const { data: products } = await useProducts() // Hook registered in Child
</script>
Zapytanie o nawigację i zapytanie o produkty lecą sekwencyjnie — nie przez nasz kod, tylko dlatego, że Vue renderuje komponenty z góry na dół. Rodzic musi skończyć, zanim dziecko w ogóle wystartuje.
Żeby uzyskać prawdziwą równoległość, oba zapytania muszą siedzieć w tym samym komponencie (albo w tym samym composable'u wywołanym z tego samego komponentu). Dlatego na naszej stronie głównej oba zapytania były na tym samym poziomie:
// Both in the same component's setup
const { data, status } = await useBanners(bannersParams)
const { collections, news } = await useGlobal()
Po naszej poprawce hooki onServerPrefetch obu zapytań lądują w tej samej paczce Promise.all, bo są zarejestrowane w tym samym komponencie.
Pułapka dla używających TanStack Query w Vue SSR
Jeśli używasz TanStack Query z SSR w Vue (czy to przez Nuxta, czy własny setup), oto wzorzec, na który trzeba uważać.
Wzorzec niebezpieczny:
// composable that wraps useQuery
export const useMyQuery = async (params) => {
const response = useQuery({
queryKey: ['my-data'],
queryFn: () => fetchData(params),
})
// THIS LINE KILLS PARALLELISM
await response.suspense()
return response
}
Wzorzec bezpieczny:
export const useMyQuery = async (params) => {
const response = useQuery({
queryKey: ['my-data'],
queryFn: () => fetchData(params),
})
// Let onServerPrefetch handle SSR data loading
onServerPrefetch(async () => {
await response.suspense()
})
return response
}
Różnica sprowadza się do tego, gdzie awaitujemy suspense(). W wariancie niebezpiecznym blokuje funkcję setup. W bezpiecznym oddaje robotę do onServerPrefetch, gdzie Vue może go zbatchować z pozostałymi zapytaniami z tego samego komponentu.
TanStack Query i tak rejestruje wewnętrznie własny hook onServerPrefetch. Jeśli w swoim wrapperze w ogóle nie wołasz suspense(), wbudowany hook TanStacka załatwi sprawę. Nasz jawny onServerPrefetch daje nam kontrolę nad obsługą błędów i callbackami sukcesu, ale sam mechanizm działa out of the box.
Alternatywa: useQueries
W komponentach, które zawsze pobierają ten sam zestaw zapytań naraz, drugą drogą do równoległości jest useQueries (w liczbie mnogiej) z TanStacka. Używamy go w gridzie produktów, który jednocześnie pobiera produkty, wykończenia, filtry i strefy:
const response = useQueries({
queries: [
{
queryKey: products.queryKey.value,
queryFn: () => fetchProducts(),
suspense: true,
},
{
queryKey: finishes.queryKey.value,
queryFn: () => fetchFinishes(),
suspense: true,
enabled: !!options?.isProductsFinishes,
},
{
queryKey: filters.queryKey.value,
queryFn: () => fetchFilters(),
suspense: true,
enabled: !!options?.isProductsFilters,
},
]
})
onServerPrefetch(async () => {
await queryClient.prefetchQuery(productsQuery.value)
})
useQueries puszcza wszystkie włączone zapytania równolegle w obrębie jednego composable'a. W połączeniu z onServerPrefetch daje to równoległe wykonanie zarówno na poziomie TanStacka (wiele zapytań w jednym hooku), jak i na poziomie Vue (wiele hooków w jednym komponencie).
Czego się nauczyliśmy
Domyślne zachowania kumulują się po cichu
Domyślne lazy: false wydawało się rozsądne w momencie, gdy pisaliśmy composable. „Większość zapytań potrzebuje danych już podczas SSR, więc domyślnie awaitujemy”. Tyle że ta domyślność stworzyła wydajnościową przepaść, niewidoczną, dopóki nie sprofilowałeś czasów SSR. Zanim to zauważyliśmy, dotknęło to 46 miejsc wywołania, a każdy deweloper wyuczył się dorzucać lazy: true, nie pytając, po co ten default w ogóle istnieje.
Jeśli każdy konsument nadpisuje wartość domyślną, to ta wartość jest po prostu zła.
Prymitywy frameworka wygrywają z własnymi rozwiązaniami
Zbudowaliśmy flagę lazy, żeby włączać zachowanie, które Vue oddaje za darmo przez onServerPrefetch. Framework miał właściwą abstrakcję — my po prostu ją objechaliśmy inline'owym await. Usunięcie własnego rozwiązania i pozwolenie frameworkowi robić swoje okazało się i prostsze, i poprawniejsze.
Kompozycja wymaga nieblokującego setupu
Composition API w Vue opiera się na założeniu, że composable'e rejestrują efekty, watchery i hooki cyklu życia podczas setupu, a framework koordynuje ich wykonanie. Kiedy awaitujesz request sieciowy w setupie, rozwalasz tę koordynację. Funkcja setup zamienia się w sekwencyjny skrypt zamiast deklaratywnej rejestracji zachowań.
Dotyczy to nie tylko pobierania danych. Każdy await w setupie, który blokuje na zewnętrznym I/O, uniemożliwia rejestrację kolejnych composable'ów i hooków, dopóki to I/O się nie zakończy. Trzymaj setup synchroniczny (albo prawie synchroniczny), a asynchroniczną robotę zostaw hookom cyklu życia.
Mierz SSR, nie tylko wydajność po stronie klienta
Mieliśmy rozbudowany monitoring wydajności po stronie klienta. Wyniki Lighthouse'a, Core Web Vitals, time-to-interactive — wszystko mierzone i optymalizowane. Ale czasy renderowania SSR były martwym polem. Sekwencyjne pobieranie dokładało 150-300ms do każdego renderu na serwerze, co wprost uderzało w Time to First Byte. Odkryliśmy to dopiero, gdy zaczęliśmy logować czasy SSR per strona.
Jeśli robisz SSR, czas renderowania na serwerze wyznacza podłogę dla twojego TTFB. Optymalizowanie renderowania po stronie klienta przy jednoczesnym ignorowaniu pobierania danych na serwerze to optymalizowanie niewłaściwej połowy.
Zmiana w liczbach
| Metryka | Przed | Po |
|---|---|---|
| Linie kodu | +114 (flagi lazy, blok await) | -73 netto usunięte |
| Zmienione pliki | 0 | 46 (jednorazowe sprzątanie) |
| Czas pobierania danych w SSR (2 zapytania) | ~300ms | ~150ms |
| Czas pobierania danych w SSR (3 zapytania) | ~450ms | ~150ms |
| Konfiguracja wymagana przy zapytaniu | lazy: true | Żadna |
| Wykonanie równoległe | Opt-in | Domyślne |
Najważniejszy jest ten ostatni wiersz. Równoległe pobieranie danych w SSR przestało być czymś, o czym trzeba pamiętać i co trzeba konfigurować przy każdym zapytaniu, a stało się czymś, co dzieje się samo — dla każdego zapytania, w każdym komponencie, w obu aplikacjach.
Czterdzieści sześć zmienionych plików, siedemdziesiąt trzy skasowane linijki, a najszybszy kod, jaki kiedykolwiek wypuściliśmy, to ten, który usunęliśmy.