Универсальные (SSR) бандлы: client и server конфигурации

Универсальное приложение (isomorphic/universal application) использует один и тот же код как на сервере, так и в браузере. В такой архитектуре Webpack обычно собирает два независимых бандла:

  • клиентский (client bundle)
  • серверный (server bundle)

Клиентский бандл отвечает за гидратацию интерфейса, работу браузерных API, интерактивность и загрузку чанков.

Серверный бандл используется для SSR — рендеринга HTML на стороне Node.js до отправки страницы браузеру.

Типичная структура:

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

Разделение client и server конфигураций

SSR-проект почти никогда не использует один webpack-конфиг. Причины:

  • разные target-платформы
  • разные entry points
  • разные output-файлы
  • разные оптимизации
  • разные loader-цепочки
  • разная обработка CSS и assets

Чаще всего используется следующая схема:

webpack.common.js
webpack.client.js
webpack.server.js

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

Общий конфиг содержит:

  • Babel
  • TypeScript
  • aliases
  • resolve
  • общие loaders
  • общие plugins

webpack.common.js

const path = require('path');

module.exports = {
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
    alias: {
      '@': path.resolve(__dirname, '../src')
    }
  },

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

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

Клиентская сборка ориентирована на браузер.

Основные особенности

  • target: 'web'
  • code splitting
  • lazy loading
  • extract CSS
  • работа с assets
  • минификация
  • кеширование

webpack.client.js

const path = require('path');
const { merge } = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

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

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

  target: 'web',

  mode: 'production',

  entry: {
    client: path.resolve(__dirname, '../src/client/index.js')
  },

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

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },

  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css'
    })
  ]
});

Конфигурация серверного бандла

Серверный бандл работает внутри Node.js.

Его задача:

  • импортировать React/Vue приложение
  • выполнить SSR
  • вернуть HTML

Ключевые отличия

  • target: 'node'
  • отсутствие browser polyfills
  • отключение splitChunks
  • отключение asset emission
  • минимизация CSS-обработки
  • сохранение require/import для node_modules

webpack.server.js

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

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

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

  target: 'node',

  mode: 'production',

  entry: {
    server: path.resolve(__dirname, '../src/server/server.js')
  },

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

  externals: [nodeExternals()],

  module: {
    rules: [
      {
        test: /\.css$/,
        use: 'null-loader'
      }
    ]
  }
});

Почему серверный target отличается от browser target

Webpack генерирует разный runtime-код в зависимости от target.

target: ‘web’

Добавляются:

  • browser runtime
  • JSONP loader
  • динамическая загрузка чанков
  • DOM-ориентированный bootstrap

target: ‘node’

Webpack:

  • использует require
  • не создает browser runtime
  • не внедряет chunk loader для DOM
  • работает через CommonJS

Server-Side Rendering

SSR выполняется на Node.js-сервере.

Пример:

import express from 'express';
import ReactDOMServer from 'react-dom/server';

import App from '../app/App';

const app = express();

app.use('/assets', express.static('dist/client'));

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

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

app.listen(3000);

Гидратация клиентского приложения

После SSR браузер получает готовый HTML.

Затем React/Vue “подключает” клиентскую логику.

client/index.js

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

import App from '../app/App';

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

Общий код между server и client

Универсальная архитектура предполагает общий слой:

src/shared/

Там располагаются:

  • API-клиенты
  • routes
  • redux store
  • utilities
  • validation
  • constants

Пример:

export function formatPrice(value) {
  return `${value} USD`;
}

Этот код может использоваться:

  • сервером
  • браузером
  • тестами

Проблема browser-only API

SSR-код выполняется в Node.js.

Следовательно, недоступны:

  • window
  • document
  • localStorage
  • navigator

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

const width = window.innerWidth;

Ошибка:

ReferenceError: window is not defined

Безопасная работа с window

Используется проверка окружения:

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

Либо:

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

Разделение кода по окружениям

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

Пример:

export function getStorage() {
  if (typeof window === 'undefined') {
    return null;
  }

  return window.localStorage;
}

Использование DefinePlugin

Webpack позволяет внедрять compile-time константы.

client config

const webpack = require('webpack');

plugins: [
  new webpack.DefinePlugin({
    __IS_BROWSER__: JSON.stringify(true)
  })
]

server config

plugins: [
  new webpack.DefinePlugin({
    __IS_BROWSER__: JSON.stringify(false)
  })
]

Использование:

if (__IS_BROWSER__) {
  console.log(window.location.href);
}

Externals в server bundle

На сервере часто не требуется упаковывать node_modules.

Причины:

  • уменьшение размера бандла
  • ускорение сборки
  • сохранение native require
  • совместимость с Node.js

Используется:

externals: [nodeExternals()]

Webpack оставляет:

require('express')

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


Почему externals опасны для client bundle

В браузере require('react') работать не будет.

Поэтому клиентская сборка должна содержать все зависимости:

