SSR: серверный и клиентский бандлы

SSR (Server-Side Rendering) — подход, при котором HTML генерируется на сервере, а затем отправляется клиенту в уже готовом виде. После загрузки браузер получает JavaScript-бандл и выполняет гидратацию интерфейса.

В инфраструктуре Webpack SSR почти всегда требует двух независимых сборок:

  • серверного бандла;
  • клиентского бандла.

Такое разделение связано с различием сред выполнения:

Среда Особенности
Сервер Node.js, отсутствуют DOM API, window, document
Клиент Браузер, доступны DOM API, события, CSSOM

Серверный код отвечает за:

  • рендеринг HTML;
  • маршрутизацию;
  • подготовку данных;
  • генерацию стартовой разметки.

Клиентский код отвечает за:

  • гидратацию;
  • интерактивность;
  • обработку событий;
  • навигацию SPA;
  • lazy loading.

Причины разделения серверного и клиентского бандлов

Разные target

Webpack должен понимать, под какую платформу создаётся сборка.

Для браузера:

target: 'web'

Для Node.js:

target: 'node'

Эти режимы влияют на:

  • генерацию require/import;
  • обработку встроенных модулей Node.js;
  • polyfill;
  • runtime Webpack;
  • систему chunk loading.

Разные зависимости

Серверный бандл может использовать:

fs
path
stream
http

Клиентский бандл не имеет доступа к таким API.

Клиентская часть может использовать:

window
document
localStorage
navigator

Серверная среда не поддерживает эти объекты.


Разный runtime

На сервере Webpack создаёт runtime под CommonJS.

В браузере используется runtime для загрузки чанков через:

  • script injection;
  • dynamic import;
  • fetch;
  • module loading.

Типовая структура проекта

project/
├── src/
│   ├── client/
│   │   └── index.js
│   ├── server/
│   │   └── server.js
│   ├── shared/
│   │   ├── App.jsx
│   │   └── routes.js
│   └── components/
├── webpack/
│   ├── webpack.client.js
│   ├── webpack.server.js
│   └── webpack.common.js
└── dist/
    ├── client/
    └── server/

Общая конфигурация

Общие настройки выносятся в отдельный файл.

// webpack.common.js

const path = require('path');

module.exports = {
  resolve: {
    extensions: ['.js', '.jsx']
  },

  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      }
    ]
  }
};

Клиентская сборка

Базовая конфигурация

// webpack.client.js

const path = require('path');
const { merge } = require('webpack-merge');

const common = require('./webpack.common');

module.exports = merge(common, {
  name: 'client',

  target: 'web',

  mode: 'production',

  entry: './src/client/index.js',

  output: {
    path: path.resolve(__dirname, '../dist/client'),
    filename: '[name].[contenthash].js',
    publicPath: '/assets/'
  }
});

Особенности клиентского бандла

Клиентская сборка:

  • содержит hydration runtime;
  • поддерживает code splitting;
  • включает CSS;
  • оптимизируется для браузера;
  • минифицируется.

Hydration

Клиент должен не создавать DOM заново, а “подцепиться” к уже существующему HTML.

Пример для React:

import React from 'react';
import { hydrateRoot } from 'react-dom/client';

import App from '../shared/App';

hydrateRoot(
  document.getElementById('root'),
  <App />
);

Серверная сборка

Базовая конфигурация

// webpack.server.js

const path = require('path');
const { merge } = require('webpack-merge');

const common = require('./webpack.common');

module.exports = merge(common, {
  name: 'server',

  target: 'node',

  mode: 'production',

  entry: './src/server/server.js',

  output: {
    path: path.resolve(__dirname, '../dist/server'),
    filename: 'server.js',
    libraryTarget: 'commonjs2'
  }
});

libraryTarget для SSR

Серверная сборка обычно экспортируется через CommonJS.

libraryTarget: 'commonjs2'

Webpack генерирует:

module.exports = ...

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


Node externals

Серверный бандл не должен включать весь node_modules внутрь сборки.

Для этого применяется externals.

Установка

npm install webpack-node-externals

Конфигурация

const nodeExternals = require('webpack-node-externals');

module.exports = {
  target: 'node',

  externals: [nodeExternals()]
};

Что делает webpack-node-externals

Без externals:

server.bundle.js
  react
  express
  lodash
  axios
  ...

С externals:

server.bundle.js
node_modules/

Webpack оставляет:

require('react')

вместо встраивания пакета внутрь бандла.


Почему externals важен

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

  • уменьшение размера серверного бандла;
  • ускорение сборки;
  • уменьшение потребления памяти;
  • ускорение cold start;
  • более стабильный stack trace.

SSR и CSS

Проблема CSS на сервере

Node.js не умеет:

import './styles.css';

Webpack должен обработать такие импорты отдельно.


null-loader для сервера

Иногда CSS полностью исключается из серверного бандла.

{
  test: /\.css$/,
  use: 'null-loader'
}

css-loader only locals

Другой подход — экспортировать только className mapping.

{
  test: /\.css$/,
  use: [
    {
      loader: 'css-loader',
      options: {
        exportOnlyLocals: true
      }
    }
  ]
}

CSS Modules и SSR

SSR должен генерировать одинаковые className:

import styles from './Button.module.css';

<button className={styles.button}>

Если сервер и клиент сгенерируют разные хэши классов:

Hydration failed

Поэтому конфигурация CSS Modules обязана совпадать.


Пример настройки CSS Modules

{
  test: /\.module\.css$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        modules: {
          localIdentName: '[hash:base64]'
        }
      }
    }
  ]
}

Разделение shared-кода

SSR требует общего слоя кода.

Обычно shared содержит:

  • компоненты;
  • роутинг;
  • store;
  • бизнес-логику;
  • API abstraction;
  • utility-функции.

Shared App

// shared/App.jsx

export default function App() {
  return <h1>SSR App</h1>;
}

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

// server.js

import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';

import App from '../shared/App';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(<App />);

  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/assets/main.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

renderToString

renderToString()

Полностью рендерит HTML строку.

Недостатки:

  • блокирующий рендер;
  • большой TTFB;
  • отсутствие streaming.

Streaming SSR

Современный React поддерживает потоковый SSR.

renderToPipeableStream()

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

  • ранняя отправка HTML;
  • progressive rendering;
  • улучшение TTFB;
  • уменьшение perceived latency.

Dynamic import и SSR

Проблема

const Admin = lazy(() => import('./Admin'));

На сервере необходимо:

  • понять, какие чанки нужны;
  • вставить script tags;
  • синхронизировать hydration.

Loadable Components

Популярное решение:

npm install @loadable/component
npm install @loadable/server

Клиентский код

import loadable from '@loadable/component';

const Admin = loadable(() => import('./Admin'));

Серверный рендер

import { ChunkExtractor } from '@loadable/server';

const extractor = new ChunkExtractor({
  statsFile: './dist/client/loadable-stats.json'
});

ChunkExtractor

Webpack генерирует mapping:

компонент -> chunk

Сервер узнаёт:

  • какие JS-файлы нужны;
  • какие CSS-файлы подключить;
  • какие preload/preload теги вставить.

webpack stats для SSR

Генерация stats

const LoadablePlugin =
  require('@loadable/webpack-plugin');

plugins: [
  new LoadablePlugin()
]

Manifest-файлы

SSR часто использует manifest.

Пример:

{
  "main.js": "/assets/main.a1b2c3.js"
}

Зачем нужен manifest

Сервер не знает contenthash заранее.

Manifest позволяет:

  • найти актуальный filename;
  • вставить правильный script;
  • подключить CSS.

WebpackManifestPlugin

npm install webpack-manifest-plugin

Конфигурация

const {
  WebpackManifestPlugin
} = require('webpack-manifest-plugin');

plugins: [
  new WebpackManifestPlugin()
]

Asset injection

Сервер читает manifest:

const manifest = require(
  '../dist/client/manifest.json'
);

const bundle = manifest['main.js'];

Далее вставляет:

<script src="/assets/main.a1b2c3.js"></script>

SSR и publicPath

Клиентская сборка обязана иметь корректный publicPath.

output: {
  publicPath: '/assets/'
}

Иначе:

  • чанки не загрузятся;
  • lazy loading перестанет работать;
  • hydration сломается.

Code splitting в SSR

Webpack может разделять:

  • routes;
  • vendor;
  • framework;
  • async modules.

SplitChunksPlugin

optimization: {
  splitChunks: {
    chunks: 'all'
  }
}

SSR и lazy hydration

Иногда гидратация выполняется частями.

Пример:

  • header — сразу;
  • comments — позже;
  • widgets — по visibility.

Это уменьшает:

  • blocking time;
  • main thread pressure;
  • startup JS cost.

Проблемы window и document

Классическая ошибка SSR:

ReferenceError: window is not defined

Причина

Код выполняется в Node.js:

window.location

Но window отсутствует.


Защита браузерного кода

if (typeof window !== 'undefined') {
  console.log(window.location.href);
}

Разделение платформенного кода

Browser-only modules

if (typeof document !== 'undefined') {
  require('./browser');
}

Conditional exports

Иногда применяются разные entry.

Button.client.js
Button.server.js

Resolve aliases

Webpack может подменять реализации.

