Перейти к содержимому

25. Server-Side Rendering

Server-Side Rendering (SSR) — это техника, при которой HTML-страница генерируется на сервере перед отправкой в браузер. Вместо того чтобы пользователь видел пустую страницу, пока грузится JavaScript, он сразу получает готовый HTML с контентом. Vue 3 поддерживает SSR из коробки через @vue/server-renderer. 🚀


Проблема CSR (Client-Side Rendering):

Браузер → GET / → Сервер: пустой HTML + JS бандл
Браузер → загружает JS → 2-5 секунд белый экран
Браузер → JS выполнен → рендер компонентов
Браузер → показывает контент (поздно!)

Решение с SSR:

Браузер → GET / → Сервер: готовый HTML с контентом
Браузер → показывает HTML сразу (быстро!)
Браузер → загружает JS → гидратация (интерактивность)

Преимущества SSR:

  • Быстрый First Contentful Paint (FCP)
  • 🔍 SEO — поисковики видят контент
  • 📱 Медленные сети — HTML приходит до JS
  • 🌐 Социальные сети — метатеги в HTML

Nuxt 3 — полноценный фреймворк поверх Vue 3 с SSR, файловым роутингом, и авто-оптимизациями. Ручной SSR — когда нужен полный контроль.

Nuxt 3:
✅ Файловый роутинг
✅ Авто-SSR/SSG/CSR
✅ Модули (image, auth, i18n...)
✅ Nitro сервер
❌ Меньше контроля
❌ Сложнее кастомизировать
Ручной SSR:
✅ Полный контроль
✅ Кастомный сервер (Express, Fastify, Hapi)
✅ Интеграция с любым бэкендом
❌ Больше кода
❌ Нужно самому настроить всё

Окно терминала
npm install @vue/server-renderer
npm install express

src/
App.vue ← корневой компонент
main.ts ← клиентский entrypoint (hydration)
entry-server.ts ← серверный entrypoint (SSR)
entry-client.ts ← клиентский entrypoint
server.ts ← Express-сервер
index.html ← HTML-шаблон
vite.config.ts ← конфигурация Vite

src/entry-server.ts
import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import App from './App.vue';
export async function render(url: string) {
const app = createSSRApp(App);
// Настроить роутер для SSR
const router = createRouter({ history: createMemoryHistory() });
app.use(router);
// Дождаться навигации
await router.push(url);
await router.isReady();
// Рендер в HTML-строку
const html = await renderToString(app);
return { html };
}

src/entry-client.ts
import { createSSRApp } from 'vue';
import App from './App.vue';
// createSSRApp вместо createApp — включает режим гидратации!
const app = createSSRApp(App);
const router = createRouter({ history: createWebHistory() });
app.use(router);
// Дождаться роутера перед монтированием
router.isReady().then(() => {
// mount() запустит гидратацию, а не свежий рендер
app.mount('#app');
});

server.ts
import express from 'express';
import { createServer as createViteServer } from 'vite';
import { readFileSync } from 'fs';
import path from 'path';
async function createServer() {
const app = express();
// Vite dev-сервер как middleware (только в dev)
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
app.use('*', async (req, res, next) => {
const url = req.originalUrl;
try {
// Читаем HTML-шаблон
let template = readFileSync(path.resolve('index.html'), 'utf-8');
// Vite применяет трансформации к HTML
template = await vite.transformIndexHtml(url, template);
// Загружаем серверный entrypoint
const { render } = await vite.ssrLoadModule('/src/entry-server.ts');
// Рендерим компонент
const { html: appHtml } = await render(url);
// Вставляем в шаблон
const finalHtml = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(finalHtml);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
app.listen(5173, () => {
console.log('http://localhost:5173');
});
}
createServer();
index.html
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Vue SSR App</title>
<script type="module" src="/src/entry-client.ts"></script>
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
</body>
</html>

Гидратация — процесс, при котором Vue «оживляет» статичный SSR-HTML, добавляя к нему интерактивность.

// createSSRApp автоматически включает гидратацию
const app = createSSRApp(App);
app.mount('#app'); // Vue НЕ создаёт новый DOM — он использует существующий SSR HTML

Как это работает:

  1. Сервер рендерит HTML и добавляет data-v-app атрибуты
  2. Клиент получает HTML и сразу показывает его (быстро!)
  3. JS загружается и запускается
  4. createSSRApp + mount проходит по DOM и привязывает события
  5. Приложение становится интерактивным

Проблемы гидратации (Hydration Mismatch):

<template>
<!-- ❌ Разный HTML на сервере и клиенте! -->
<div>{{ new Date().toString() }}</div>
<!-- ❌ Math.random() разный каждый раз -->
<div>ID: {{ Math.random() }}</div>
<!-- ✅ Показываем время только на клиенте -->
<ClientOnly>
<div>{{ new Date().toString() }}</div>
</ClientOnly>
</template>

import { createSSRApp, defineComponent, h } from 'vue';
import { renderToString, ssrRenderComponent } from '@vue/server-renderer';
// Для продвинутых случаев — рендер одного компонента
const MyButton = defineComponent({
props: ['label'],
setup(props) {
return () => h('button', { class: 'btn' }, props.label);
},
});
// ssrRenderComponent используется внутри рендер-функций при SSR
// Обычно ты не вызываешь это напрямую — компилятор шаблонов делает это за тебя

🎯 useSSRContext — данные из серверного контекста

Заголовок раздела «🎯 useSSRContext — данные из серверного контекста»
src/composables/useServerData.ts
import { useSSRContext } from 'vue';
export function useServerData() {
// useSSRContext() возвращает объект только на сервере, undefined на клиенте
const ssrContext = useSSRContext();
// Записываем данные в контекст (для передачи из сервера в клиент)
if (ssrContext) {
ssrContext.teleports = ssrContext.teleports ?? {};
ssrContext.modules = ssrContext.modules ?? new Set();
}
return ssrContext;
}
// В серверном entrypoint — передать контекст в renderToString
export async function render(url: string) {
const app = createSSRApp(App);
// Создаём контекст SSR
const ssrContext: { teleports?: Record<string, string>; modules?: Set<string> } = {};
// Передаём контекст при рендере
const html = await renderToString(app, ssrContext);
// Получаем список использованных модулей (для prefetch links)
const modules = ssrContext.modules;
return { html, modules };
}

vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
// Отдельные билды для клиента и сервера
},
});
Окно терминала
# Клиентский бандл
vite build --outDir dist/client
# Серверный бандл
vite build --ssr src/entry-server.ts --outDir dist/server
// package.json — скрипты для SSR
{
"scripts": {
"dev": "node server.ts",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
"build": "npm run build:client && npm run build:server",
"preview": "NODE_ENV=production node server-prod.ts"
}
}

