Универсальное приложение (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/
SSR-проект почти никогда не использует один webpack-конфиг. Причины:
Чаще всего используется следующая схема:
webpack.common.js
webpack.client.js
webpack.server.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'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.
Его задача:
target: 'node'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'
}
]
}
});
Webpack генерирует разный runtime-код в зависимости от
target.
Добавляются:
Webpack:
requireSSR выполняется на 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 “подключает” клиентскую логику.
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from '../app/App';
hydrateRoot(
document.getElementById('root'),
<App />
);
Универсальная архитектура предполагает общий слой:
src/shared/
Там располагаются:
Пример:
export function formatPrice(value) {
return `${value} USD`;
}
Этот код может использоваться:
SSR-код выполняется в Node.js.
Следовательно, недоступны:
windowdocumentlocalStoragenavigatorНеправильный код:
const width = window.innerWidth;
Ошибка:
ReferenceError: window is not defined
Используется проверка окружения:
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;
}
Webpack позволяет внедрять compile-time константы.
const webpack = require('webpack');
plugins: [
new webpack.DefinePlugin({
__IS_BROWSER__: JSON.stringify(true)
})
]
plugins: [
new webpack.DefinePlugin({
__IS_BROWSER__: JSON.stringify(false)
})
]
Использование:
if (__IS_BROWSER__) {
console.log(window.location.href);
}
На сервере часто не требуется упаковывать
node_modules.
Причины:
Используется:
externals: [nodeExternals()]
Webpack оставляет:
require('express')
вместо включения Express внутрь бандла.
В браузере require('react') работать не будет.
Поэтому клиентская сборка должна содержать все зависимости:
react
react-dom
redux
axios
Стили — одна из сложнейших частей SSR.
Обычно:
MiniCssExtractPlugin
Часто:
null-loader
или:
css-loader/locals
Node.js не понимает:
import './style.css';
Без loader возникнет ошибка:
Unexpected token .
SSR должен получать class names, но не генерировать реальные CSS-файлы.
{
test: /\.module\.css$/,
use: {
loader: 'css-loader',
options: {
exportOnlyLocals: true,
modules: true
}
}
}
{
test: /\.module\.css$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true
}
}
]
}
Сервер должен знать URL клиентских assets.
Пример:
output: {
publicPath: '/static/'
}
Тогда:
<script src="/static/client.js"></script>
SSR часто требует сопоставления:
logical name -> hashed filename
Например:
client.js -> client.ae7123.js
Используется:
WebpackManifestPlugin
const { WebpackManifestPlugin } =
require('webpack-manifest-plugin');
plugins: [
new WebpackManifestPlugin()
]
const manifest = require(
'../dist/client/manifest.json'
);
const script = manifest['client.js'];
Далее:
<script src="/assets/${script}"></script>
Обычный dynamic import:
const Page = lazy(() => import('./Page'));
создает проблему:
Используются библиотеки:
Пример:
import loadable from '@loadable/component';
const ProfilePage = loadable(
() => import('./ProfilePage')
);
Во время SSR сервер собирает список использованных чанков.
Пример:
const extractor = new ChunkExtractor({
statsFile
});
const jsx = extractor.collectChunks(<App />);
const html = renderToString(jsx);
const scripts = extractor.getScriptTags();
Webpack 5 использует:
type: 'asset/resource'
На сервере assets обычно:
Пример:
{
test: /\.(png|jpg)$/,
type: 'asset/resource',
generator: {
emit: false
}
}
Server-side stack traces должны быть читаемыми.
Используется:
devtool: 'source-map'
Иногда клиент и сервер используют разные targets.
[
'@babel/preset-env',
{
targets: 'defaults'
}
]
[
'@babel/preset-env',
{
targets: {
node: 'current'
}
}
]
Server bundle тоже может использовать tree shaking.
Однако:
Распространенные подходы:
Чаще всего отключается:
optimization: {
splitChunks: false
}
Причина:
Клиентская сборка:
optimization: {
runtimeChunk: 'single'
}
Серверная:
optimization: {
runtimeChunk: false
}
Во время разработки используются:
Часто:
Webpack может запускать обе сборки одновременно.
module.exports = [
clientConfig,
serverConfig
];
Webpack создает:
в рамках одного процесса.
Типичная структура:
dist/
├── client/
│ ├── client.js
│ └── styles.css
└── server/
└── server.js
Причины:
В production обычно деплоятся:
dist/client
в CDN или nginx.
dist/server/server.js
в Node.js runtime.
ReferenceError: window is not defined
404 on chunks
Hydration failed
navigator is not defined
Text content does not match server-rendered HTML
Сервер и браузер должны рендерить одинаковую разметку.
Проблемный код:
const id = Math.random();
Сервер:
0.152
Браузер:
0.981
React обнаружит несовпадение.
Хорошая практика:
src/
├── client/
├── server/
├── shared/
└── app/
Где:
client — browser bootstrapserver — SSR runtimeshared — общий кодapp — UI и бизнес-логикаWebpack собирает browser bundle.
Webpack собирает Node.js bundle.
Node.js запускает server bundle.
SSR генерирует HTML.
Браузер загружает client bundle.
Происходит hydration.