resolve: {
  alias: {
    '@platform': path.resolve(
      __dirname,
      './platform/browser'
    )
  }
}

Для серверной сборки:

resolve: {
  alias: {
    '@platform': path.resolve(
      __dirname,
      './platform/server'
    )
  }
}

Babel и SSR

Babel обычно имеет разные target.

Клиент

[
  '@babel/preset-env',
  {
    targets: 'defaults'
  }
]

Сервер

[
  '@babel/preset-env',
  {
    targets: {
      node: 'current'
    }
  }
]

Почему это важно

Node.js поддерживает больше современных возможностей.

Серверный код:

  • меньше трансформируется;
  • быстрее выполняется;
  • имеет меньший размер.

Source maps для SSR

Серверные stack trace должны быть читаемыми.

devtool: 'source-map'

Запуск серверного бандла

После сборки:

node dist/server/server.js

Watch mode для SSR

Во время разработки:

watch: true

или:

webpack --watch

Hot Reload и SSR

SSR значительно усложняет HMR.

Необходимо синхронизировать:

  • серверный runtime;
  • клиентский runtime;
  • состояние приложения;
  • hydration boundary.

webpack-dev-middleware

Часто сервер подключается напрямую к webpack compiler.

const webpack = require('webpack');
const middleware =
  require('webpack-dev-middleware');

Memory FS

В development бандлы могут храниться в памяти.

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

  • отсутствие записи на диск;
  • быстрый rebuild;
  • ускорение HMR.

Multi Compiler Mode

Webpack поддерживает массив конфигураций.

module.exports = [
  clientConfig,
  serverConfig
];

Что делает Multi Compiler

Webpack запускает:

  • клиентскую сборку;
  • серверную сборку;

одновременно.


Имена компиляторов

{
  name: 'client'
}
{
  name: 'server'
}

Это облегчает:

  • логирование;
  • profiling;
  • диагностику ошибок.

SSR и production deployment

Обычно структура выглядит так:

dist/
  client/
    main.hash.js
    vendors.hash.js
  server/
    server.js

Разделение ответственности

Сервер

  • Express;
  • Fastify;
  • Koa;
  • NestJS;
  • Node.js runtime.

Клиент

  • hydration;
  • SPA navigation;
  • lazy chunks;
  • UI interaction.

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

Минификация

Иногда минификация отключается:

optimization: {
  minimize: false
}

Причины:

  • читаемые stack trace;
  • более простая отладка;
  • снижение CPU на build stage.

tree shaking в SSR

Tree shaking полезен и на сервере.

Особенно для:

  • utility libraries;
  • UI frameworks;
  • shared modules.

Side effects

Webpack анализирует:

{
  "sideEffects": false
}

Это позволяет удалять неиспользуемый код.


SSR и ESM

Современные SSR-системы всё чаще используют:

output: {
  module: true
}

и:

experiments: {
  outputModule: true
}

Проблемы ESM SSR

Основные сложности:

  • несовместимость пакетов;
  • различия import/require;
  • dynamic import semantics;
  • отсутствие __dirname;
  • особенности Node.js loader.

Hydration mismatch

Одна из самых тяжёлых проблем SSR.

Пример:

Text content does not match

Причины mismatch

Нестабильные данные

Math.random()
Date.now()

Разный порядок рендера

Различие environment

Browser-only conditionals


Неправильный SSR-код

const isMobile =
  window.innerWidth < 768;

Правильный подход

const isBrowser =
  typeof window !== 'undefined';

React StrictMode и SSR

StrictMode может вызывать двойной render в development.

Это приводит к:

  • неожиданным side effects;
  • повторным запросам;
  • нестабильному HTML.

Кэширование SSR

Сервер может кэшировать:

  • HTML;
  • API responses;
  • fragments;
  • rendered routes.

CDN и SSR

Обычно CDN обслуживает:

  • JS;
  • CSS;
  • images;
  • fonts.

Node.js сервер генерирует только HTML.


SSR vs CSR

SSR CSR
HTML генерируется сервером HTML строится браузером
Быстрый First Paint Дольше initial render
Лучше SEO SEO сложнее
Сложнее инфраструктура Проще архитектура
Нужен Node.js сервер Можно использовать static hosting

SSR vs SSG

SSR SSG
HTML создаётся на запрос HTML создаётся заранее
Подходит для динамики Подходит для статического контента
Нагрузка на сервер Быстрая отдача файлов
Сложнее кэширование CDN-friendly

Universal Rendering

SSR-приложения часто называют:

  • universal;
  • isomorphic applications.

Это означает:

  • один код работает и на сервере, и в браузере;
  • shared modules используются в обеих средах;
  • hydration связывает серверный HTML с клиентским runtime.