25. Server-Side Rendering
🌐 Урок 26: Server-Side Rendering во Vue 3
Заголовок раздела «🌐 Урок 26: Server-Side Rendering во Vue 3»Server-Side Rendering (SSR) — это техника, при которой HTML-страница генерируется на сервере перед отправкой в браузер. Вместо того чтобы пользователь видел пустую страницу, пока грузится JavaScript, он сразу получает готовый HTML с контентом. Vue 3 поддерживает SSR из коробки через @vue/server-renderer. 🚀
🤔 Зачем нужен SSR?
Заголовок раздела «🤔 Зачем нужен SSR?»Проблема 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 vs Ручной SSR
Заголовок раздела «🆚 Nuxt vs Ручной SSR»Nuxt 3 — полноценный фреймворк поверх Vue 3 с SSR, файловым роутингом, и авто-оптимизациями. Ручной SSR — когда нужен полный контроль.
Nuxt 3:✅ Файловый роутинг✅ Авто-SSR/SSG/CSR✅ Модули (image, auth, i18n...)✅ Nitro сервер❌ Меньше контроля❌ Сложнее кастомизировать
Ручной SSR:✅ Полный контроль✅ Кастомный сервер (Express, Fastify, Hapi)✅ Интеграция с любым бэкендом❌ Больше кода❌ Нужно самому настроить всё📦 Установка @vue/server-renderer
Заголовок раздела «📦 Установка @vue/server-renderer»npm install @vue/server-renderernpm install express🏗️ Базовая структура ручного SSR
Заголовок раздела «🏗️ Базовая структура ручного SSR»src/ App.vue ← корневой компонент main.ts ← клиентский entrypoint (hydration) entry-server.ts ← серверный entrypoint (SSR) entry-client.ts ← клиентский entrypointserver.ts ← Express-серверindex.html ← HTML-шаблонvite.config.ts ← конфигурация Vite📄 entry-server.ts — серверный рендеринг
Заголовок раздела «📄 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 };}📄 entry-client.ts — гидратация на клиенте
Заголовок раздела «📄 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');});🖥️ Express-сервер
Заголовок раздела «🖥️ Express-сервер»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();<!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>💧 Гидратация (Hydration)
Заголовок раздела «💧 Гидратация (Hydration)»Гидратация — процесс, при котором Vue «оживляет» статичный SSR-HTML, добавляя к нему интерактивность.
// createSSRApp автоматически включает гидратациюconst app = createSSRApp(App);app.mount('#app'); // Vue НЕ создаёт новый DOM — он использует существующий SSR HTMLКак это работает:
- Сервер рендерит HTML и добавляет
data-v-appатрибуты - Клиент получает HTML и сразу показывает его (быстро!)
- JS загружается и запускается
createSSRApp+mountпроходит по DOM и привязывает события- Приложение становится интерактивным
Проблемы гидратации (Hydration Mismatch):
<template> <!-- ❌ Разный HTML на сервере и клиенте! --> <div>{{ new Date().toString() }}</div>
<!-- ❌ Math.random() разный каждый раз --> <div>ID: {{ Math.random() }}</div>
<!-- ✅ Показываем время только на клиенте --> <ClientOnly> <div>{{ new Date().toString() }}</div> </ClientOnly></template>🔧 ssrRenderComponent — ручной рендер компонента
Заголовок раздела «🔧 ssrRenderComponent — ручной рендер компонента»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 — данные из серверного контекста»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 — передать контекст в renderToStringexport 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 SSR Plugin
Заголовок раздела «⚡ Vite SSR Plugin»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 SSR
Заголовок раздела «🌊 Streaming SSR»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' }, }); },};📊 SSR с Pinia (управление состоянием)
Заголовок раздела «📊 SSR с Pinia (управление состоянием)»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__;}💡 Нюансы SSR в Vue 3
Заголовок раздела «💡 Нюансы SSR в Vue 3»1. Нет доступа к браузерным API на сервере:
// ❌ Упадёт на сервере!const width = window.innerWidth;
// ✅ Проверяем окружениеconst width = typeof window !== 'undefined' ? window.innerWidth : 1024;
// ✅ Или используем onMountedonMounted(() => { // Этот код выполняется только в браузере 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>🚀 Nuxt 3: SSR без боли
Заголовок раздела «🚀 Nuxt 3: SSR без боли»npx nuxi@latest init my-appcd my-app && npm install && npm run devexport default defineNuxtConfig({ // SSR по умолчанию включён ssr: true,
// Или гибридный рендеринг — разные стратегии для разных роутов routeRules: { '/': { prerender: true }, // Статичный HTML '/blog/**': { swr: 3600 }, // Stale-While-Revalidate '/dashboard/**': { ssr: false }, // CSR для авторизованных '/admin/**': { ssr: false }, // CSR },});📊 Итог: какой режим выбрать?
Заголовок раздела «📊 Итог: какой режим выбрать?»| Режим | Когда использовать |
|---|---|
| CSR | Dashboard, SPA без SEO требований |
| SSR | Блог, новости, SEO важен |
| SSG | Документация, лендинги |
| ISR/SWR | Контент обновляется редко |
| Streaming SSR | Большие страницы, медленные API |
SSR в Vue 3 — мощный инструмент для SEO и производительности. Начни с Nuxt 3 — он берёт на себя всю сложность. Ручной SSR нужен только если тебе нужна глубокая интеграция с существующим Node.js сервером. 🎯