Современная маршрутизация на стороне клиента: API навигации

Стандартизация клиентской маршрутизации с помощью совершенно нового API, который полностью меняет процесс создания одностраничных приложений.

Джейк Арчибальд
Jake Archibald

Browser Support

  • Хром: 102.
  • Край: 102.
  • Firefox: не поддерживается.
  • Safari: не поддерживается.

Source

Одностраничные приложения, или 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" , который содержит информацию о навигации, например, 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, если навигация — same-document, и хэш — единственная часть 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 при извлечении данных или загрузке новых подресурсов.

Способ отсрочки изменения URL-адреса обсуждается на GitHub , но обычно рекомендуется немедленно обновить страницу, добавив в нее какой-либо плейсхолдер для входящего контента:

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 . Для получения дополнительной информации см. Abortable fetch .

Короткая версия заключается в том, что он в основном предоставляет объект, который запускает событие, когда вам следует остановить работу. В частности, вы можете передать 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 навигации активирует "navigatesuccess" с Event .
  • Если возвращенное Promise отклоняется, API активирует "navigateerror" с ErrorEvent .

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

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 , уникальное строковое свойство каждой записи, которое представляет текущую запись и ее slot . Этот key остается тем же, даже если URL или состояние текущей записи изменяется. Он все еще находится в том же слоте. И наоборот, если пользователь нажимает Back и затем повторно открывает ту же страницу, key изменится, так как эта новая запись создает новый слот.

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

// 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;

Состояние

Navigation API выводит на поверхность понятие "state", которое является информацией, предоставляемой разработчиком, которая постоянно хранится в текущей записи истории, но которая не видна пользователю напрямую. Это очень похоже на history.state в History 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

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

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

Доступ ко всем записям

Однако «текущая запись» — это еще не все. API также предоставляет способ доступа ко всему списку записей, которые пользователь просматривал при использовании вашего сайта, с помощью вызова navigation.entries() , который возвращает массив моментальных снимков записей. Это можно использовать, например, для отображения другого пользовательского интерфейса в зависимости от того, как пользователь перешел на определенную страницу, или просто для просмотра предыдущих URL-адресов или их состояний. Это невозможно с текущим API истории.

Вы также можете прослушивать событие "dispose" для отдельных NavigationHistoryEntry s, которое запускается, когда запись больше не является частью истории браузера. Это может произойти как часть общей очистки, но также может произойти при навигации. Например, если вы вернетесь на 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 также имеет объект параметров, в котором можно задать:

  • state : состояние новой записи истории, доступное через метод .getState() в NavigationHistoryEntry .
  • history : можно установить значение "replace" чтобы заменить текущую запись истории.
  • info : объект для передачи событию navigation через navigateEvent.info .

В частности, info может быть полезно, например, для обозначения определенной анимации, которая вызывает появление следующей страницы. (Альтернативой может быть установка глобальной переменной или включение ее в качестве части #hash. Оба варианта немного неудобны.) Примечательно, что эта info не будет воспроизведена, если пользователь позже вызовет навигацию, например, с помощью кнопок Назад и Вперед. Фактически, она всегда будет undefined в этих случаях.

Демонстрация открывания слева или справа

navigation также имеет ряд других методов навигации, все из которых возвращают объект, содержащий { committed, finished } . Я уже упоминал traverseTo() (который принимает key , обозначающий определенную запись в истории пользователя) и navigate() . Он также включает back() , forward() и reload() . Все эти методы обрабатываются — как и navigate() — централизованным прослушивателем событий "navigate" .

Форма отправки

Во-вторых, отправка HTML <form> через POST — это особый тип навигации, и Navigation 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" при первой загрузке страницы. И для сайтов, которые используют Server Side Rendering (SSR) для всех состояний, это может быть нормально — ваш сервер может вернуть правильное начальное состояние, что является самым быстрым способом доставить контент вашим пользователям. Но сайтам, которые используют клиентский код для создания своих страниц, может потребоваться создать дополнительную функцию для инициализации своей страницы.

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

Наконец, пока нет консенсуса по программному изменению или переупорядочиванию списка записей, по которым перемещался пользователь. В настоящее время это обсуждается , но одним из вариантов может быть разрешение только удаления: либо исторических записей, либо «всех будущих записей». Последнее допускает временное состояние. Например, как разработчик, я мог бы:

  • задать вопрос пользователю, перейдя по новому URL-адресу или состоянию
  • разрешить пользователю завершить свою работу (или вернуться назад)
  • удалить запись истории по завершении задачи

Это может быть идеальным для временных модальных окон или межстраничных объявлений: новый URL-адрес — это то, из чего пользователь может выйти с помощью жеста «Назад», но затем он не сможет случайно перейти вперед, чтобы открыть его снова (потому что запись была удалена). Это просто невозможно с текущим API истории.

Попробуйте API навигации

API навигации доступен в Chrome 102 без флагов. Вы также можете попробовать демо от Доменика Дениколы .

Хотя классический API истории кажется простым, он не очень хорошо определен и имеет большое количество проблем в угловых случаях и в том, как он был реализован по-разному в разных браузерах. Мы надеемся, что вы рассмотрите возможность предоставления отзывов о новом API навигации.

Ссылки

Благодарности

Спасибо Томасу Штайнеру , Доменику Дениколе и Нейту Чапину за рецензирование этой публикации.