Streaming позволяет отправлять HTML по частям — пользователь видит начало страницы ещё до того, как сервер дорендерил конец.

import { renderToNodeStream, renderToWebStream } from '@vue/server-renderer';
// Node.js streams (Express)
app.use('*', async (req, res) => {
res.setHeader('Content-Type', 'text/html');
// Пишем начало HTML
res.write('<!doctype html><html><head>...</head><body><div id="app">');
const app = createSSRApp(App);
const stream = renderToNodeStream(app);
stream.pipe(res, { end: false });
stream.on('end', () => {
res.write('</div></body></html>');
res.end();
});
});
// Web Streams API (Cloudflare Workers, Deno, Bun)
export default {
async fetch(request: Request) {
const app = createSSRApp(App);
const stream = renderToWebStream(app);
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
});
},
};

src/entry-server.ts
import { createPinia } from 'pinia';
export async function render(url: string) {
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
// Заполняем store данными
const store = useUserStore(pinia);
await store.fetchUser();
const html = await renderToString(app);
// Сериализуем состояние для передачи клиенту
const state = JSON.stringify(pinia.state.value);
return { html, state };
}
// В HTML-шаблон добавляем состояние
const finalHtml = template
.replace('<!--ssr-outlet-->', appHtml)
.replace('<!--pinia-state-->', `<script>window.__PINIA_STATE__=${state}</script>`);
// src/entry-client.ts — восстанавливаем состояние
const pinia = createPinia();
app.use(pinia);
// Гидратируем состояние Pinia из окна
if (window.__PINIA_STATE__) {
pinia.state.value = window.__PINIA_STATE__;
}

1. Нет доступа к браузерным API на сервере:

// ❌ Упадёт на сервере!
const width = window.innerWidth;
// ✅ Проверяем окружение
const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
// ✅ Или используем onMounted
onMounted(() => {
// Этот код выполняется только в браузере
const width = window.innerWidth;
});

2. Компонент <ClientOnly> (Nuxt) / условный рендер:

<template>
<!-- Нуждается в браузере — рендерим только на клиенте -->
<ClientOnly>
<DatePicker v-model="date" />
</ClientOnly>
<!-- Или через composable -->
<div v-if="isClient">
<InteractiveChart />
</div>
</template>
<script setup lang="ts">
const isClient = ref(false);
onMounted(() => { isClient.value = true; });
</script>

3. Асинхронные данные:

<!-- В Nuxt 3 — useFetch автоматически работает с SSR -->
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts');
</script>

Окно терминала
npx nuxi@latest init my-app
cd my-app && npm install && npm run dev
nuxt.config.ts
export default defineNuxtConfig({
// SSR по умолчанию включён
ssr: true,
// Или гибридный рендеринг — разные стратегии для разных роутов
routeRules: {
'/': { prerender: true }, // Статичный HTML
'/blog/**': { swr: 3600 }, // Stale-While-Revalidate
'/dashboard/**': { ssr: false }, // CSR для авторизованных
'/admin/**': { ssr: false }, // CSR
},
});

РежимКогда использовать
CSRDashboard, SPA без SEO требований
SSRБлог, новости, SEO важен
SSGДокументация, лендинги
ISR/SWRКонтент обновляется редко
Streaming SSRБольшие страницы, медленные API

SSR в Vue 3 — мощный инструмент для SEO и производительности. Начни с Nuxt 3 — он берёт на себя всю сложность. Ручной SSR нужен только если тебе нужна глубокая интеграция с существующим Node.js сервером. 🎯