Как обрабатывать асинхронные данные в Vue с учетом SSR?

Обработка асинхронных данных во Vue с учётом SSR (server-side rendering) требует особого подхода, поскольку данные должны быть получены на сервере до рендера HTML, а затем синхронизированы с клиентом во время гидратации. Неправильная реализация приводит к "hydration mismatch", двойным запросам и неконсистентному состоянию.

Во Vue 3 используется механизм асинхронного setup(), серверной сериализации и инициализации состояния на клиенте. Ниже — подробное объяснение архитектуры, механизмов и практик.

1. Проблема асинхронности при SSR

На сервере:

  • Запускается createApp() и рендер компонента.

  • Асинхронные данные должны быть загружены до вызова renderToString().

На клиенте:

  • Vue должен не делать повторный запрос, а использовать состояние, полученное сервером (иначе будет дублирование).

2. Использование async setup() в SSR

Vue 3 поддерживает async setup(), который позволяет выполнять асинхронные операции до рендера компонента. При SSR Vue дождётся завершения setup() перед тем, как сгенерировать HTML.

Пример:

<script setup>
import { ref } from 'vue';
const data = ref(null);
const res = await fetch('https://api.example.com/data');
data.value = await res.json();
</script>
<template>
<div v-if="data">{{ data.title }}</div>
</template>

Если setup() асинхронный, renderToString() будет ждать его завершения. Однако без дополнительной сериализации данные на клиенте будут недоступны.

3. Сериализация состояния

Для избежания повторного запроса данных на клиенте, состояние должно быть сохранено на сервере и передано в HTML.

Серверный рендеринг:

const app = createApp();
const appContent = await renderToString(app, context);
const initialState = context.state; // собранные данные
const serialized = JSON.stringify(initialState).replace(/</g, '\\\\u003c');
const html = \`
&lt;div id="app"&gt;${appContent}&lt;/div&gt;
&lt;script&gt;window.\__INITIAL_STATE__ = ${serialized}&lt;/script&gt;
&lt;script src="/client-bundle.js"&gt;&lt;/script&gt;
\`;

4. Инициализация состояния на клиенте

На клиенте необходимо прочитать window.__INITIAL_STATE__ и использовать его при создании приложения:

// client-entry.ts
import { createApp } from './app';
const { app, store } = createApp();
// инициализация состояния
if (window.\__INITIAL_STATE_\_) {
store.$patch(window.\__INITIAL_STATE_\_);
}
app.mount('#app');

Таким образом, состояние будет идентично серверному, и не произойдёт дублирования запросов.

5. Использование Pinia с SSR

Pinia имеет встроенную поддержку SSR. Каждый store должен быть создан внутри createApp(), и состояние экспортируется и импортируется через pinia.state.value.

На сервере:

const pinia = createPinia();
app.use(pinia);
await someStore.fetch(); // асинхронная загрузка
context.state = pinia.state.value; // сериализация

На клиенте:

const pinia = createPinia();
app.use(pinia);
if (window.\__INITIAL_STATE_\_) {
pinia.state.value = window.\__INITIAL_STATE_\_;
}

Pinia гарантирует реактивность при восстановлении состояния.

6. Управление асинхронностью при маршрутизации

Для каждого маршрута необходимо дождаться загрузки данных до рендера. Это достигается с помощью механизма router.isReady() и перехвата маршрутов:

router.push(url);
await router.isReady();

Внутри компонентов можно использовать onBeforeRouteEnter():

onBeforeRouteEnter(async (to, from, next) => {
const data = await fetchData(to.params.id);
next(vm => vm.data = data);
});

Однако при SSR предпочтительнее использовать async setup() с useRoute().

7. Унифицированный способ сбора данных: useAsyncData

Можно создать обёртку useAsyncData() для унифицированной работы и на сервере, и на клиенте:

// composables/useAsyncData.ts
import { ref, onServerPrefetch } from 'vue';
export function useAsyncData(key, fetcher) {
const data = ref(null);
const load = async () => {
data.value = await fetcher();
};
if (import.meta.env.SSR) {
onServerPrefetch(load);
} else {
load();
}
return { data };
}

Использование:

const { data } = useAsyncData('article', () => fetchArticle(id));

onServerPrefetch() — это хук, вызываемый только во время SSR, до renderToString().

8. Nuxt 3 (как готовое решение)

В Nuxt SSR встроен из коробки:

const { data } = await useFetch('/api/article');

Под капотом Nuxt:

  • Выполняет await во время setup() или asyncData()

  • Сохраняет результат в __NUXT__

  • Восстанавливает данные на клиенте

  • Кэширует запросы

Это упрощает разработку и исключает дублирование логики.

9. Ошибки и fallback

При загрузке асинхронных данных можно реализовать fallback, показывающий скелетон или сообщение об ошибке:

&lt;template&gt;
&lt;div v-if="error"&gt;Ошибка загрузки&lt;/div&gt;
&lt;div v-else-if="!data"&gt;Загрузка...&lt;/div&gt;
&lt;div v-else&gt;{{ data.title }}&lt;/div&gt;
&lt;/template&gt;

Ошибки нужно логировать и не блокировать весь SSR:

try {
const response = await fetch(...);
data.value = await response.json();
} catch (e) {
error.value = e;
}

10. Согласованность между клиентом и сервером

Vue требует, чтобы HTML, отрендеренный на сервере, точно совпадал с результатом на клиенте. Поэтому:

  • Все данные должны быть предсказуемыми (не зависеть от Date.now(), Math.random() без стабилизации).

  • Не использовать onMounted или setTimeout для SSR-данных.

  • Не делать условную логику на typeof window, если это влияет на template.

Нарушения ведут к ошибке "hydration mismatch".

11. Кэширование асинхронных данных

Чтобы ускорить SSR и снизить нагрузку на API:

  • Используется кэш на уровне Node.js (memory-cache, lru-cache)

  • Кэшируются fetch() или axios внутри useAsyncData

Пример:

const cache = new Map();
export async function fetchWithCache(key, fetcher) {
if (cache.has(key)) return cache.get(key);
const data = await fetcher();
cache.set(key, data);
return data;
}

12. Многократное использование одного источника данных

При SSR нужно избегать дублированных запросов из разных компонентов. Поэтому:

  • Данные собираются на уровне страницы (Page.vue)

  • Компоненты получают их через props или provide/inject

13. Прогрессивное отображение

SSR можно использовать вместе с клиентским lazy-загрузчиком:

&lt;Suspense&gt;
&lt;template #default&gt;
&lt;HeavyComponent /&gt;
&lt;/template&gt;
&lt;template #fallback&gt;
&lt;SkeletonLoader /&gt;
&lt;/template&gt;
&lt;/Suspense&gt;

Во время SSR Suspense гарантирует, что данные будут загружены до HTML-рендера.