Server-Side Rendering в связке Vite и Express строится на разделении ответственности: Vite отвечает за сборку клиентского и серверного бандлов, а Express предоставляет HTTP-слой, через который выполняется рендеринг HTML на сервере и доставка статических ресурсов.
Ключевая идея SSR в Vite заключается в том, что один и тот же код приложения используется в двух средах:
Это требует явного разделения точек входа и аккуратного управления зависимостями, чтобы избежать попадания браузерных API в серверный контекст и наоборот.
Типичная структура проекта с Vite SSR и Express выглядит следующим образом:
project/
src/
entry-client.js
entry-server.js
App.vue / App.jsx / App.tsx
server/
index.js
vite.config.js
package.json
entry-client
entry-server
server/index.js
Express используется как слой обработки HTTP-запросов и точка интеграции Vite в режиме разработки и продакшена.
import express from 'express';
const app = express();
app.use(express.static('dist/client'));
app.get('*', async (req, res) => {
const url = req.originalUrl;
const html = await renderApp(url);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});
app.listen(3000);
На этом уровне Express просто делегирует рендеринг функции
renderApp, которая будет подключена к Vite SSR API.
В режиме разработки Vite запускается как middleware внутри Express. Это позволяет:
import express from 'express';
import { createServer as createViteServer } from 'vite';
async function startServer() {
const app = express();
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
});
app.use(vite.middlewares);
app.use('*', async (req, res) => {
const url = req.originalUrl;
let template = await vite.transformIndexHtml(
url,
'<html><body><div id="app"></div></body></html>'
);
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
const appHtml = await render(url);
const html = template.replace('<!--app-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});
app.listen(3000);
}
startServer();
Vite SSR требует два независимых бандла:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
ssrManifest: true,
outDir: 'dist/client'
},
ssr: {
noExternal: []
}
});
Для серверной сборки:
export default defineConfig({
build: {
ssr: 'src/entry-server.js',
outDir: 'dist/server'
}
});
Файл entry-server.js отвечает за генерацию HTML на
основе URL.
import { createApp } from './app';
export async function render(url) {
const { app, router } = createApp();
router.push(url);
await router.isReady();
const html = await renderToString(app);
return html;
}
window и
documentimport { createApp } from './app';
const { app, router } = createApp();
router.isReady().then(() => {
app.mount('#app');
});
Клиент не перерисовывает DOM, а «подхватывает» уже существующий HTML.
Ключевой момент интеграции — функция, которая объединяет:
async function renderApp(url, vite) {
const template = await vite.transformIndexHtml(
url,
await fs.readFile('./index.html', 'utf-8')
);
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
const appHtml = await render(url);
return template.replace('<!--app-->', appHtml);
}
Vite использует index.html как часть сборочного
процесса:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>SSR App</title>
</head>
<body>
<div id="app"><!--app--></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
Метка <!--app--> служит точкой вставки
SSR-HTML.
ssrLoadModuledist/server/entry-server.jsimport express from 'express';
import fs from 'fs';
const app = express();
const template = fs.readFileSync('./dist/client/index.html', 'utf-8');
const { render } = await import('./dist/server/entry-server.js');
app.use('/assets', express.static('./dist/client/assets'));
app.get('*', async (req, res) => {
const url = req.originalUrl;
const appHtml = await render(url);
const html = template.replace('<!--app-->', appHtml);
res.status(200).setHeader('Content-Type', 'text/html').end(html);
});
app.listen(3000);
SSR требует синхронизации состояния между сервером и браузером.
const state = {
user: { name: 'John' }
};
const html = `
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(state)}
</script>
`;
const state = window.__INITIAL_STATE__;
Важно избегать утечек чувствительных данных при сериализации.
Роутер должен работать одинаково в серверной и клиентской среде.
Пример:
router.push(url);
await router.isReady();
Ошибки рендеринга должны возвращать корректный HTTP-статус.
app.get('*', async (req, res) => {
try {
const html = await renderApp(req.url);
res.status(200).end(html);
} catch (err) {
res.status(500).end('Server Error');
}
});
В продакшене важно логировать стек ошибок отдельно от ответа пользователю.
Vite автоматически разбивает клиентский бандл, снижая время загрузки.
SSR может добавлять preload-скрипты в HTML:
<link rel="modulepreload" href="/assets/entry-client.js">
Серверный код должен быть максимально лёгким:
ssr.noExternalНекоторые библиотеки нельзя бандлить в SSR:
export default defineConfig({
ssr: {
external: ['some-native-lib']
}
});
Это важно для:
windowExpress позволяет расширять SSR пайплайн:
app.use(authMiddleware);
app.use(i18nMiddleware);
app.use(ssrHandler);
SSR-слой становится последним звеном цепочки, получая уже подготовленный контекст запроса.
Часто SSR требует передачи request-specific данных:
const render = (url, context) => {
return renderToString(app, {
url,
context
});
};
Контекст может включать:
Vite может использоваться с потоковым рендерингом:
import { renderToNodeStream } from 'vue/server-renderer';
const stream = renderToNodeStream(app);
stream.pipe(res);
Преимущества:
Vite:
Express:
Архитектура строится вокруг идеи, что Vite не является сервером приложений, а Express не занимается сборкой кода.