react
react-dom
redux
axios

CSS в SSR

Стили — одна из сложнейших частей SSR.

Клиентский бандл

Обычно:

MiniCssExtractPlugin

Серверный бандл

Часто:

null-loader

или:

css-loader/locals

Почему CSS нельзя просто импортировать на сервере

Node.js не понимает:

import './style.css';

Без loader возникнет ошибка:

Unexpected token .

CSS Modules и SSR

SSR должен получать class names, но не генерировать реальные CSS-файлы.

server

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

client

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

publicPath в SSR

Сервер должен знать URL клиентских assets.

Пример:

output: {
  publicPath: '/static/'
}

Тогда:

<script src="/static/client.js"></script>

Manifest-файлы

SSR часто требует сопоставления:

logical name -> hashed filename

Например:

client.js -> client.ae7123.js

Используется:

WebpackManifestPlugin

Генерация manifest

client config

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

plugins: [
  new WebpackManifestPlugin()
]

Использование manifest на сервере

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

const script = manifest['client.js'];

Далее:

<script src="/assets/${script}"></script>

Code Splitting в SSR

Обычный dynamic import:

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

создает проблему:

  • сервер не знает, какие chunks нужны
  • HTML не содержит правильные script tags

SSR-aware splitting

Используются библиотеки:

  • loadable-components
  • react-loadable
  • @loadable/server

Пример:

import loadable from '@loadable/component';

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

Chunk extraction

Во время SSR сервер собирает список использованных чанков.

Пример:

const extractor = new ChunkExtractor({
  statsFile
});

Внедрение script tags

const jsx = extractor.collectChunks(<App />);

const html = renderToString(jsx);

const scripts = extractor.getScriptTags();

Asset Modules в SSR

Webpack 5 использует:

type: 'asset/resource'

На сервере assets обычно:

  • не эмитятся
  • заменяются путями
  • игнорируются

emit: false

Пример:

{
  test: /\.(png|jpg)$/,

  type: 'asset/resource',

  generator: {
    emit: false
  }
}

Source maps для SSR

Server-side stack traces должны быть читаемыми.

Используется:

devtool: 'source-map'

Раздельные Babel-конфигурации

Иногда клиент и сервер используют разные targets.

client

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

server

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

Tree Shaking в SSR

Server bundle тоже может использовать tree shaking.

Однако:

  • CommonJS ухудшает tree shaking
  • ES Modules работают эффективнее
  • sideEffects влияет и на server bundle

Оптимизация размера server bundle

Распространенные подходы:

  • externals
  • минимизация polyfills
  • отключение splitChunks
  • отключение asset emission
  • selective imports

splitChunks для server bundle

Чаще всего отключается:

optimization: {
  splitChunks: false
}

Причина:

  • Node.js быстрее работает с одним файлом
  • SSR не нуждается в browser chunk loading

Разделение runtime

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

optimization: {
  runtimeChunk: 'single'
}

Серверная:

optimization: {
  runtimeChunk: false
}

Hot Reload и SSR

Во время разработки используются:

  • webpack-dev-middleware
  • webpack-hot-middleware
  • webpack-dev-server
  • nodemon

Часто:

  • client rebuild выполняется отдельно
  • server rebuild выполняется отдельно
  • Node.js-процесс перезапускается автоматически

MultiCompiler

Webpack может запускать обе сборки одновременно.

module.exports = [
  clientConfig,
  serverConfig
];

Webpack создает:

  • client compilation
  • server compilation

в рамках одного процесса.


Разделение output директорий

Типичная структура:

dist/
├── client/
│   ├── client.js
│   └── styles.css
└── server/
    └── server.js

Причины:

  • разные targets
  • разные assets
  • разные runtime
  • независимый deployment

SSR и production deployment

В production обычно деплоятся:

Клиент

dist/client

в CDN или nginx.

Сервер

dist/server/server.js

в Node.js runtime.


Частые ошибки SSR-сборок

Использование window/document

ReferenceError: window is not defined

Неправильный publicPath

404 on chunks

Несовпадение HTML

Hydration failed

Использование browser-only библиотек

navigator is not defined

Разные render results

Text content does not match server-rendered HTML

Причины hydration mismatch

Сервер и браузер должны рендерить одинаковую разметку.

Проблемный код:

const id = Math.random();

Сервер:

0.152

Браузер:

0.981

React обнаружит несовпадение.


Изоляция SSR-логики

Хорошая практика:

src/
├── client/
├── server/
├── shared/
└── app/

Где:

  • client — browser bootstrap
  • server — SSR runtime
  • shared — общий код
  • app — UI и бизнес-логика

Типичный pipeline SSR-сборки

Этап 1

Webpack собирает browser bundle.

Этап 2

Webpack собирает Node.js bundle.

Этап 3

Node.js запускает server bundle.

Этап 4

SSR генерирует HTML.

Этап 5

Браузер загружает client bundle.

Этап 6

Происходит hydration.