SSR (Server-Side Rendering) — подход, при котором HTML генерируется на сервере, а затем отправляется клиенту в уже готовом виде. После загрузки браузер получает JavaScript-бандл и выполняет гидратацию интерфейса.
В инфраструктуре Webpack SSR почти всегда требует двух независимых сборок:
Такое разделение связано с различием сред выполнения:
| Среда | Особенности |
|---|---|
| Сервер | Node.js, отсутствуют DOM API, window, document |
| Клиент | Браузер, доступны DOM API, события, CSSOM |
Серверный код отвечает за:
Клиентский код отвечает за:
Webpack должен понимать, под какую платформу создаётся сборка.
Для браузера:
target: 'web'
Для Node.js:
target: 'node'
Эти режимы влияют на:
Серверный бандл может использовать:
fs
path
stream
http
Клиентский бандл не имеет доступа к таким API.
Клиентская часть может использовать:
window
document
localStorage
navigator
Серверная среда не поддерживает эти объекты.
На сервере Webpack создаёт runtime под CommonJS.
В браузере используется runtime для загрузки чанков через:
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/'
}
});
Клиентская сборка:
Клиент должен не создавать 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'
}
});
Серверная сборка обычно экспортируется через CommonJS.
libraryTarget: 'commonjs2'
Webpack генерирует:
module.exports = ...
Это позволяет запускать серверный бандл напрямую в Node.js.
Серверный бандл не должен включать весь node_modules внутрь сборки.
Для этого применяется externals.
npm install webpack-node-externals
const nodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
externals: [nodeExternals()]
};
Без externals:
server.bundle.js
react
express
lodash
axios
...
С externals:
server.bundle.js
node_modules/
Webpack оставляет:
require('react')
вместо встраивания пакета внутрь бандла.
Преимущества:
Node.js не умеет:
import './styles.css';
Webpack должен обработать такие импорты отдельно.
Иногда CSS полностью исключается из серверного бандла.
{
test: /\.css$/,
use: 'null-loader'
}
Другой подход — экспортировать только className mapping.
{
test: /\.css$/,
use: [
{
loader: 'css-loader',
options: {
exportOnlyLocals: true
}
}
]
}
SSR должен генерировать одинаковые className:
import styles from './Button.module.css';
<button className={styles.button}>
Если сервер и клиент сгенерируют разные хэши классов:
Hydration failed
Поэтому конфигурация CSS Modules обязана совпадать.
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[hash:base64]'
}
}
}
]
}
SSR требует общего слоя кода.
Обычно shared содержит:
// shared/App.jsx
export default function App() {
return <h1>SSR App</h1>;
}
// 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()
Полностью рендерит HTML строку.
Недостатки:
Современный React поддерживает потоковый SSR.
renderToPipeableStream()
Преимущества:
const Admin = lazy(() => import('./Admin'));
На сервере необходимо:
Популярное решение:
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'
});
Webpack генерирует mapping:
компонент -> chunk
Сервер узнаёт:
const LoadablePlugin =
require('@loadable/webpack-plugin');
plugins: [
new LoadablePlugin()
]
SSR часто использует manifest.
Пример:
{
"main.js": "/assets/main.a1b2c3.js"
}
Сервер не знает contenthash заранее.
Manifest позволяет:
npm install webpack-manifest-plugin
const {
WebpackManifestPlugin
} = require('webpack-manifest-plugin');
plugins: [
new WebpackManifestPlugin()
]
Сервер читает manifest:
const manifest = require(
'../dist/client/manifest.json'
);
const bundle = manifest['main.js'];
Далее вставляет:
<script src="/assets/main.a1b2c3.js"></script>
Клиентская сборка обязана иметь корректный publicPath.
output: {
publicPath: '/assets/'
}
Иначе:
Webpack может разделять:
optimization: {
splitChunks: {
chunks: 'all'
}
}
Иногда гидратация выполняется частями.
Пример:
Это уменьшает:
Классическая ошибка SSR:
ReferenceError: window is not defined
Код выполняется в Node.js:
window.location
Но window отсутствует.
if (typeof window !== 'undefined') {
console.log(window.location.href);
}
if (typeof document !== 'undefined') {
require('./browser');
}
Иногда применяются разные entry.
Button.client.js
Button.server.js
Webpack может подменять реализации.
resolve: {
alias: {
'@platform': path.resolve(
__dirname,
'./platform/browser'
)
}
}
Для серверной сборки:
resolve: {
alias: {
'@platform': path.resolve(
__dirname,
'./platform/server'
)
}
}
Babel обычно имеет разные target.
[
'@babel/preset-env',
{
targets: 'defaults'
}
]
[
'@babel/preset-env',
{
targets: {
node: 'current'
}
}
]
Node.js поддерживает больше современных возможностей.
Серверный код:
Серверные stack trace должны быть читаемыми.
devtool: 'source-map'
После сборки:
node dist/server/server.js
Во время разработки:
watch: true
или:
webpack --watch
SSR значительно усложняет HMR.
Необходимо синхронизировать:
Часто сервер подключается напрямую к webpack compiler.
const webpack = require('webpack');
const middleware =
require('webpack-dev-middleware');
В development бандлы могут храниться в памяти.
Преимущества:
Webpack поддерживает массив конфигураций.
module.exports = [
clientConfig,
serverConfig
];
Webpack запускает:
одновременно.
{
name: 'client'
}
{
name: 'server'
}
Это облегчает:
Обычно структура выглядит так:
dist/
client/
main.hash.js
vendors.hash.js
server/
server.js
Иногда минификация отключается:
optimization: {
minimize: false
}
Причины:
Tree shaking полезен и на сервере.
Особенно для:
Webpack анализирует:
{
"sideEffects": false
}
Это позволяет удалять неиспользуемый код.
Современные SSR-системы всё чаще используют:
output: {
module: true
}
и:
experiments: {
outputModule: true
}
Основные сложности:
Одна из самых тяжёлых проблем SSR.
Пример:
Text content does not match
Math.random()
Date.now()
const isMobile =
window.innerWidth < 768;
const isBrowser =
typeof window !== 'undefined';
StrictMode может вызывать двойной render в development.
Это приводит к:
Сервер может кэшировать:
Обычно CDN обслуживает:
Node.js сервер генерирует только HTML.
| SSR | CSR |
|---|---|
| HTML генерируется сервером | HTML строится браузером |
| Быстрый First Paint | Дольше initial render |
| Лучше SEO | SEO сложнее |
| Сложнее инфраструктура | Проще архитектура |
| Нужен Node.js сервер | Можно использовать static hosting |
| SSR | SSG |
|---|---|
| HTML создаётся на запрос | HTML создаётся заранее |
| Подходит для динамики | Подходит для статического контента |
| Нагрузка на сервер | Быстрая отдача файлов |
| Сложнее кэширование | CDN-friendly |
SSR-приложения часто называют:
Это означает: