Navigation API предоставляет возможность инициировать (программно запускать), перехватывать и управлять навигацией в браузере. Он также позволяет исследовать (traverse) сущности истории (history entries) приложения. Это улучшенный вариант предыдущих возможностей веб-платформы, связанных с навигацией, таких как History API и window.location, который решает их проблемы и специально предназначен для одностраничных приложений (single-page applications, SPA).

На сегодняшний день Navigation API поддерживается всеми основными браузерами:

❯ Концепции и использование

В SPA шаблон страницы имеет тенденцию оставаться одинаковым в процессе использования, содержимое страницы динамически модифицируется при посещении пользователем разных страниц или разделов. Как следствие, в браузер загружается только одна страница, что ломает пользовательский опыт по переходу назад и вперед между разными локациями в истории отображения. Эта проблема может быть частично решена с помощью History API, но он не рассчитан на SPA. Navigation API призван закрыть эту брешь.

Navigation API доступен через свойство Window.navigation, которое возвращает ссылку на глобальный объект Navigation. Каждый объект window содержит собственный экземпляр navigation.

❯ Обработка навигации

В интерфейс navigation входит несколько связанных с навигацией событий, самым важным из которых является navigate. Это событие возникает при любом типе навигации, что позволяет управлять всеми навигациями в одном месте. Это идеально подходит для функционала маршрутизации в SPA. Обработчику события navigate передается объект NavigateEvent, который содержит большое количество информации о навигации, которую можно использовать для определения логики обработки конкретного перехода.

Ключевые свойства NavigateEvent:

  • canIntercept — если имеет значение false, перехватить навигацию невозможно. Например, невозможен перехват навигации между разными источниками (cross-origin) (см. пример «Перехват навигации» ниже).

  • destination.url — URL, к которому выполняется переход. Пожалуй, самая важная информация, которую следует учитывать при навигации. hashChange — имеет значение true, если навигация осуществляется в пределах одного документа, и хеш — единственная часть, отличающаяся от текущего URL. В современных SPA хеш должен использоваться для ссылок на разные части текущего документа. Поэтому, если hashChange имеет значение true, вероятно, такую навигацию перехватывать не следует.

  • downloadRequest — если имеет значение true, то навигация была инициирована ссылкой (<a>) с атрибутом download. В большинстве случаев такую навигацию перехватывать не нужно.

  • formData — если значение не равно null, значит, эта навигация является частью отправки формы методом POST. Перехват такой навигации тоже под вопросом.

  • navigationType — один из вариантов: "reload", "push", "replace" или "traverse". В случае "traverse" навигацию нельзя отменить с помощью метода preventDefault().

Учитывая все это, можно реализовать такую вспомогательную функцию:

// Если функция возвращает true, навигацию перехватывать не нужно
function shouldNotIntercept(navigationEvent) {
  return (
    // Нельзя перехватить по умолчанию
    !navigationEvent.canIntercept ||
    // Если изменился только хеш, просто позволяем браузеру
    // выполнить прокрутку до соответствующего контента
    navigationEvent.hashChange ||
    // Если выполняется скачивание файла,
    // не мешаем браузеру выполнить эту операцию
    navigationEvent.downloadRequest ||
    // Если выполняется отправка формы,
    // пропускаем ее на сервер
    navigationEvent.formData
  );
}

Событие navigate также возникает при использовании старых методов навигации, таких location.assign(), pushState() и replaceState(). Сигнатуры старых методов остаются прежними, т.е. они не возвращают промисы в соответствии с новой спецификацией.

Обработчик события navigate централизует обработку изменения URL в SPA. Это сложная задача при использовании старых API. Например, вот как это может выглядеть при использовании History API:

function updatePage(event) {
  event.preventDefault(); // отключаем дефолтную обработку клика по ссылке браузером
  window.history.pushState(null, '', event.target.href);
  // Настраиваем страницу на основе нового URL
}
const links = [...document.querySelectorAll('a[href]')];
links.forEach(link => link.addEventListener('click', updatePage));

Это работает, но есть нюансы. Ссылки могут добавляться и удаляться динамически, и клик по ссылке — далеко не единственный способ навигации. Например, пользователь может отправить форму или даже использовать элемент .

Кроме того, приведенный выше код не обрабатывает навигацию «Назад/Вперед». Для этого существует другое событие — popstate.

Объект NavigationEvent предоставляет два метода:

  • intercept() — позволяет определять кастомную логику навигации и принимает следующие аргументы:

    • handler — функция обратного вызова, позволяющая определять логику навигации как после фиксации (commit) навигации, так и непосредственно перед этим. Например, здесь можно загружать новый контент в UI на основе нового URL или выполнять перенаправление на страницу авторизации, если URL указывает на защищенную страницу, а пользователь не авторизован.

    • свойства, позволяющие включать/выключать дефолтное поведение браузера по управлению фокусом (focusReset) и прокруткой (scroll) в процессе навигации.

  • scroll() — позволяет вручную инициировать обработку прокрутки браузером (например, к идентификатору фрагмента в URL), когда это имеет смысл, вместо ожидания автоматической обработки прокрутки браузером.

После инициализации навигации и вызова обработчика intercept(), создается экземпляр NavigationTransition (доступный через Navigation.transition), который может использоваться для отслеживания процесса выполняемой навигации.

«transition» в контексте навигации означает переход от одной сущности истории к другой. Не путайте его с переходом CSS.

Когда промис, возвращаемый intercept(), разрешается, возникает событие navigatesuccess объекта Navigation, что позволяет выполнить очистку после завершения успешной навигации. Отклонение промиса означает провал навигации и вызывает событие navigateerror, что позволяет мягко обработать провал. Также имеется свойство finished в значении, возвращаемом методами навигации (такими как navigate()), которое разрешается или отклоняется одновременно с названными событиями, что предоставляет еще одну возможность по обработке результата навигации.

❯ Программное обновление и исследование истории навигации

Каждая новая локация, посещенная пользователем, приводит к созданию сущности (записи) истории навигации (history navigation entry). Каждая сущность истории представлена экземпляром NavigationHistoryEntry. Сущность содержит несколько свойств, таких как ключ (key), URL и информацию о состоянии (state). Текущая сущность доступна через Navigation.currentEntry, а массив всех сущностей можно получить с помощью Navigation.entries. Каждый объект NavigationHistoryEntry имеет событие dispose, которое возникает, когда сущность удаляется из истории браузера. Например, если пользователь перешел назад три раза, а затем перешел вперед в другое место, три сущности истории будут удалены.

Объект Navigation содержит все методы, необходимые для обновления и исследования истории навигации:

  • navigate() — выполняет переход к новому URL, создавая новую сущность истории навигации.

  • reload() — перезагружает текущую сущность истории навигации.

  • back() — выполняет переход к предыдущей сущности истории навигации, если это возможно.

  • forward() — выполняет переход к следующей сущности истории навигации, если это возможно.

  • traverseTo() — выполняет переход к определенной сущности истории навигации по значению ключа из свойства NavigationHistoryEntry.key.

Каждый из этих методов возвращает объект, содержащий два промиса — { committed, finished }. Это позволяет вызывающему ждать:

  • разрешения committed — изменился видимый URL, создана новая NavigationHistoryEntry

  • разрешения finished — все промисы, возвращенные intercept(), разрешились. Это эквивалент разрешения промиса NavigationTransition.finished при возникновении события navigatesuccess

  • отклонения любого промиса — провал навигации

Метод navigate() (и некоторые другие) в качестве аргументов принимает новый URL и опциональный объект со следующими настройками:

  • state — новое состояние сущности истории (см. раздел «Состояние» ниже)

  • history — значение "replace" заменяет текущую сущность истории

  • info — объект, передаваемый событию navigate и доступный через NavigateEvent.info. Может быть полезен, например, для определения анимации отображения следующей страницы. Важно отметить, что info не будет воспроизведен, если пользователь совершит навигацию, например, с помощью кнопок «Назад» и «Вперед». В этом случае info будет иметь значение undefined

❯ Состояние

Navigation API позволяет сохранять состояние в каждой сущности истории. Никаких требований к состоянию не предъявляется, оно может содержать любую информацию. Например, оно может содержать свойство visitCount с количеством посещений страницы или объект со свойствами UI для его восстановления при возврате пользователя на страницу.

Для получения состояния NavigationHistoryEntry используется метод getState(). По умолчанию состояние имеет значение undefined.

Установка состояния немного сложнее. getState() возвращает копию состояния, ее модификация не влияет на исходное состояние. Для обновления состояния сущности в метод navigate() или reload() передается опциональный объект со свойством state, содержащим новое состояние сущности истории. Состояние сущности обновляется после фиксации навигации.

Однако в некоторых случаях изменение состояния не зависит от навигации или перезагрузки страницы, например, когда страница содержит раскрываемый элемент <details>. В этом случае состояние раскрытости можно хранить в сущности истории для последующего восстановления при возврате пользователя или перезагрузки страницы. Для таких кейсов предназначен метод Navigation.updateCurrentEntry(). После изменения текущей сущности возникает событие currententrychange (см. пример «Обновление состояния сущности истории» ниже).

