Роутинг в SPA

Зачем нужно

Роутинг (маршрутизация) в SPA — это механизм сопоставления URL с конкретным представлением без перезагрузки страницы. Поскольку SPA — это одна HTML-страница, роутер берёт на себя задачу серверной навигации: определяет, что показать пользователю в зависимости от адреса.

Где используется

  • Навигация между разделами SPA-приложения
  • Вложенные маршруты (dashboard → settings → profile)
  • Защищённые маршруты (проверка авторизации)
  • Lazy loading — загрузка кода страницы по требованию
  • Deep linking — прямая ссылка на конкретное состояние приложения

Два подхода к роутингу

Hash Routing (#)

Использует window.location.hash — часть URL после #:

https://example.com/#/about
https://example.com/#/users/42
// Hash-роутер
class HashRouter {
  constructor {
    this.routes = {};
    // hashchange срабатывает при изменении #
    window.addEventListener('hashchange', () => this.handleRoute);
    window.addEventListener('load', () => this.handleRoute);
  }

  addRoute(hash, handler) {
    this.routes[hash] = handler;
  }

  handleRoute {
    // Убираем # из начала
    const hash = window.location.hash.slice(1) || '/';
    const handler = this.routes[hash];

    if (handler) {
      handler;
    } else {
      this.routes['/404']?.;
    }
  }

  navigate(hash) {
    window.location.hash = hash;
    // hashchange сработает автоматически
  }
}

// Использование
const router = new HashRouter();
const app = document.getElementById('app');

router.addRoute('/', () => {
  app.innerHTML = '<h1>Главная</h1>';
});

router.addRoute('/about', () => {
  app.innerHTML = '<h1>О нас</h1>';
});

router.addRoute('/404', () => {
  app.innerHTML = '<h1>Страница не найдена</h1>';
});

Плюсы hash-роутинга:

  • Работает без настройки сервера
  • Прост в реализации
  • Совместим со старыми браузерами

Минусы:

  • URL выглядит некрасиво (/#/about)
  • Хеш не отправляется на сервер — проблемы с SSR
  • SEO хуже — поисковики могут игнорировать фрагмент

History API Routing

Использует history.pushState — чистые URL:

https://example.com/about
https://example.com/users/42
class HistoryRouter {
  constructor {
    this.routes = ;

    // Обработка кнопок Назад/Вперёд
    window.addEventListener('popstate', () => {
      this.handleRoute(window.location.pathname);
    });

    // Перехват всех кликов по ссылкам
    document.addEventListener('click', (e) => {
      const anchor = e.target.closest('a[data-link]');
      if (anchor) {
        e.preventDefault();
        this.navigate(anchor.getAttribute('href'));
      }
    });
  }

  // Поддержка динамических параметров: /users/:id
  addRoute(pattern, handler) {
    const paramNames = ;
    // Превращаем /users/:id в regex /users/([^/]+)
    const regexStr = pattern.replace(/:(\w+)/g, (_, name) => {
      paramNames.push(name);
      return '([^/]+)';
    });
    const regex = new RegExp(`^${regexStr}$`);

    this.routes.push({ regex, paramNames, handler });
  }

  handleRoute(path) {
    for (const route of this.routes) {
      const match = path.match(route.regex);
      if (match) {
        // Извлекаем параметры
        const params = {};
        route.paramNames.forEach((name, i) => {
          params[name] = match[i + 1];
        });
        route.handler(params);
        return;
      }
    }
    // 404
    this.routes.find(r => r.regex.test('/404'))?.handler({});
  }

  navigate(path) {
    history.pushState(null, '', path);
    this.handleRoute(path);
  }

  start {
    this.handleRoute(window.location.pathname);
  }
}

// Использование
const router = new HistoryRouter();
const app = document.getElementById('app');

router.addRoute('/', () => {
  app.innerHTML = `
    <h1>Главная</h1>
    <a href="/users" data-link>Пользователи</a>
  `;
});

router.addRoute('/users', () => {
  app.innerHTML = `
    <h1>Пользователи</h1>
    <a href="/users/42" data-link>Пользователь #42</a>
  `;
});

// Динамический параметр :id
router.addRoute('/users/:id', (params) => {
  app.innerHTML = `<h1>Пользователь #${params.id}</h1>`;
});

router.start();

Параметры маршрутов

Path Parameters (параметры пути)

// /users/:id → /users/42 → { id: '42' }
// /posts/:year/:month → /posts/2024/03 → { year: '2024', month: '03' }

router.addRoute('/users/:id', (params) => {
  fetchUser(params.id).then(user => renderUser(user));
});

router.addRoute('/posts/:year/:month', (params) => {
  fetchPosts(params.year, params.month).then(renderPosts);
});

Query Parameters (параметры запроса)

// /search?q=javascript&page=2

function getQueryParams() {
  const params = new URLSearchParams(window.location.search);
  return Object.fromEntries(params.entries());
}

router.addRoute('/search', () => {
  const { q, page } = getQueryParams;
  // q = 'javascript', page = '2'
  searchProducts(q, page).then(renderResults);
});

Вложенные маршруты

// Вложенные маршруты — layout + outlet
class NestedRouter {
  constructor {
    this.layouts = {};
    this.routes = {};
  }

  // Регистрация layout
  addLayout(prefix, layoutFn) {
    this.layouts[prefix] = layoutFn;
  }

  // Регистрация дочернего маршрута
  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  handleRoute(path) {
    // Находим layout
    for (const [prefix, layoutFn] of Object.entries(this.layouts)) {
      if (path.startsWith(prefix)) {
        // Рендерим layout
        layoutFn;
        // Рендерим дочерний маршрут в outlet
        const outlet = document.getElementById('outlet');
        const handler = this.routes[path];
        if (handler && outlet) {
          handler(outlet);
        }
        return;
      }
    }
  }
}

const router = new NestedRouter();

// Layout для /dashboard/*
router.addLayout('/dashboard', () => {
  document.getElementById('app').innerHTML = `
    <nav>
      <a href="/dashboard" data-link>Обзор</a>
      <a href="/dashboard/settings" data-link>Настройки</a>
    </nav>
    <div id="outlet"></div>
  `;
});

// Дочерние маршруты
router.addRoute('/dashboard', (outlet) => {
  outlet.innerHTML = '<h2>Обзор дашборда</h2>';
});

router.addRoute('/dashboard/settings', (outlet) => {
  outlet.innerHTML = '<h2>Настройки</h2>';
});

Lazy Loading маршрутов

Загрузка кода страницы только при переходе на неё:

// Динамический import — код загружается по требованию
const routes = {
  '/':  => import('./pages/Home.js'),
  '/about':  => import('./pages/About.js'),
  '/dashboard':  => import('./pages/Dashboard.js'),
};

async function handleRoute(path) {
  const app = document.getElementById('app');

  // Показываем индикатор загрузки
  app.innerHTML = '<div class="spinner">Загрузка...</div>';

  try {
    const loader = routes[path];
    if (loader) {
      const module = await loader; // Загружаем модуль
      module.default(app);            // Рендерим страницу
    } else {
      app.innerHTML = '<h1>404</h1>';
    }
  } catch (error) {
    app.innerHTML = '<h1>Ошибка загрузки</h1>';
    console.error(error);
  }
}

// pages/Home.js
export default function Home(container) {
  container.innerHTML = '<h1>Главная</h1>';
}

// pages/About.js
export default function About(container) {
  container.innerHTML = '<h1>О нас</h1>';
}

Prefetch — предзагрузка маршрутов

// Предзагрузка при наведении на ссылку
document.addEventListener('mouseover', (e) => {
  const link = e.target.closest('a[data-link]');
  if (link) {
    const path = link.getAttribute('href');
    const loader = routes[path];
    if (loader) {
      // Браузер начнёт загрузку модуля, но не выполнит
      loader; // import кэшируется
    }
  }
});

Защищённые маршруты (Route Guards)

// Guard — проверка перед рендерингом маршрута
function requireAuth(handler) {
  return (params) => {
    const token = localStorage.getItem('token');
    if (!token) {
      // Перенаправляем на логин
      router.navigate('/login');
      return;
    }
    handler(params);
  };
}

// Публичные маршруты
router.addRoute('/', renderHome);
router.addRoute('/login', renderLogin);

// Защищённые маршруты
router.addRoute('/dashboard', requireAuth(renderDashboard));
router.addRoute('/profile', requireAuth(renderProfile));

Настройка сервера для History API

При History API роутинге сервер должен отдавать index.html для всех маршрутов:

# Nginx
server {
  listen 80;
  root /var/www/app;

  location / {
    try_files $uri $uri/ /index.html;
  }
}
// Express.js
const express = require('express');
const path = require('path');
const app = express;

app.use(express.static('dist'));

// Все маршруты → index.html
app.get('*', (req, res) => {
  res.sendFile(path.resolve('dist', 'index.html'));
});
// Webpack Dev Server
module.exports = {
  devServer: {
    historyApiFallback: true, // Все 404 → index.html
  },
};

Частые ошибки

  1. Забывают e.preventDefault() — клик по ссылке перезагружает страницу вместо SPA-навигации
  2. Не настраивают сервер — прямой переход на /about выдаёт 404 от сервера
  3. Не обрабатывают popstate — кнопка «Назад» не работает
  4. Утечки при смене маршрута — слушатели событий предыдущей страницы не очищаются
  5. Нет 404 страницы — неизвестный маршрут показывает пустой экран
  6. Не декодируют параметрыdecodeURIComponent не применяют к path/query params

Практика

  1. Реализовать hash-роутер с 3 страницами
  2. Переписать на History API с динамическими параметрами /users/:id
  3. Добавить вложенные маршруты с layout
  4. Реализовать lazy loading страниц через import
  5. Добавить route guard для защищённых маршрутов

Связанные темы

Ресурсы