Стандартизация маршрутизации на стороне клиента с помощью совершенно нового API, который полностью меняет подход к созданию одностраничных приложений.
Одностраничные приложения (SPA) определяются одной ключевой особенностью: динамическим перезаписыванием контента по мере взаимодействия пользователя с сайтом, в отличие от стандартного метода загрузки совершенно новых страниц с сервера.
Хотя одностраничные приложения (SPA) и предоставляли эту функцию через History API (или, в ограниченных случаях, путем изменения части #hash на сайте), это неуклюжий API, разработанный задолго до того, как SPA стали нормой, и веб-пространство остро нуждается в совершенно новом подходе. Navigation API — это предлагаемый API, который полностью перестраивает эту область, а не пытается просто залатать дыры в History API. (Например, Scroll Restoration запатентовал History API, вместо того чтобы пытаться изобрести его заново.)
В этом посте в общих чертах описан API навигации. Чтобы ознакомиться с техническим предложением, см. проект отчета в репозитории WICG .
Пример использования
Для использования Navigation API начните с добавления обработчика события "navigate" к глобальному объекту navigation . Это событие, по сути, централизовано : оно будет срабатывать для всех типов навигации, независимо от того, выполнил ли пользователь какое-либо действие (например, щелкнул ссылку, отправил форму или перешел назад или вперед) или когда навигация запускается программно (т.е., через код вашего сайта). В большинстве случаев это позволяет вашему коду переопределить поведение браузера по умолчанию для этого действия. Для одностраничных приложений (SPA) это, вероятно, означает удержание пользователя на той же странице и загрузку или изменение содержимого сайта.
В NavigateEvent "navigate" передается объект NavigateEvent, содержащий информацию о навигации, например, целевой URL-адрес, и позволяющий реагировать на навигацию в одном централизованном месте. Базовый обработчик события "navigate" может выглядеть следующим образом:
navigation.addEventListener('navigate', navigateEvent => {
// Exit early if this navigation shouldn't be intercepted.
// The properties to look at are discussed later in the article.
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname === '/') {
navigateEvent.intercept({handler: loadIndexPage});
} else if (url.pathname === '/cats/') {
navigateEvent.intercept({handler: loadCatsPage});
}
});
Навигацию можно организовать двумя способами:
- Вызов функции
intercept({ handler })(как описано выше) для обработки навигации. - Вызов метода
preventDefault()может полностью отменить навигацию.
В этом примере вызывается intercept() для обработки события. Браузер вызывает ваш handler обратного вызова, который должен настроить следующее состояние вашего сайта. Это создаст объект перехода, navigation.transition , который другой код сможет использовать для отслеживания прогресса навигации.
Как правило, разрешены вызовы методов intercept() и preventDefault() , но существуют случаи, когда их использование невозможно. Вы не можете обрабатывать навигацию с помощью intercept() если это навигация из другого источника. И вы не можете отменить навигацию с помощью preventDefault() , если пользователь нажимает кнопки «Назад» или «Вперед» в своем браузере; вы не должны иметь возможность перехватывать действия пользователей на вашем сайте. (Это обсуждается на GitHub .)
Даже если вы не можете остановить или перехватить саму навигацию, событие "navigate" все равно сработает. Оно носит информативный характер , поэтому ваш код может, например, зарегистрировать событие Analytics, указывающее на то, что пользователь покидает ваш сайт.
Зачем добавлять на платформу еще одно мероприятие?
Обработчик событий "navigate" централизует обработку изменений URL-адресов внутри одностраничного приложения (SPA). Это сложная задача при использовании старых API. Если вы когда-либо писали маршрутизацию для своего SPA, используя History API, вы могли добавить примерно такой код:
function updatePage(event) {
event.preventDefault(); // we're handling this link
window.history.pushState(null, '', event.target.href);
// TODO: set up page based on new URL
}
const links = [...document.querySelectorAll('a[href]')];
links.forEach(link => link.addEventListener('click', updatePage));
Это хорошо, но не исчерпывающий список. Ссылки могут появляться и исчезать на вашей странице, и это не единственный способ навигации пользователей. Например, они могут отправить форму или даже использовать карту изображений . Ваша страница может обрабатывать эти действия, но существует множество других возможностей, которые можно просто упростить — именно этого и добивается новый API навигации.
Кроме того, приведенный выше код не обрабатывает навигацию назад/вперед. Для этого существует другое событие — "popstate" .
Лично мне кажется , что History API мог бы в какой-то мере помочь в решении этих задач. Однако его возможности ограничены двумя основными направлениями: реагированием на нажатия пользователем кнопок «Назад» или «Вперед» в браузере, а также заменой и обновлением URL-адресов. Аналогии с "navigate" у него нет, за исключением, например, случаев, когда вы вручную настраиваете обработчики событий клика, как показано выше.
Решение о том, как организовать навигацию.
navigateEvent содержит много информации о навигации, которую можно использовать для определения способа обработки конкретного перехода.
Ключевые свойства:
-
canIntercept - Если это неверно, перехватить навигацию невозможно. Перехват навигации между источниками и обход документов невозможен.
-
destination.url - Пожалуй, это самая важная информация, которую следует учитывать при навигации.
-
hashChange - Значение true, если навигация осуществляется в пределах одного документа, и хеш — единственная часть URL-адреса, отличающаяся от текущего URL. В современных одностраничных приложениях (SPA) хеш должен использоваться для ссылок на разные части текущего документа. Поэтому, если
hashChangeимеет значение true, вам, вероятно, не нужно перехватывать эту навигацию. -
downloadRequest - Если это так, то навигация была инициирована ссылкой с атрибутом
download. В большинстве случаев перехватывать это не требуется. -
formData - Если значение не равно null, значит, эта навигация является частью отправки формы методом POST. Учитывайте это при обработке навигации. Если вы хотите обрабатывать только навигацию методом GET, избегайте перехвата навигаций, где
formDataне равно null. Пример обработки отправки форм см. далее в статье. -
navigationType - Это один из вариантов:
"reload","push","replace"или"traverse". Если это"traverse", то эту навигацию нельзя отменить с помощьюpreventDefault().
Например, функция shouldNotIntercept , использованная в первом примере, может выглядеть примерно так:
function shouldNotIntercept(navigationEvent) {
return (
!navigationEvent.canIntercept ||
// If this is just a hashChange,
// just let the browser handle scrolling to the content.
navigationEvent.hashChange ||
// If this is a download,
// let the browser perform the download.
navigationEvent.downloadRequest ||
// If this is a form submission,
// let that go to the server.
navigationEvent.formData
);
}
Перехват
Когда ваш код вызывает intercept({ handler }) из обработчика события "navigate" , он сообщает браузеру, что страница подготавливается к новому, обновленному состоянию, и что навигация может занять некоторое время.
Браузер начинает с захвата положения прокрутки для текущего состояния, чтобы его можно было при необходимости восстановить позже, а затем вызывает ваш handler обратного вызова. Если ваш handler возвращает промис (что происходит автоматически с асинхронными функциями ), этот промис сообщает браузеру, сколько времени занимает навигация и была ли она успешной.
navigation.addEventListener('navigate', navigateEvent => {
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname.startsWith('/articles/')) {
navigateEvent.intercept({
async handler() {
const articleContent = await getArticleContent(url.pathname);
renderArticlePage(articleContent);
},
});
}
});
Таким образом, этот API вводит семантическую концепцию, понятную браузеру: навигация в SPA происходит в данный момент, а со временем документ изменяется с предыдущего URL-адреса и состояния на новое. Это имеет ряд потенциальных преимуществ, включая доступность: браузеры могут отображать начало, конец или возможный сбой навигации. Chrome, например, активирует свой собственный индикатор загрузки и позволяет пользователю взаимодействовать с кнопкой остановки. (В настоящее время это не происходит при навигации с помощью кнопок «назад»/«вперед», но это будет исправлено в ближайшее время .)
подтверждение навигации
При перехвате навигации новый URL вступит в силу непосредственно перед вызовом handler . Если вы не обновите DOM немедленно, возникнет период, когда старый контент будет отображаться вместе с новым URL. Это повлияет на такие вещи, как относительное разрешение URL при получении данных или загрузке новых подресурсов.
На GitHub обсуждается способ отложить изменение URL-адреса, но обычно рекомендуется немедленно обновить страницу, добавив какой-либо заполнитель для входящего контента:
navigation.addEventListener('navigate', navigateEvent => {
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname.startsWith('/articles/')) {
navigateEvent.intercept({
async handler() {
// The URL has already changed, so quickly show a placeholder.
renderArticlePagePlaceholder();
// Then fetch the real data.
const articleContent = await getArticleContent(url.pathname);
renderArticlePage(articleContent);
},
});
}
});
Это не только позволяет избежать проблем с разрешением URL-адресов, но и создает ощущение скорости, поскольку вы мгновенно отвечаете пользователю.
Сигналы прерывания
Поскольку в обработчике intercept() можно выполнять асинхронную работу, навигация может стать избыточной. Это происходит в следующих случаях:
- Пользователь переходит по другой ссылке, или какой-либо код выполняет другую навигацию. В этом случае старая навигация отменяется в пользу новой.
- Пользователь нажимает кнопку «Стоп» в браузере.
Для обработки любой из этих возможностей событие, передаваемое обработчику события "navigate" содержит свойство signal , которое представляет собой AbortSignal . Дополнительную информацию см. в разделе "Прерываемая выборка" .
Вкратце, это, по сути, предоставляет объект, который генерирует событие, когда вам следует остановить свою работу. Примечательно, что вы можете передать AbortSignal в любой вызов функции fetch() , что отменит выполняющиеся сетевые запросы, если навигация будет прервана. Это позволит сэкономить пропускную способность пользователя и отклонить Promise , возвращаемый функцией fetch() , предотвращая выполнение последующих действий, таких как обновление DOM для отображения недействительной навигации по странице.
Вот предыдущий пример, но с встроенным вызовом getArticleContent , демонстрирующий, как AbortSignal можно использовать с fetch() :
navigation.addEventListener('navigate', navigateEvent => {
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname.startsWith('/articles/')) {
navigateEvent.intercept({
async handler() {
// The URL has already changed, so quickly show a placeholder.
renderArticlePagePlaceholder();
// Then fetch the real data.
const articleContentURL = new URL(
'/get-article-content',
location.href
);
articleContentURL.searchParams.set('path', url.pathname);
const response = await fetch(articleContentURL, {
signal: navigateEvent.signal,
});
const articleContent = await response.json();
renderArticlePage(articleContent);
},
});
}
});
Обработка прокрутки
При использовании intercept() для перехвата навигации браузер попытается автоматически обработать прокрутку.
При переходе к новой записи в истории (когда navigationEvent.navigationType имеет значение "push" или "replace" ) это означает попытку прокрутки до части, указанной фрагментом URL (часть после # ), или сброс прокрутки в начало страницы.
При перезагрузке и перемещении по странице это означает восстановление положения прокрутки в том месте, где она находилась в последний раз, когда отображалась данная запись в истории.
По умолчанию это происходит после завершения выполнения промиса, возвращаемого вашим handler , но если прокрутка имеет смысл раньше, вы можете вызвать navigateEvent.scroll() :
navigation.addEventListener('navigate', navigateEvent => {
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname.startsWith('/articles/')) {
navigateEvent.intercept({
async handler() {
const articleContent = await getArticleContent(url.pathname);
renderArticlePage(articleContent);
navigateEvent.scroll();
const secondaryContent = await getSecondaryContent(url.pathname);
addSecondaryContent(secondaryContent);
},
});
}
});
В качестве альтернативы вы можете полностью отказаться от автоматической обработки прокрутки, установив параметр scroll функции intercept() в значение "manual" :
navigateEvent.intercept({
scroll: 'manual',
async handler() {
// …
},
});
фокусировка
Как только промис, возвращаемый вашим handler , разрешится, браузер сфокусируется на первом элементе с установленным атрибутом autofocus или на элементе <body> , если ни один элемент не имеет этого атрибута.
Отключить это поведение можно, установив параметр focusReset функции intercept() в значение "manual" :
navigateEvent.intercept({
focusReset: 'manual',
async handler() {
// …
},
});
События успеха и неудачи
Когда будет вызван обработчик intercept() , произойдет одно из двух:
- Если возвращенный
Promiseвыполняется (или вы не вызывалиintercept()), API навигации сгенерируетEvent"navigatesuccess". - Если возвращаемый
Promiseотклоняется, API вызовет событие ErrorEvent с помощьюErrorEvent"navigateerror".
Эти события позволяют вашему коду централизованно обрабатывать успех или неудачу. Например, в случае успеха вы можете скрыть ранее отображавшийся индикатор выполнения, как показано ниже:
navigation.addEventListener('navigatesuccess', event => {
loadingIndicator.hidden = true;
});
Или же при сбое может отобразиться сообщение об ошибке:
navigation.addEventListener('navigateerror', event => {
loadingIndicator.hidden = true; // also hide indicator
showMessage(`Failed to load page: ${event.message}`);
});
Обработчик событий "navigateerror" , получающий ErrorEvent , особенно удобен, поскольку гарантирует получение любых ошибок из вашего кода, который настраивает новую страницу. Вы можете просто await fetch() зная, что если сеть недоступна, ошибка в конечном итоге будет перенаправлена в обработчик "navigateerror" .
Пункты навигации
navigation.currentEntry предоставляет доступ к текущей записи. Это объект, описывающий текущее местоположение пользователя. Эта запись включает текущий URL-адрес, метаданные, которые можно использовать для идентификации этой записи во времени, и состояние, предоставленное разработчиком.
Метаданные включают key — уникальное строковое свойство каждой записи, представляющее текущую запись и её слот . Этот ключ остаётся неизменным, даже если URL-адрес или состояние текущей записи изменяются. Он по-прежнему находится в том же слоте. И наоборот, если пользователь нажимает кнопку «Назад», а затем снова открывает ту же страницу, key изменится, поскольку эта новая запись создаёт новый слот.
Для разработчика key полезен, поскольку API навигации позволяет напрямую перенаправлять пользователя к записи с соответствующим ключом. Вы можете сохранять его, даже если другие записи находятся в том же состоянии, чтобы легко переключаться между страницами.
// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const {key} = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);
// Navigate away, but the button will always work.
await navigation.navigate('/another_url').finished;
Состояние
API навигации предоставляет понятие «состояния», которое представляет собой информацию, предоставляемую разработчиком и постоянно хранящуюся в текущей записи истории, но не видимую пользователю напрямую. Это очень похоже на history.state в API истории, но является его улучшенной версией.
В API навигации вы можете вызвать метод ` .getState() ` текущей записи (или любой другой записи), чтобы получить копию её состояния:
console.log(navigation.currentEntry.getState());
По умолчанию это значение будет undefined .
Установка состояния
Хотя объекты состояния могут изменяться, эти изменения не сохраняются вместе с записью в истории, поэтому:
const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// But:
console.info(navigation.currentEntry.getState().count); // will still be 1
Правильный способ задать состояние — во время навигации по скрипту:
navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});
Где newState может быть любым клонируемым объектом .
Если вы хотите обновить состояние текущей записи, лучше всего выполнить навигацию, которая заменит текущую запись:
navigation.navigate(location.href, {state: newState, history: 'replace'});
Затем обработчик события "navigate" сможет отследить это изменение с помощью navigateEvent.destination :
navigation.addEventListener('navigate', navigateEvent => {
console.log(navigateEvent.destination.getState());
});
Синхронное обновление состояния
Как правило, лучше обновлять состояние асинхронно с помощью navigation.reload({state: newState}) , тогда ваш обработчик событий "navigate" сможет применить это состояние. Однако иногда изменение состояния уже полностью применено к тому моменту, когда ваш код узнает о нем, например, когда пользователь переключает элемент <details> или изменяет состояние поля ввода формы. В таких случаях может потребоваться обновление состояния таким образом, чтобы эти изменения сохранялись при перезагрузке и обходе страницы. Это возможно с помощью updateCurrentEntry() :
navigation.updateCurrentEntry({state: newState});
Также состоится мероприятие, на котором можно будет узнать об этих изменениях:
navigation.addEventListener('currententrychange', () => {
console.log(navigation.currentEntry.getState());
});
Однако, если вы реагируете на изменения состояния в "currententrychange" , вы можете разделить или даже дублировать код обработки состояния между событием "navigate" и событием "currententrychange" , тогда как navigation.reload({state: newState}) позволит вам обрабатывать это в одном месте.
Состояние против параметров URL
Поскольку состояние может быть структурированным объектом, возникает соблазн использовать его для хранения всего состояния приложения. Однако во многих случаях лучше хранить это состояние в URL-адресе.
Если вы ожидаете, что состояние будет сохраняться, когда пользователь делится URL-адресом с другим пользователем, храните его в URL-адресе. В противном случае, объект состояния — лучший вариант.
Доступ ко всем записям
Однако "текущая запись" — это еще не все. API также предоставляет способ доступа ко всему списку записей, которые пользователь просмотрел во время использования вашего сайта, с помощью вызова navigation.entries() , который возвращает массив снимков записей. Это можно использовать, например, для отображения различного пользовательского интерфейса в зависимости от того, как пользователь перешел на определенную страницу, или просто для просмотра предыдущих URL-адресов или их состояний. В текущей версии History API это невозможно.
Вы также можете отслеживать событие "dispose" для отдельных элементов NavigationHistoryEntry , которое срабатывает, когда запись больше не является частью истории браузера. Это может происходить в рамках общей очистки, а также во время навигации. Например, если вы вернетесь на 10 позиций назад, а затем перейдете вперед, эти 10 записей истории будут удалены.
Примеры
Как уже упоминалось выше, событие "navigate" срабатывает для всех типов навигации. ( В спецификации есть подробное приложение со списком всех возможных типов.)
Хотя для многих сайтов наиболее распространенным случаем будет клик пользователя по ссылке <a href="..."> , существуют два примечательных, более сложных типа навигации, которые заслуживают внимания.
Программная навигация
Первый тип навигации — программная навигация, при которой навигация осуществляется путем вызова метода в клиентском коде.
Вы можете вызвать navigation.navigate('/another_page') из любого места в вашем коде, чтобы инициировать навигацию. Это будет обработано централизованным обработчиком событий, зарегистрированным в обработчике событий "navigate" , и ваш централизованный обработчик будет вызван синхронно.
Это задумано как улучшенное объединение более старых методов, таких как location.assign() и подобных им, а также методов API истории pushState() и replaceState() .
Метод navigation.navigate() возвращает объект, содержащий два экземпляра Promise в { committed, finished } . Это позволяет вызывающему объекту ждать, пока переход не будет "зафиксирован" (видимый URL изменился и стал доступен новый NavigationHistoryEntry ) или "завершен" (все промисы, возвращаемые intercept({ handler }) , выполнены или отклонены из-за сбоя или прерывания другой навигацией).
Метод navigate также имеет объект options, в котором можно установить следующие параметры:
-
state: состояние новой записи в истории, доступное через метод.getState()объектаNavigationHistoryEntry. -
history: можно установить параметр"replace", чтобы заменить текущую запись в истории. -
info: объект, передаваемый событию navigate черезnavigateEvent.info.
В частности, info может быть полезна, например, для обозначения конкретной анимации, которая приводит к отображению следующей страницы. (Альтернативным вариантом может быть установка глобальной переменной или включение её в хэш-тег. Оба варианта несколько неудобны.) Важно отметить, что эта info не будет воспроизведена, если пользователь позже совершит навигацию, например, с помощью кнопок «Назад» и «Вперед». Фактически, в таких случаях она всегда будет undefined .
navigation также имеет ряд других методов, каждый из которых возвращает объект, содержащий { committed, finished } . Я уже упоминал traverseTo() (который принимает key , обозначающий конкретную запись в истории пользователя) и navigate() . Он также включает в себя back() , forward() и reload() . Все эти методы обрабатываются — как и navigate() — централизованным обработчиком событий "navigate" .
Отправка форм
Во-вторых, отправка HTML- <form> методом POST представляет собой особый тип навигации, и API навигации может её перехватить. Хотя она включает дополнительную полезную нагрузку, навигация всё равно обрабатывается централизованно слушателем события "navigate" .
Отправку формы можно отследить по свойству formData в NavigateEvent . Вот пример, который просто преобразует любую отправленную форму в форму, которая остаётся на текущей странице, с помощью fetch() :
navigation.addEventListener('navigate', navigateEvent => {
if (navigateEvent.formData && navigateEvent.canIntercept) {
// User submitted a POST form to a same-domain URL
// (If canIntercept is false, the event is just informative:
// you can't intercept this request, although you could
// likely still call .preventDefault() to stop it completely).
navigateEvent.intercept({
// Since we don't update the DOM in this navigation,
// don't allow focus or scrolling to reset:
focusReset: 'manual',
scroll: 'manual',
handler() {
await fetch(navigateEvent.destination.url, {
method: 'POST',
body: navigateEvent.formData,
});
// You could navigate again with {history: 'replace'} to change the URL here,
// which might indicate "done"
},
});
}
});
Чего не хватает?
Несмотря на централизованный характер обработчика события "navigate" , текущая спецификация Navigation API не запускает "navigate" при первой загрузке страницы. И для сайтов, использующих рендеринг на стороне сервера (SSR) для всех состояний, это может быть приемлемо — ваш сервер может вернуть правильное начальное состояние, что является самым быстрым способом доставки контента пользователям. Но сайтам, использующим клиентский код для создания своих страниц, может потребоваться создать дополнительную функцию для инициализации страницы.
Ещё одно намеренное дизайнерское решение API навигации заключается в том, что он работает только в рамках одного фрейма — то есть, страницы верхнего уровня или одного конкретного <iframe> . Это имеет ряд интересных последствий, которые более подробно описаны в спецификации , но на практике это уменьшит путаницу среди разработчиков. Предыдущий API истории имел ряд сложных частных случаев, таких как поддержка фреймов, а переработанный API навигации обрабатывает эти частные случаи с самого начала.
Наконец, пока нет единого мнения относительно программного изменения или перестановки списка записей, по которым пользователь перемещался. В настоящее время это обсуждается , но одним из вариантов может быть разрешение только удаления: либо исторических записей, либо «всех будущих записей». Последний вариант позволит сохранять временное состояние. Например, как разработчик, я мог бы:
- Задайте пользователю вопрос, перейдя по новому URL-адресу или в другое состояние.
- Предоставить пользователю возможность завершить работу (или вернуться назад).
- удалить запись из истории по завершении задачи
Это идеально подошло бы для временных модальных окон или промежуточных окон: новый URL-адрес пользователь может закрыть с помощью жеста «Назад», но при этом случайно не сможет снова открыть его, перейдя на «Вперед» (поскольку запись была удалена). Однако с текущим API истории это просто невозможно.
Попробуйте API навигации.
API навигации доступен в Chrome 102 без флагов. Вы также можете попробовать демо-версию от Доменика Дениколы .
Хотя классический API истории кажется простым, он не очень хорошо определен и имеет большое количество проблем, связанных с частными случаями и тем, как он реализован по-разному в разных браузерах. Мы надеемся, что вы рассмотрите возможность оставить отзыв о новом API навигации.
Ссылки
- WICG/navigation-api
- Позиция Mozilla по стандартам
- Намерение создать прототип
- Обзор TAG
- запись Chromestatus
Благодарности
Благодарим Томаса Штайнера , Доменика Дениколу и Нейта Чапина за рецензирование этого поста.