❯ Ограничения

Сейчас Navigation API имеет несколько заметных ограничений:

  1. Событие navigate не возникает при первой загрузке страницы. Это подходит для сайтов, использующих серверный рендеринг (server side rendering, SSR) — сервер возвращает правильное начальное состояние, что является самым быстрым способом доставки контента пользователям. Но сайтам, использующим рендеринг на стороне клиента (client side rendering, CSR), может потребоваться дополнительная функция для инициализации страницы.

  2. Navigation API работает только в одном фрейме — странице верхнего уровня или определенном <iframe>. На практике это уменьшает путаницу. В History API сущности истории страниц и <iframe> содержатся в одном стеке.

  3. Navigation API не позволяет программно модифицировать или менять порядок элементов в списке сущностей истории (обсуждение на GitHub). Однако в некоторых случаях может быть полезным иметь временное состояние. Например, выполняется навигация к временному модальному окну, запрашивающему определенную информацию о пользователе. Затем выполняется навигация к предыдущему URL. В этом случае требуется программно удалить сущность истории модалки, чтобы нажатие кнопки «Вперед» не приводило к ее повторному открытию.

❯ Примеры использования

Перехват навигации

Вызов intercept({ handler }) в обработчике события navigate сообщает браузеру, что страница подготавливается к новому, обновленному состоянию, и что навигация может занять некоторое время.

Браузер начинает с захвата положения прокрутки для текущего состояния, чтобы его можно было при необходимости восстановить позже, а затем вызывает handler(). Если handler() возвращает промис (что происходит автоматически в случае асинхронных функций), этот промис сообщает браузеру, сколько времени заняла навигация и была ли она успешной:

navigation.addEventListener("navigate", (event) => {
  // См. выше
  if (shouldNotIntercept(event)) return

  const url = new URL(event.destination.url);

  if (url.pathname.startsWith("/articles/")) {
    event.intercept({
      async handler() {
        // Получаем новый контент
        const articleContent = await getArticleContent(url.pathname);
        // И рендерим его
        renderArticlePage(articleContent);
      },
    });
  }
});

Существуют случаи, когда вызывать методы intercept() и preventDefault() нельзя. Нельзя перехватывать (intercept()) навигацию из другого источника (origin). Нельзя отменять (preventDefault()) навигацию, запущенную пользователем путем нажатия кнопок «Назад» или «Вперед» в браузере (обсуждение на GitHub).

Фиксация навигации

При перехвате навигации 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() {
        // URL уже изменился, быстро показываем заглушку
        renderArticlePagePlaceholder();
        // Затем получаем реальные данные
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

Это не только позволяет избежать проблем с разрешением URL, но и создает ощущение скорости, поскольку пользователь мгновенно получает ответ.

Отмена навигации

Поскольку в handler() можно выполнять асинхронную работу, навигация может стать избыточной. Это происходит в следующих случаях:

  • пользователь переходит по другой ссылке, или какой-либо код инициирует другую навигацию. В этом случае старая навигация отменяется в пользу новой

  • пользователь нажимает кнопку «Стоп» в браузере

Для обработки таких кейсов NavigateEvent предоставляет свойство signal, которое представляет собой AbortSignal.

Примечательно, что этот AbortSignal можно передать в любой вызов функции fetch(), что отменит выполняющиеся сетевые запросы при отмене навигации. Это экономит пропускную способность и отклоняет промис, возвращаемый fetch(), предотвращая выполнение последующих действий, таких как обновление DOM для отображения неверной навигации.

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        renderArticlePagePlaceholder();
        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);
      },
    });
  }
});

Отправка формы

Событие navigate возникает также при отправке формы методом POST. Данные формы доступны через свойство NavigateEvent.formData:

navigation.addEventListener('navigate', (event) => {
  // Нас интересует только отправка формы
  if (event.formData) {
    event.intercept({
      async handler() {
        const data = event.formData;
        console.log(`Отправка данных формы...`);

        const username = data.get('username');

        // Отправляет данные на сервер
        postFormData(data);

        // Обновляем UI без перезагрузки страницы
        renderSuccessMessage(username);
      }
    });
  }
});

// Стандартная форма HTML (JS-обработчик 'onsubmit' не требуется)
// <form action="/login" method="POST">
//   <input name="username" type="text" required />
//   <button type="submit">Login</button>
// </form>

Установка фокуса

