Navigation API реализован во всех браузерах
09.01.2026 • dom
Введение
В бета-версии Файерфокса 147 включили поддержку Navigation API. Вместе с релизом Safari 26.2 это апи теперь есть в трёх основных браузерах. Я уверен, что это будет базой для будущих SPA. А почему, будем разбираться после небольшого исторического экскурса.
История минувших дней
Лет 15 назад сайты уже вполне себе могли быть интерактивными, реагировать на клики пользователя, догружать данные, показывать окошки пользователю и многое другое. Одна из возможностей тех времён — навигировать пользователя между страницами без полной перезагрузки. И тогда это можно было сделать посредством смены хеша в урле (он же якорь, идёт через #). Это работало с кнопками “назад” / “вперёд”, а для отслеживания переходов использовалось событие hashshange.
У этого подхода был существенный недостаток: хеш не передавался на сервер, а значит, состояние, соответствующее урлу, могло быть получено только на клиенте. Это заметно ухудшало пользовательский опыт, не говоря уже о SEO и прочих вещах.
С появлением History API с его методами history.pushState / history.replaceState началась полноценная эра SPA, когда страницы могли напрямую влиять на урл страницы без перезагрузки и тем самым предоставляя новый и в чём-то даже удивительный по тем временам опыт использования.
Глядя на History API сейчас, можно заметить явные проблемы, и мы на них посмотрим в данной статье. Но самым вопиющим является даже само описание pushState:
pushState(data: any, unused: string, url?: string | URL | null): void;unused — это на самом деле title для страницы. Более того, этот параметр даже используется в Safari (до сих пор). Думаю, вы поняли, что с текущим “фундаментом” для построения SPA не всё гладко :)
Мы имели 2 API, а теперь получили третье. Давайте посмотрим, как именно оно работает.
Базовое использование Navigation API
Если с хешами у нас было событие hashchange, с History API у нас был pushState и событие popstate, то с Navigation API у нас есть глобальный объект navigation и событие navigate.
navigation.addEventListener('navigate', event => {
...
});У события есть пачка полезных полей. Начнём с destination:
const url = event.destination.url;Ещё ряд дополнительных полей позволяют нам понять, что, скорее всего, это событие нам обрабатывать не нужно:
navigation.addEventListener('navigate', event => {
if (
// Переходы на другие сайты
// нельзя отменить
!event.canIntercept ||
// Переход по "якорю", например,
// по ссылке из оглавления
event.hashChange ||
// Ссылка с атрибутом download
event.downloadRequest ||
// Сабмит формы с body
event.formData
) {
return;
}
...
});Новое апи предлагает совершенно другую концепцию. Если раньше мы были вынуждены вручную обрабатывать все переходы по ссылкам (и проверять, что клик произошёл левой кнопкой мыши, и тому подобное) и отменять сабмиты форм (например, в поиске по сайту), то теперь это делать больше не нужно, обработчик navigate сам поймает все переходы. Из этого же следует, что решена моя давняя хотелка — отладка ухода со страницы.
Для большинства изменений со страницей есть соответствующий брейкпоинт в девтулзах, будь то обработчик клика или изменение DOM. Но в редких случаях необходимо выяснить причину ухода со страницы, и способа это выяснить не было! Только вручную выставив брейкпоинты во все возможные места. А теперь — вуаля:
navigation.addEventListener('navigate', e => {debugger;})Однострочная команда в консоль — и у нас уже есть стектрейс, откуда был вызван переход на другую страницу:

