Синхронизация клиента и сервера

Universal Router — это легковесная библиотека для маршрутизации в JavaScript, которая позволяет создавать маршруты как на клиенте, так и на сервере, обеспечивая единообразное поведение приложения. Синхронизация между клиентской и серверной частью является ключевым аспектом при построении универсальных приложений, где один и тот же код маршрутов используется как для серверного рендеринга (SSR), так и для клиентского SPA.


Определение маршрутов для универсального использования

Маршруты в Universal Router определяются в виде массива объектов или дерева объектов с обязательным свойством path и функцией action. Для синхронизации важно, чтобы структура маршрутов была одинаковой на сервере и клиенте:

const routes = [
  {
    path: '/',
    action: () => ({ page: 'home' })
  },
  {
    path: '/users/:id',
    action: ({ params }) => fetchUserData(params.id)
  },
  {
    path: '(.*)',
    action: () => ({ page: 'not-found' })
  }
];
  • path — строка маршрута, поддерживает динамические сегменты (:id) и регулярные выражения.
  • action — функция, которая выполняется при совпадении маршрута и может возвращать объект данных или промис.

Использование одинаковых маршрутов на клиенте и сервере гарантирует, что серверный рендеринг отобразит правильное состояние страницы до того, как клиент возьмёт управление.


Работа на сервере

На сервере Universal Router часто применяется совместно с Node.js и Express. Основная задача сервера — отрендерить HTML с данными, которые соответствуют текущему маршруту:

import express from 'express';
import UniversalRouter from 'universal-router';
import routes from './routes.js';

const app = express();

app.get('*', async (req, res) => {
  const router = new UniversalRouter(routes);
  try {
    const result = await router.resolve({ pathname: req.path });
    res.send(renderHTML(result));
  } catch (err) {
    res.status(500).send('Server Error');
  }
});
  • router.resolve({ pathname }) — основной метод, который находит маршрут, соответствующий запрошенному пути, и выполняет его action.
  • Асинхронная обработка позволяет возвращать данные с внешних API и обеспечивать готовность HTML с уже загруженными данными.

Важно, чтобы серверный маршрут возвращал данные в формате, который клиент сможет повторно использовать при гидрации.


Работа на клиенте

На клиенте Universal Router обеспечивает переходы между страницами без полной перезагрузки. Используется объект window.history и метод router.resolve:

import UniversalRouter from 'universal-router';
import routes from './routes.js';

const router = new UniversalRouter(routes);

async function navigate(pathname) {
  const result = await router.resolve({ pathname });
  renderPage(result);
  window.history.pushState({}, '', pathname);
}

window.addEventListener('popstate', () => {
  navigate(window.location.pathname);
});
  • pushState и обработка события popstate позволяют управлять историей браузера.
  • Асинхронные action маршрутов позволяют предварительно загружать данные перед отображением страницы.

Синхронизация достигается через единый набор маршрутов, что гарантирует одинаковую обработку как на сервере, так и на клиенте.


Передача данных между сервером и клиентом

Для эффективной синхронизации важно передавать серверные данные на клиент без дублирования запросов. Обычно это делается через внедрение данных в HTML:

<script>
  window.__INITIAL_DATA__ = {{ JSON.stringify(result) }};
</script>

На клиенте эти данные можно использовать при первом рендере:

const initialData = window.__INITIAL_DATA__;
renderPage(initialData);

Такой подход снижает задержки и предотвращает повторные вызовы API, обеспечивая согласованное состояние приложения.


Обработка ошибок и маршрутов по умолчанию

Синхронизация также требует единого подхода к обработке ошибок:

const routes = [
  {
    path: '/users/:id',
    action: async ({ params }) => {
      try {
        return await fetchUserData(params.id);
      } catch (err) {
        return { page: 'error', message: 'Failed to fetch user' };
      }
    }
  },
  { path: '(.*)', action: () => ({ page: 'not-found' }) }
];
  • Глобальные catch-блоки и маршруты-заглушки ((.*)) позволяют унифицировать обработку несуществующих маршрутов и ошибок API как на сервере, так и на клиенте.

Оптимизация и кэширование

Для больших приложений рекомендуется использовать кэширование данных, получаемых на сервере, чтобы минимизировать сетевые запросы при первом рендере на клиенте. Можно комбинировать Universal Router с клиентскими хранилищами (Redux, Zustand) или простыми объектами:

const cache = {};

async function fetchUserData(id) {
  if (cache[id]) return cache[id];
  const data = await fetch(`/api/users/${id}`).then(res => res.json());
  cache[id] = data;
  return data;
}

Кэширование обеспечивает согласованность данных между сервером и клиентом, ускоряя рендеринг и улучшая пользовательский опыт.


Итоги по синхронизации

  • Универсальные маршруты обеспечивают одинаковое поведение на сервере и клиенте.
  • Асинхронные action маршрутов позволяют загружать данные до отображения страницы.
  • Передача серверных данных в клиентскую среду предотвращает лишние API-запросы.
  • Обработка ошибок и маршруты по умолчанию должны быть идентичны на обеих сторонах.
  • Кэширование данных ускоряет синхронизацию и повышает стабильность приложения.

Сочетание этих методов делает Universal Router эффективным инструментом для построения универсальных JavaScript-приложений с полностью синхронизированным состоянием.