Как только промис, возвращаемый handler(), разрешается, браузер фокусируется на первом элементе с атрибутом autofocus или на элементе <body> при отсутствии таких элементов.

Отключить это поведение можно следующим образом:

navigateEvent.intercept({
  focusReset: 'manual',
  async handler() {
    // ...
  },
});

Обработка прокрутки

Метод NavigationEvent.scroll() позволяет управлять восстановлением прокрутки браузером.

По умолчанию браузер пытается восстановить положение прокрутки при вызове intercept(). Но что если содержимое нашей страницы в этот момент еще не готово (например, мы еще не получили ответ от нашего API)? Если браузер выполнит прокрутку перед рендерингом контента, положение прокрутки будет установлено неправильно или останется наверху страницы.

Предположим, что пользователь нажал кнопку «Назад» для возврата к длинному списку элементов. Нам нужно получить и отрендерить эти элементы перед выполнением прокрутки:

navigation.addEventListener('navigate', (event) => {
  if (!event.canIntercept) return;

  event.intercept({
    // Отключаем дефолтную обработку прокрутки браузером
    scroll: 'manual',

    async handler() {
      // Получаем данные и рендерим их
      const data = await fetchListData();
      renderItems(data);

      // Теперь, когда элементы добавлены в DOM, и страница имеет правильную высоту,
      // можно безопасно выполнить прокрутку к сохраненному положению (для навигации "Назад/Вперед")
      // или в начало страницы (для новой навигации)
      event.scroll();
    }
  });
});

Переход к определенной сущности истории

Как отмечалось выше, navigation.currentEntry предоставляет доступ к текущей сущности истории. Это объект, описывающий текущую локацию пользователя. Эта сущность включает текущий URL, метаданные, которые можно использовать для идентификации этой сущности, а также состояние, предоставленное разработчиком.

Даже сайты, которые явно не используют Navigation API, будут иметь currentEntry, и эта сущность будет обновляться или заменяться, даже при использовании старых методов History API.

Метаданные включают key — уникальное строковое значение каждой сущности. Этот ключ остается неизменным, даже если URL или состояние текущей сущности меняются. Он будет находиться в том же слоте (slot). И наоборот, если пользователь нажимает кнопку «Назад», а затем снова открывает ту же страницу, key изменится, поскольку новая сущность истории создаст новый слот.

Для разработчика key может быть полезен для перенаправления пользователя к определенной сущности истории навигации:

// При запуске приложения получаем ключ первой загруженной страницы,
// чтобы пользователь всегда мог к ней вернуться
const { key } = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);

// Несмотря на переход к другой странице, backToHomeButton будет работать
await navigation.navigate('/another_url').finished;

Обновление состояния сущности истории

Как уже упоминалось, модификация копии состояния сущности истории не влияет на ее исходное состояние, поэтому:

const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// Но
console.info(navigation.currentEntry.getState().count); // по-прежнему равняется 1

Правильный способ обновления состояния сущности истории:

navigation.navigate(url, { state: newState });
// Или
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> или изменяет состояние поля ввода формы. В таких случаях может потребоваться обновление состояния таким образом, чтобы эти изменения сохранялись при перезагрузке и обходе (traverse) страницы. Это возможно с помощью метода updateCurrentEntry():

navigation.updateCurrentEntry({ state: newState });

Вот как можно узнать об этих изменениях:

navigation.addEventListener('currententrychange', () => {
  console.log(navigation.currentEntry.getState());
});

Поскольку состояние может быть структурированным объектом, возникает соблазн использовать его для хранения всего состояния приложения. Однако во многих случаях лучше хранить это состояние в URL.

Переходы отображения

Navigation API и View Transitions API могут использоваться совместно для создания плавных переходов в SPA.

При перехвате события навигации обновления DOM можно «завернуть» в document.startViewTransition(). Это укажет браузеру захватить старое состояние, выполнить необходимые обновления DOM и анимировать их к новому состоянию:

navigation.addEventListener('navigate', (event) => {
  if (shouldNotIntercept(event)) return;

  const url = new URL(event.destination.url);

  event.intercept({
    async handler() {
      // Сначала получаем содержимое новой страницы (опционально, но рекомендуется)
      const content = await fetchNewPageContent(url.pathname);

      // Запускаем переход отображения
      document.startViewTransition(() => {
        // Обновляем DOM в коллбэке.
        // Браузер делает снимки (snapshots) старого UI
        // перед обновлением DOM и нового UI после обновления DOM
        document.getElementById('app').innerHTML = content;
      });
    }
  });
});

Источники:


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (0)