Перейти к содержимому

Серверный рендеринг

По умолчанию 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>
  );
};

Прежде чем перейти к примерам, перечислим задачи, которые нужно решить.

Шаг необязательный, но он снижает 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 (на момент написания примера последняя версия — 15.3.2).

/app
  └─ layout.tsx
  └─ page.tsx
/client
  └─ Layout.tsx
  └─ Page.tsx

Представим наполнение каждого файла.

app/layout.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>
  );
}
app/page.tsx
import { Page } from '../client/Page';
 
export default Page;

Передаём platform и direction из app/layout.tsx (типы берём из ConfigProviderProps) и включаем disableSettingVKUIClassesInRuntime у AppRoot.

client/Layout.tsx
'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>
  );
}
client/Pages.tsx
'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>
  );
}

Возьмём шаблон 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
index.html
<!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>
server.js
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}`);
});
src/entry-client.jsx
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.

src/entry-server.jsx
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.

src/App.tsx
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>
  );
}