Создание клиентской маршрутизации с помощью History API
Соберите простой роутер на History API с pushState, popstate, динамическими параметрами, SEO-URL и защитой от 404 и XSS.
Клиентская маршрутизация переключает представления, обновляя URL и выполняя повторный рендеринг на JavaScript, без обращения к серверу (сервер задействуется только при самой первой загрузке или при жёстком обновлении страницы).
Если вы когда-нибудь выпускали single-page приложение, вам знаком этот момент: локально всё работает, а затем коллега нажимает кнопку «Назад» — URL меняется, а страница остаётся неподвижной. Когда знаешь, куда смотреть, исправление занимает пять минут, но в первый раз на это попадаются практически все.
Фреймворки вроде React Router и Vue Router оборачивают это поведение в компоненты и хуки, но под капотом все они управляют одним и тем же браузерным примитивом — History API. В этой статье мы построим минимальный, корректный и готовый к развёртыванию роутер на чистом JavaScript примерно в 50 строк, разберём разделение обязанностей между pushState и popstate, а также рассмотрим два подводных камня (404 при развёртывании и риск XSS-инъекции), которые отличают учебный пример от чего-то, что можно выпускать в продакшн.
Ключевые выводы
- В History-режиме
history.pushState(state, '', url)меняет URL без перезагрузки страницы, но не вызывает событиеpopstate. Вы сами вызываете свою функцию рендеринга послеpushStateи отдельно подписываетесь наpopstate, чтобы обрабатывать «Назад» и «Вперёд». - Чистые URL вида
/dashboardв History-режиме лучше для SEO и для обмена ссылками, но требуют, чтобы сервер перенаправлял любой неизвестный путь наindex.html, иначе прямой переход или обновление страницы вернёт 404. - Второй аргумент
pushState— устаревший параметрtitle, который браузеры игнорируют; его нельзя опустить, поэтому всегда передавайте пустую строку. - Вставка представления через
innerHTML— это вектор XSS для любых интерполированных недоверенных данных, и она молча теряет обработчики событий на вставленной разметке. Создавайте узлы черезcreateElement, санитизируйте данные или используйте библиотеку шаблонизации, а поведение подключайте через делегирование событий. - Navigation API достиг статуса Baseline Newly available в январе 2026 года и является формирующимся преемником этого подхода, но History API остаётся базой с самой широкой совместимостью.
В чём разница между hash-режимом и History-режимом?
Клиентская маршрутизация обновляет представление при изменении URL без полной перезагрузки страницы. Изменить URL без навигации можно двумя способами: hash-режим и History-режим. Hash-режим кодирует маршрут после # (/app#/users). Фрагмент после решётки никогда не отправляется на сервер, поэтому навигация на основе хэша полностью клиентская и не требует никакой серверной настройки, а вы слушаете событие hashchange. History-режим формирует чистые пути (/users) с помощью History API и слушает popstate.
| Hash-режим | History-режим | |
|---|---|---|
| Вид URL | /app#/users | /users |
| Событие изменения | hashchange | popstate |
| Настройка сервера | Не требуется | Перенаправление всех путей на index.html |
| Обновление / прямая ссылка | Всегда работает | 404 без перенаправления |
| SEO / ссылки для обмена | Слабее | Чище, предпочтительнее |
History-режим — выбор по умолчанию из-за чистых, индексируемых URL, и именно его мы реализуем в этой статье. Единственная плата — необходимость поддержки со стороны сервера, о чём речь ниже.
Примитивы History API, которые вам действительно нужны
Discover how at OpenReplay.com.
Роутер в History-режиме держится на трёх примитивах. history.pushState(state, unused, url) добавляет запись в стек истории сессии и меняет адресную строку; history.replaceState делает то же самое, но перезаписывает текущую запись вместо добавления новой. location.pathname читает текущий путь, чтобы вы могли сопоставить маршрут. Событие popstate срабатывает, когда пользователь нажимает «Назад» или «Вперёд».
Ключевое правило: pushState и replaceState не вызывают popstate. Вы обязаны сами вызывать свою функцию рендеринга после каждого pushState и отдельно регистрировать слушатель popstate, чтобы кнопки браузера «Назад» и «Вперёд» перерисовывали представление. Пропустите слушатель — и при нажатии «Назад» URL изменится, а DOM останется замороженным: баг, невидимый на code review, но очевидный в тот момент, когда вы смотрите запись сессии приложения.
Ещё две детали имеют значение. Средний аргумент — устаревшее значение title, которое браузеры игнорируют, и его нельзя опустить, поэтому передавайте пустую строку. url должен быть того же происхождения (same-origin): браузер не загружает его при вызове pushState, и вызов выбросит исключение, если origin отличается от текущей страницы. Само событие popstate — старое и надёжное, доступно во всех браузерах с июля 2015 года.
Как построить минимальный роутер?
Работающему роутеру в History-режиме нужны пять частей: карта маршрутов, функция resolve, которая читает location.pathname и сопоставляет маршрут с фолбэком на 404, делегирование клика по атрибуту data-link, слушатель popstate и первоначальный рендеринг. Вот полный файл:
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
const routes = {
'/': { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
'/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
'/404': { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};
const app = document.getElementById('app');
function match(pathname) {
for (const pattern of Object.keys(routes)) {
const pParts = pattern.split('/');
const uParts = pathname.split('/');
if (pParts.length !== uParts.length) continue;
const params = {};
const ok = pParts.every((part, i) => {
if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
return part === uParts[i];
});
if (ok) return { route: routes[pattern], params };
}
return { route: routes['/404'], params: {} };
}
function resolve() {
const { route, params } = match(location.pathname);
app.innerHTML = route.view(params);
document.title = route.title;
}
function navigate(url) {
history.pushState({}, '', url); // '' is the ignored legacy title
resolve(); // pushState does NOT fire popstate — render manually
}
document.addEventListener('click', (e) => {
const link = e.target.closest('[data-link]'); // robust: works on nested markup
if (!link) return;
e.preventDefault();
navigate(link.getAttribute('href'));
});
window.addEventListener('popstate', resolve); // Back / Forward
history.replaceState({}, '', location.pathname); // seed the initial entry
resolve(); // render on first paint
Делегирование событий через e.target.closest('[data-link]') выбрано намеренно. Оно выдерживает клики по дочерним узлам (иконка внутри ссылки) и продолжает работать при повторном рендеринге представлений — в отличие от навешивания слушателей на каждый элемент или чтения e.target.attributes[0], которое зависит от порядка атрибутов и ломается на вложенной разметке.
Следующий уровень: динамические параметры, заголовки и начальная запись истории
Функция match выше уже обрабатывает динамические сегменты. Шаблон вида /users/:id разбивается на части; любой сегмент, начинающийся с :, захватывает соответствующий сегмент пути в объект params, поэтому /users/42 разрешается с { id: '42' }. Сегменты без : должны совпадать точно, а несовпадение длины пропускает шаблон, что не позволяет /users совпасть с /users/42. Установка document.title внутри resolve обновляет заголовок вкладки и подпись в истории при каждой навигации.
В роутер стоит добавить ещё одно исправление. Браузер создаёт вашу первую запись истории из обычной загрузки страницы, поэтому в ней ничего не сохранено, и руководство MDN по работе с History API рекомендует вызывать history.replaceState() при старте, чтобы привязать состояние к этой записи. Сделайте это — и первое нажатие «Назад» сможет восстановить исходное представление. Это и есть завершающая строка replaceState в роутере.
Два подводных камня, которые отличают учебный пример от настоящего роутера
Развёртывание. Чистые URL History-режима требуют, чтобы сервер перенаправлял любой неизвестный путь на index.html, иначе прямой переход или обновление на /users/42 вернёт 404. Обойти это средствами JavaScript невозможно, поскольку запрос попадает на сервер до загрузки вашего бандла. Настройте перенаправление один раз для каждого хостинга. Express 5 изменил синтаксис сопоставления путей: теперь каждый wildcard обязан быть именованным, поэтому старый catch-all app.get('*') выбрасывает ошибку «Missing parameter name» при старте. Используйте именованный wildcard в фигурных скобках, который совпадает как с корневым путём, так и со всем, что ниже:
// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/* /index.html 200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
404 при жёстком обновлении по присланной глубокой ссылке — ещё один сбой, который в коде выглядит нормально, но явно проявляется, когда вы наблюдаете, как реальная сессия попадает на пустую страницу.
Безопасность. Вставка представления через innerHTML — вектор XSS всякий раз, когда интерполируются недоверенные данные (${p.id} выше приходит прямо из URL), и она молча теряет обработчики событий на вставленной разметке. Экранируйте интерполируемые значения (вызов escapeHtml выше), создавайте узлы через document.createElement либо используйте библиотеку шаблонизации, например lit-html, а поведение подключайте через делегирование событий на стабильном родителе, а не на вставленных узлах. Статические, написанные разработчиком строки шаблонов без интерполяции сами по себе не являются инъекцией; риск создают недоверенные данные, которые вы в них вставляете.
Куда движется платформа: Navigation API
Navigation API достиг статуса Baseline Newly available в январе 2026 года — в том месяце, когда поддержку добавил Firefox 147, — и является формирующимся преемником этого подхода. Вместо того чтобы отдельно связывать pushState, слушатель popstate и обработчик клика, вы регистрируете один слушатель navigate. Он срабатывает для каждой навигации, которую видит страница, чем бы она ни была инициирована, а вызов event.intercept() внутри этого слушателя оставляет адресную строку и стек истории на попечение браузера. Один из недостатков, которые он устраняет, — то, что popstate не срабатывает при программных pushState/replaceState, то есть именно то трение, которое обходит наш роутер. Пока Navigation API не станет минимальным уровнем совместимости для ваших целевых браузеров, History API остаётся базой с самой широкой поддержкой и самым понятным способом разобраться, что на самом деле делает роутер.
Теперь у вас есть работающий роутер в History-режиме: маршруты, сопоставление параметров, делегированные клики, корректная обработка popstate, инициализированная начальная запись истории и оба продакшн-исправления. Следующий конкретный шаг — настроить серверное перенаправление для вашего хостинга до развёртывания, чтобы глубокие ссылки выживали при обновлении страницы.
Частые вопросы
Почему в моём SPA кнопка «Назад» меняет URL, но страница остаётся неизменной?
Потому что pushState и replaceState не вызывают событие popstate, поэтому если вы рендерите только внутри обработчика клика и никогда не регистрируете слушатель popstate, «Назад» и «Вперёд» обновляют адресную строку без повторного рендеринга. Исправление — отдельный window.addEventListener('popstate', resolve), который запускает вашу функцию рендеринга каждый раз, когда браузер перемещается по истории. Посмотрите запись сессии — и вы увидите изменение URL без изменения DOM.
В чём разница между pushState и replaceState?
pushState добавляет новую запись в стек истории сессии, поэтому предыдущее представление остаётся доступным по кнопке «Назад». replaceState перезаписывает текущую запись вместо добавления новой, поэтому не создаёт новую цель для «Назад». Используйте pushState для обычной навигации, а replaceState — для инициализации начальной записи страницы при старте или для корректировки текущего URL без загрязнения истории. Оба имеют одинаковую сигнатуру (state, unused, url), и ни один из них не вызывает popstate.
Требует ли маршрутизация в hash-режиме какой-либо серверной настройки?
Нет. Фрагмент после решётки, например '/users' в '/app#/users', никогда не отправляется на сервер, поэтому навигация на основе хэша полностью клиентская и работает на любом статическом хостинге без правил перенаправления. Обновления страницы и глубокие ссылки всегда разрешаются, потому что сервер видит только '/app'. History-режим — это компромисс: он даёт более чистые URL, но требует, чтобы сервер перенаправлял любой неизвестный путь на index.html, иначе обновление страницы вернёт 404.
Стоит ли изучать History API теперь, когда Navigation API получил статус Baseline?
Да. Navigation API достиг статуса Baseline Newly available в январе 2026 года и является формирующимся преемником, заменяя ручные pushState, popstate и перехват клика единственным событием navigate и вызовом event.intercept(). Но History API остаётся базой с самой широкой совместимостью, работает в старых браузерах, где Navigation API недоступен, и именно им до сих пор управляют под капотом фреймворки вроде React Router и Vue Router. Его изучение — самый понятный способ разобраться, что на самом деле делает любой роутер.