Роутинг в 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
},
};
Частые ошибки
- Забывают
e.preventDefault()— клик по ссылке перезагружает страницу вместо SPA-навигации - Не настраивают сервер — прямой переход на
/aboutвыдаёт 404 от сервера - Не обрабатывают
popstate— кнопка «Назад» не работает - Утечки при смене маршрута — слушатели событий предыдущей страницы не очищаются
- Нет 404 страницы — неизвестный маршрут показывает пустой экран
- Не декодируют параметры —
decodeURIComponentне применяют к path/query params
Практика
- Реализовать hash-роутер с 3 страницами
- Переписать на History API с динамическими параметрами
/users/:id - Добавить вложенные маршруты с layout
- Реализовать lazy loading страниц через
import - Добавить route guard для защищённых маршрутов
Связанные темы
- Что такое SPA — основы SPA и History API
- Управление состоянием — передача данных между маршрутами
- Webpack — code splitting для lazy loading
- HTTP протокол — настройка сервера для SPA