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

По умолчанию 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. Опять же, если в ConfigProvider в свойстве 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;

Применяем переданные из app/layout.tsx свойства platform и direction, типы берём из 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>,
);

Применяем переданные из server.js свойства platform и direction.

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>
  );
}