А если стектрейса нет, то это или переход по ссылке, или сабмит формы. В обоих случаях есть поле события под названием sourceElement, которое позволяет определить виновника.
Отвлеклись, вернёмся к обработке события navigate. Что можно сделать? А можно многое:
- Отменить переход через
preventDefault() - Средиректить пользователя на другой урл
- Заменить браузерную навигацию на кастомную обработку
- Можно даже вытащить
FormDataиз запроса и сделатьfetchс нужными данными вручную
Пользовательская обработка переходов
На кастомной обработке остановимся и вспомним, как мы грузим данные для SPA с History API. Дело в том, что pushState / popstate никак не работают с промисами или чем-то асинхронным (промисов в момент их создания даже не существовало). Поэтому вся асинхронная логика реализовывается “сбоку” от браузерного апи, и браузер про асинхронность ничего не знает.
С новым апи можно передать промис! И браузер будет знать, что переход “асинхронный”. На что это влияет?
- Браузер покажет индикатор загрузки, как у настоящего перехода по ссылке (тут спорно, хорошо это или нет, но такой возможности раньше для SPA не было!)
- Есть возможность обработать
AbortSignalот браузера (в случае, если пользователь нажмёт на отмену в интерфейсе, например) - Теперь нормально разруливается ситуация, когда происходит навигация во время неоконченной предыдущей навигации (новая отменяет предыдущую)
- Можно контролировать браузерную логику скролла и фокуса
Улучшения над History API
Последний пункт про контроль скролла и фокуса раскроем подробнее, но затронем и ещё несколько вещей.
Контролирование скролла и фокуса
При обычных браузерных навигациях (не SPA) происходит скролл (либо к началу страницы, либо к предыдущей позиции) и автофокус. Для фокуса в своё время в History API появилась доделка в виде history.scrollRestoration:
history.scrollRestoration = 'manual';Штука не очень удобная сразу по нескольким причинам:
- Свойство глобальное и влияет на все навигации
- Браузерный скролл никак не подружить с “асинхронной” навигацией
И то и то теперь полноценно решено в новом апи. Помните, мы говорили о том, что навигации теперь поддерживают асинхронщину? По дефолту, скролл происходит после окончания всей асинхронной операции. Более того, всё ещё можно отменить браузерную логику скролла или вызвать её в нужный момент:
navigation.addEventListener('navigate', event => {
if (shouldNotIntercept(event)) {
return;
}
const url = new URL(navigateEvent.destination.url);
if (url.pathname.startsWith('/post/')) {
event.intercept({
async handler() {
const articleContent = await getArticleContent();
renderArticlePage(articleContent);
// Вручную вызываем браузерную логику скролла
event.scroll();
const comments = await getArticleComments();
showComments(comments);
}
});
} else {
event.intercept({
// Отменяем браузерную логику скролла
scroll: 'manual',
async handler() {
// ...
}
});
}
});Аналогично и с фокусом. Если с History API нужно было вручную устанавливать фокус после навигации, то с новым Navigation API браузер выполнит дефолтное поведение за нас (например, зафокусит элемент с атрибутом autofocus). Аналогичным способом это поведение можно отключить:
event.intercept({
focusReset: 'manual'
});Объект navigation.activation
Одним из подходов по работе с браузерной историей, который касается всех страниц, а не только SPA, является очистка браузерного урла после открытия. Зачем это нужно?
Предположим, у нас есть какие-то utm параметры в ссылке или технические параметры (из популярных вариантов может быть случайное число, чтобы пробить браузерный кеш). Чтобы пользователь не видел это в адресной строке (техника старая, ещё до того, как браузеры стали сами скрывать “лишнее” из своего интерфейса) или не копировал ссылку с лишними параметрами другу, можно использовать history.replaceState:
const cleanUrl = clean(location.href);
history.replaceState(null, '', cleanUrl);Тут используется именно replaceState, а не pushState, чтобы у пользователя не появлялись “мусорные” переходы в истории. Но у этого подхода есть один минус: если нам затем потребуется исходный урл страницы, то нам попросту неоткуда будет его брать! Приходится сохранять исходный урл и каким-то образом протаскивать его в другое место в коде (и это далеко не всегда удобно).
Как это решается с помощью Navigation API? Очень просто — теперь у нас есть объект с исходным “назначением”, с которым открылась страница:
const originalUrl = navigation.activation.entry.url;Возвращаясь к replaceState, с новом апи есть аналогичная возможность:
const { committed, finished } =
navigation.navigate('/', {
history: 'replace'
});committed — промис “смены урла в браузере”, а finished — это промис окончания загрузки нового состояния. Теперь мы можем получить промис окончания перехода в том самом месте, в котором зовём переход. Важное отличие от replaceState: без обработчика navigate произойдёт реальный переход, как от location.href = '...'! Чтобы такое не произошло, обязательно нужен intercept.
Аналитика
Частая задача — отслеживать пользовательские переходы по ссылкам, чтобы понимать, что нужно людям, а что нет. navigate и тут может помочь, но с нюансами. Через него можно получить кликнутую ссылку, но событие не сработает, если ссылка открывается в новой вкладке через target=_blank. Также это не сработает и на кнопках, которые не приводят к переходу по истории, так что какой-то код всё ещё придётся написать.
Однако если раньше сам факт перехода по конкретной ссылке было сложно отследить, и городили конструкции с mousedown, подменой ссылки, определением кнопки мыши и т..д, то теперь можно сделать проще.
Ифреймы
С ифреймами и History API можно было столкнуться с тем, что popstate срабатывает внутри дочернего ифрейма, а навигации в нём влияют на историю всей страницы. Хуже того, это поведение может быть разным в разных браузерах (до сих пор помню баги Chrome для iOS).
С Navigation API всё должно быть лучше, так как апи изначально оперирует только одной страницей, без учёта вложенных фреймов. Насколько этот подход окажется беспроблемным и понятным для разработчиков — покажет время (да и не все стабильные браузеры зарелизили Navigation API), но продуманность этого кейса не может не радовать.
Определение возможности перейти “назад”
В некоторых сайтах есть задача показа кнопки “назад”, если пользователь увидел это состояние после перехода из другого раздела сайта. В текущем History API это решается не очень тривиально (например, можно использовать history.state).
В новом апи есть метод navigation.entries(), который позволяет узнать нужное напрямую.
Закрытие элементов интерфейса через “назад”
На телефонах пользователи могут хотеть закрывать разные окна через привычный элемент управления “назад”, будь то жест или отдельная кнопка. И тут происходит смешение концепций: кнопка “назад” используется и для браузерного перехода “назад”, а заметную часть попапов мы в браузерной истории видеть не хотим. Итого сейчас всё это приводит к “временным” браузерным навигациям, чтобы было, куда перейти назад. Там же появляется и вопрос, как именно закрыть такой попап? Можно ли использовать history.back() или он нас перенесёт в чужой сайт?
В текущих реалиях ожидается, что нативные диалоги <dialog> отслеживают и ESC, и “назад”, и закрываются сами. Так работает в андроиде, но не в iOS.
Другой новый подход заключается в использовании CloseWatcher, который решает именно такую задачу, но который тоже пока что не реализован на iOS.
Остаётся использовать браузерную историю. Navigation API не решает полностью эту задачу (и как будто не должно решать), но предоставляет ряд важных улучшений по сравнению с History API.
navigation.canGoBack позволяет узнать, можно ли переходить назад и останемся ли мы на текущем сайте.
navigation.entries() возвращает объекты NavigationEntry, которые, помимо прочего, содержат поле sameDocument, которое позволяет узнать, было ли это состояние в текущем документе или это была полноценная браузерная навигация (по сути, был ли это SPA переход).
Итого
Navigation API предоставляет новый подход для работы с браузерной навигацией и решает ряд проблем, которые мы имеем сейчас с History API. Самое главное, на мой взгляд — поддержка асинхронных переходов, но есть и пачка других плюсов, про которые поговорить не успели. Тут и интеграция с View Transition, и возможность перехода к нужному NavigationEntry, и полноценные объекты истории (пусть и без возможности редактирования списка записей напрямую), и способ перехода в навигации, и флаг инициации навигации пользователем, и, я уверен, много чего ещё, что покажет использование в реальных проектах. От себя могу порекомендовать статьи: