Сборка клиентского и серверного бандла

Разделение клиентской и серверной сборки

В архитектуре SSR (Server-Side Rendering) с использованием Vite ключевым моментом становится разделение итогового результата сборки на два независимых артефакта: клиентский бандл и серверный бандл. Эти два набора файлов решают разные задачи и имеют различные требования к окружению выполнения.

Клиентский бандл предназначен для браузера. Он включает в себя JavaScript, стили, статические ассеты и код гидратации, который «оживляет» уже отрендеренный на сервере HTML. Серверный бандл исполняется в Node.js или другом серверном окружении и отвечает за генерацию HTML на лету, обработку маршрутов и подготовку состояния приложения.

В Vite разделение реализуется на уровне конфигурации сборки и Rollup-пайплайна, поскольку финальная сборка в production-режиме использует Rollup как основной бандлер.


Конфигурация клиентской сборки

Клиентская сборка формируется стандартной командой:

vite build

или явно:

vite build --ssrManifest

Основной результат помещается в директорию dist/client. Типичная конфигурация:

import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    outDir: 'dist/client',
    manifest: true,
    rollupOptions: {
      input: '/src/entry-client.js'
    }
  }
})

Ключевым элементом является entry point клиента — файл, который выполняет гидратацию:

import { createApp } from './app'

createApp().mount('

В результате клиентская сборка содержит:

  • статические JS чанки
  • CSS, извлечённый из компонентов
  • ассеты (изображения, шрифты)
  • manifest.json для SSR-резолва ресурсов

Manifest используется сервером для определения, какие файлы нужно подключить в HTML.


Формирование серверного бандла

Серверная сборка запускается отдельной командой:

vite build --ssr src/entry-server.js

или через конфигурацию:

export default defineConfig({
  build: {
    ssr: 'src/entry-server.js',
    outDir: 'dist/server',
    target: 'node18',
    rollupOptions: {
      output: {
        format: 'esm'
      }
    }
  }
})

Серверный entry обычно экспортирует функцию рендера:

import { createApp } from './app'

export async function render(url) {
  const app = createApp()

  return {
    html: await app.renderToString()
  }
}

Серверный бандл отличается следующими характеристиками:

  • отсутствует DOM API
  • исключаются браузерные зависимости
  • используется Node.js ESM или CommonJS формат
  • модули внешних зависимостей часто помечаются как external

Externalизация зависимостей

При SSR важно не тащить все зависимости в серверный бандл. Vite автоматически внешизирует Node-модули:

export default defineConfig({
  ssr: {
    external: ['express']
  }
})

или отключает внешизацию:

ssr: {
  noExternal: ['some-esm-package']
}

Это позволяет гибко управлять тем, какие пакеты должны попасть в бандл, а какие будут импортироваться через Node.js напрямую.


Различия в обработке модулей

Клиентская сборка ориентирована на ESM в браузере и оптимизацию загрузки:

  • код-сплиттинг через dynamic import
  • предзагрузка модулей
  • tree-shaking

Серверная сборка ориентирована на минимизацию накладных расходов:

  • сохранение структуры модулей
  • предсказуемое выполнение
  • минимизация трансформаций

Код-сплиттинг и SSR

Vite использует Rollup для разбиения кода на чанки:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor'
          }
        }
      }
    }
  }
})

На клиенте это улучшает загрузку страниц, позволяя браузеру кэшировать стабильные зависимости.

На сервере код-сплиттинг работает иначе: слишком мелкие чанки могут ухудшить производительность из-за увеличения количества require/import операций.


SSR Manifest и привязка ресурсов

При включённой опции:

build: {
  manifest: true
}

Vite генерирует manifest.json:

{
  "src/entry-client.js": {
    "file": "assets/entry-client.8d3f2.js",
    "css": ["assets/entry-client.9a1c.css"]
  }
}

Этот файл используется сервером для вставки правильных тегов:

const manifest = JSON.parse(
  fs.readFileSync('dist/client/.vite/manifest.json', 'utf-8')
)

const entry = manifest['src/entry-client.js']

const scripts = `<script type="module" src="/${entry.file}"></script>`

Синхронизация клиентского и серверного окружения

Одной из ключевых проблем SSR является необходимость идентичного состояния на сервере и клиенте. Серверный бандл формирует HTML, а клиентский должен продолжить выполнение без расхождений.

Для этого:

  • используется одинаковый код приложения
  • общее состояние передаётся через serialized state
  • исключаются side effects при импорте модулей

Пример безопасного модуля:

export function createStore() {
  return {
    state: {}
  }
}

Небезопасный вариант:

// плохо для SSR
window.state = {}

Обработка CSS и ассетов

Vite извлекает CSS из модулей и подключает его отдельно в клиентской сборке:

  • CSS собирается в отдельные файлы
  • используется content-hash для кэширования
  • импорт CSS в JS автоматически преобразуется

Серверный бандл не содержит CSS как исполняемый код, но использует его через manifest.


Динамические импорты и SSR

Dynamic import играет ключевую роль:

const module = await import('./heavy-module.js')

На клиенте это приводит к созданию отдельного чанка. На сервере это просто асинхронная загрузка модуля в Node.js.

Важно учитывать:

  • на сервере нет prefetch/preload логики браузера
  • загрузка полностью синхронна по зависимости выполнения

Оптимизация серверного бандла

Типичные настройки:

export default defineConfig({
  build: {
    ssr: true,
    minify: false,
    sourcemap: true,
    target: 'node18'
  }
})

Отключение minify на сервере часто оправдано:

  • упрощает отладку
  • снижает стоимость холодного старта
  • уменьшает время сборки

Изоляция окружений

Ключевая задача SSR сборки — строгая изоляция:

  • браузерный API доступен только в клиенте
  • Node API доступен только в сервере
  • общие модули должны быть универсальными

Типичный паттерн:

export const isServer = typeof window === 'undefined'

Результирующая структура сборки

После выполнения сборки формируется структура:

dist/
  client/
    assets/
    manifest.json
    index.html (опционально)
  server/
    entry-server.js
    chunks/

Эта структура позволяет серверу:

  • импортировать entry-server
  • рендерить HTML
  • подставлять клиентские ассеты через manifest
  • отдавать результат браузеру для гидратации