Серверный рендеринг
По умолчанию VKUI-компоненты рендерятся одинаково на клиенте и на сервере, но есть ряд нюансов. Ниже разобраны особенности, которые нужно учитывать при настройке SSR.
Примечание к адаптивности
В документации по адаптивности описано, как компоненты меняют вёрстку и поведение в зависимости от параметров адаптивности. Возникает вопрос: «Как это сочетается с SSR?»
Чтобы вёрстка была одинаковой на клиенте и на сервере, мы жертвуем размером DOM — компонент всегда рендерит и мобильную, и настольную версии, а через CSS показывает нужную. Исключение — всплывающие окна.
Всплывающие окна
Контент ModalPage, ModalCard, Alert, Popover, Tooltip и подобных компонентов не нужен для первого рендера, поэтому по умолчанию мы не рендерим его на сервере. Это упрощает вёрстку: переключаться между мобильной и настольной версиями можно медиавыражениями.
У некоторых таких компонентов есть свойство keepMounted — его включение может привести к ошибкам гидратации. Оно оправдано, если клиент всегда в одной версии — мобильной или настольной. Также версию можно зашить, обернув компонент в AdaptivityProvider:
import { AdaptivityProvider, ViewWidth, ModalPage } from '@vkontakte/vkui';
const App = () => {
return (
<AdaptivityProvider viewWidth={ViewWidth.MOBILE}>
<ModalPage open keepMounted>
Я мобильное модальное окно. И буду таким всегда!
</ModalPage>
</AdaptivityProvider>
);
};Что нужно сделать?
Прежде чем перейти к примерам, перечислим задачи, которые нужно решить.
Подготовка HTML-страницы
Шаг необязательный, но он снижает reflow ↗ страницы после гидратации. Добавьте VKUI-классы в базовый HTML-шаблон:
class="vkui"на<html>;class="vkui__root"на точку монтирования (например,<div id="root">или<body>в случае Next.js).
Позже отключим установку этих атрибутов компонентом AppRoot через свойство disableSettingVKUIClassesInRuntime.
Определение платформы и направления текста на сервере
VKUI умеет мимикрировать под платформы android и iOS. На клиенте, если в ConfigProvider не задано свойство platform, платформа определяется автоматически по navigator.userAgent. То же самое с направлением текста ltr/rtl: если не задано свойство direction, оно определяется через браузерное API.
На клиенте платформу и направление вычислить просто, на сервере это требует дополнительных усилий. Например, платформу можно определить по HTTP-заголовку User-Agent, а направление языка — по Accept-Language.
Примеры
Примеры для Next.js и Express. Для простоты предполагаем базовую установку библиотеки (см. Установка).
Next.js
Стартовая структура из документации Next.js (на момент написания примера последняя версия — 15.3.2).
/app
└─ layout.tsx
└─ page.tsx
/client
└─ Layout.tsx
└─ Page.tsxПредставим наполнение каждого файла.
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { detectIOS } from '@vkontakte/vkjs';
import '@vkontakte/vkui/dist/vkui.css';
import { Layout } from '../client/Layout';
export const metadata: Metadata = { title: 'SSR-ready!' };
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
userScalable: false,
viewportFit: 'cover',
};
export default async function Root({ children }: React.PropsWithChildren) {
const headersList = await headers();
// Определяем платформу
const userAgent = headersList.get('user-agent') || '';
const platform = detectIOS(userAgent).isIOS ? 'ios' : 'android';
// Определяем направление текста
const acceptLanguage = headersList.get('accept-language') || 'en-US';
const lang = acceptLanguage.split('-')[0];
const direction = ['ar', 'he', 'fa', 'ur'].includes(lang) ? 'rtl' : 'ltr';
return (
<html lang={lang} dir={direction} className="vkui">
<body className="vkui__root">
<Layout platform={platform} direction={direction}>
{children}
</Layout>
</body>
</html>
);
}import { Page } from '../client/Page';
export default Page;Передаём platform и direction из app/layout.tsx (типы берём из ConfigProviderProps) и включаем disableSettingVKUIClassesInRuntime у AppRoot.
'use client';
import {
type ConfigProviderProps,
ConfigProvider,
AdaptivityProvider,
AppRoot,
} from '@vkontakte/vkui';
type LayoutProps = Pick<ConfigProviderProps, 'platform' | 'direction'> & React.PropsWithChildren;
export function Layout({ platform, direction, children }: LayoutProps) {
return (
<ConfigProvider platform={platform} direction={direction}>
<AdaptivityProvider>
<AppRoot disableSettingVKUIClassesInRuntime>{children}</AppRoot>
</AdaptivityProvider>
</ConfigProvider>
);
}'use client';
import { SelectionControl, Switch, Flex } from '@vkontakte/vkui';
export function Page() {
return (
<div style={{ width: 320, padding: 24, margin: 'auto' }}>
<SelectionControl>
<SelectionControl.Label>Ознакомлен</SelectionControl.Label>
<Switch />
</SelectionControl>
</div>
);
}Express
Возьмём шаблон https://github.com/bluwy/create-vite-extra/tree/master/template-ssr-react ↗ и перепишем в нём следующие файлы.
/
└─ index.html
└─ server.tsx
/src
└─ App.jsx
└─ entry-client.jsx
└─ entry-server.jsx<!doctype html>
<html {{ lang }} {{ dir }} class="vkui">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
/>
<title>SSR-ready!</title>
<link rel="stylesheet" href="./node_modules/@vkontakte/vkui/dist/vkui.css" />
<!--app-head-->
</head>
<body>
<div id="root" class="vkui__root"><!--app-html--></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>import fs from 'node:fs/promises';
import express from 'express';
import { detectIOS } from '@vkontakte/vkjs';
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const base = process.env.BASE || '/';
const templateHtml = isProduction ? await fs.readFile('./dist/client/index.html', 'utf-8') : '';
const app = express();
let vite;
if (!isProduction) {
const { createServer } = await import('vite');
vite = await createServer({
server: { middlewareMode: true },
appType: 'custom',
base,
});
app.use(vite.middlewares);
} else {
const compression = (await import('compression')).default;
const sirv = (await import('sirv')).default;
app.use(compression());
app.use(base, sirv('./dist/client', { extensions: [] }));
}
app.use('*all', async (req, res) => {
try {
// Определяем платформу
const userAgent = req.headers['user-agent'] || '';
const platform = detectIOS(userAgent).isIOS ? 'ios' : 'android';
// Определяем направление текста
const acceptLanguage = req.headers['accept-language'] || 'en-US';
const lang = acceptLanguage.split('-')[0];
const direction = ['ar', 'he', 'fa', 'ur'].includes(lang) ? 'rtl' : 'ltr';
const url = req.originalUrl.replace(base, '');
let template;
let render;
if (!isProduction) {
template = await fs.readFile('./index.html', 'utf-8');
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule('/src/entry-server.jsx')).render;
} else {
template = templateHtml;
render = (await import('./dist/server/entry-server.js')).render;
}
const rendered = await render(url, platform, direction);
const html = template
.replace(`{{ lang }}`, `lang=${lang}`)
.replace(`{{ dir }}`, `dir=${direction}`)
.replace(`<!--app-head-->`, rendered.head ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '');
res.status(200).set({ 'Content-Type': 'text/html' }).send(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});
app.listen(port, () => {
console.log(`Server started at http://localhost:${port}`);
});import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ConfigProvider } from '@vkontakte/vkui';
import App from './App';
hydrateRoot(
document.getElementById('root'),
<StrictMode>
<ConfigProvider>
<App />
</ConfigProvider>
</StrictMode>,
);Передаём platform и direction из server.js.
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { ConfigProvider } from '@vkontakte/vkui';
import App from './App';
export function render(_url, platform, direction) {
const html = renderToString(
<StrictMode>
<ConfigProvider platform={platform} direction={direction}>
<App />
</ConfigProvider>
</StrictMode>,
);
return { html };
}Включаем disableSettingVKUIClassesInRuntime у AppRoot.
import { AppRoot, SelectionControl, Switch } from '@vkontakte/vkui';
export default function App() {
return (
<AppRoot disableSettingVKUIClassesInRuntime>
<div style={{ width: 320, padding: 24, margin: 'auto' }}>
<SelectionControl>
<SelectionControl.Label>Ознакомлен</SelectionControl.Label>
<Switch />
</SelectionControl>
</div>
</AppRoot>
);
}