← Курс/Server-Side Rendering (SSR) в Vue#244 из 257+35 XP

Server-Side Rendering (SSR) в Vue

Три режима рендеринга

**CSR (Client-Side Rendering)** — классический SPA:

  • Сервер отдаёт пустой HTML + JS-бандл
  • Браузер скачивает JS и рендерит страницу
  • Медленный First Contentful Paint, плохой SEO
  • **SSR (Server-Side Rendering)** — рендер на сервере:

  • Сервер генерирует HTML с данными
  • Браузер получает готовую страницу (быстрый FCP)
  • Vue "гидрирует" страницу: добавляет реактивность
  • Хорошо для SEO, лучший Time to First Byte
  • **SSG (Static Site Generation)** — генерация при сборке:

  • HTML генерируется один раз при сборке
  • Отдаётся статическим хостингом (CDN)
  • Лучшая производительность, нет серверного кода
  • renderToString()

    Основной API Vue SSR — функция рендера компонента в HTML-строку:

    // server.js (Node.js)
    import { createSSRApp } from 'vue'
    import { renderToString } from '@vue/server-renderer'
    import App from './App.vue'
    
    const app = createSSRApp(App)
    const html = await renderToString(app)
    
    // html — строка с отрендеренным HTML:
    // <div id="app" data-v-app=""><h1>Привет!</h1></div>

    Гидрация (Hydration)

    После получения серверного HTML клиент делает его интерактивным:

    // client.js
    import { createSSRApp } from 'vue'
    import App from './App.vue'
    
    // createSSRApp вместо createApp — запускает гидрацию
    const app = createSSRApp(App)
    app.mount('#app')  // Vue "оживляет" существующий HTML

    Гидрация: Vue проходит по DOM-дереву и "привязывает" реактивное состояние к уже существующим элементам вместо их пересоздания.

    onServerPrefetch — загрузка данных на сервере

    // ProductList.vue
    const products = ref([])
    
    onServerPrefetch(async () => {
      // Выполняется только на сервере, перед рендером
      products.value = await fetchProducts()
    })
    
    // На клиенте — данные уже в HTML, гидрация восстановит их

    Код только для сервера / только для клиента

    import { onMounted } from 'vue'
    
    // Проверка окружения
    if (typeof window !== 'undefined') {
      // Только клиентский код
    }
    
    // Хуки работают только на клиенте:
    onMounted(() => {
      // window, document, localStorage — доступны
      // onMounted НЕ вызывается при SSR рендере
    })
    
    // Динамический импорт для клиентских библиотек:
    const Chart = defineAsyncComponent(() => {
      if (import.meta.env.SSR) return Promise.resolve({ render: () => null })
      return import('./HeavyChart.vue')
    })

    Типичные проблемы SSR

    **Hydration mismatch** — разный HTML на сервере и клиенте:

    // ❌ Проблема — Math.random() разный на сервере и клиенте
    const id = ref(Math.random())
    
    // ✅ Решение — стабильный ID
    const id = useId()  // Vue 3.5+

    **Глобальные объекты браузера** — недоступны на сервере:

    // ❌ ReferenceError на сервере
    const width = window.innerWidth
    
    // ✅ Защита
    const width = typeof window !== 'undefined' ? window.innerWidth : 1200

    Nuxt 3 как SSR-фреймворк

    Nuxt 3 — мета-фреймворк для Vue, который автоматизирует SSR:

  • Файловый роутинг
  • Автоматическая гидрация
  • useFetch / useAsyncData с SSR-поддержкой
  • Hybrid rendering (SSR + SSG + CSR по маршруту)
  • Примеры

    Реализация renderToString — рендеринг компонентного дерева в HTML-строку, как в @vue/server-renderer

    // Реализуем упрощённый renderToString:
    // обходим дерево компонентов и генерируем HTML.
    
    // --- Упрощённые VNode-объекты ---
    function h(type, props, ...children) {
      return {
        type,
        props: props || {},
        children: children.flat().filter(c => c != null && c !== false),
      }
    }
    
    // --- Renderless-компонент (серверная заглушка) ---
    const ServerOnly = {
      name: 'ServerOnly',
      serverRender: (props, children) => children,
      clientRender: () => null,
    }
    
    // --- renderToString (SSR) ---
    async function renderToString(vnode, isServer = true) {
      if (vnode == null || vnode === false) return ''
      if (typeof vnode === 'string' || typeof vnode === 'number') {
        return String(vnode)
      }
    
      const { type, props, children } = vnode
    
      // Функциональный компонент
      if (typeof type === 'function') {
        const rendered = await type(props, children)
        return renderToString(rendered, isServer)
      }
    
      // Объект-компонент с render/setup
      if (typeof type === 'object' && type !== null) {
        // onServerPrefetch — только на сервере
        if (isServer && type.serverPrefetch) {
          await type.serverPrefetch(props)
        }
    
        if (isServer && type.serverRender) {
          const rendered = type.serverRender(props, children)
          return renderToString(rendered, isServer)
        }
    
        if (!isServer && type.clientRender) {
          const rendered = type.clientRender(props, children)
          if (rendered == null) return ''
          return renderToString(rendered, isServer)
        }
    
        if (type.render) {
          const rendered = type.render(props, children)
          return renderToString(rendered, isServer)
        }
    
        return ''
      }
    
      // HTML-элемент
      const selfClosing = ['br', 'hr', 'img', 'input', 'link', 'meta']
    
      const attrs = Object.entries(props)
        .filter(([k]) => !k.startsWith('on'))
        .map(([k, v]) => {
          if (k === 'className') k = 'class'
          if (typeof v === 'boolean') return v ? k : ''
          return `${k}="${v}"`
        })
        .filter(Boolean)
        .join(' ')
    
      const attrsStr = attrs ? ' ' + attrs : ''
    
      if (selfClosing.includes(type)) {
        return `<${type}${attrsStr}>`
      }
    
      const innerParts = await Promise.all(
        children.map(c => renderToString(c, isServer))
      )
      const inner = innerParts.join('')
    
      return `<${type}${attrsStr}>${inner}</${type}>`
    }
    
    // --- Компоненты с серверной логикой ---
    
    // Компонент с onServerPrefetch
    const ProductList = {
      name: 'ProductList',
      _data: null,
      async serverPrefetch(props) {
        // Симуляция загрузки данных на сервере
        await new Promise(r => setTimeout(r, 10))
        ProductList._data = [
          { id: 1, name: 'Товар А', price: 100 },
          { id: 2, name: 'Товар Б', price: 200 },
        ]
        console.log('[SSR] onServerPrefetch: загружены продукты')
      },
      render(props) {
        const items = ProductList._data || []
        return h('ul', { class: 'products' },
          ...items.map(item =>
            h('li', { key: item.id, class: 'product' },
              h('span', { class: 'name' }, item.name),
              ' — ',
              h('span', { class: 'price' }, item.price + '₽')
            )
          )
        )
      }
    }
    
    // Компонент только для клиента (интерактивная часть)
    const ClientCounter = {
      name: 'ClientCounter',
      serverRender: () => h('div', { class: 'counter-placeholder' }, 'Загрузка...'),
      clientRender: (props) => h('div', { class: 'counter' }, `Счётчик: ${props.initialCount || 0}`),
    }
    
    // --- Рендер ---
    async function ssrRender() {
      const app = h('div', { id: 'app' },
        h('h1', {}, 'Интернет-магазин'),
        h(ProductList, {}),
        h(ClientCounter, { initialCount: 0 }),
        h('footer', { class: 'footer' }, '© 2024')
      )
    
      console.log('\n=== SSR (сервер) ===')
      const serverHtml = await renderToString(app, true)
      console.log(serverHtml)
    
      console.log('\n=== CSR (клиент) ===')
      const clientHtml = await renderToString(app, false)
      console.log(clientHtml)
    }
    
    ssrRender()