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

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

Поддержка браузера

  • 102
  • 102
  • Икс
  • Икс

Источник

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

Хотя SPA могли предоставить вам эту функцию через History API (или, в ограниченных случаях, путем настройки #hash-части сайта), это неуклюжий API , разработанный задолго до того, как SPA стали нормой, и Интернет жаждет совершенно новый подход. API навигации — это предлагаемый API, который полностью меняет это пространство, а не пытается просто исправить острые углы History API. (Например, компания Scroll Restoration исправила History API, а не пыталась изобрести его заново.)

В этом посте описывается API навигации на высоком уровне. Если вы хотите ознакомиться с техническим предложением, ознакомьтесь с проектом отчета в репозитории WICG .

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

Чтобы использовать API навигации, начните с добавления прослушивателя "navigate" к глобальному объекту navigation . Это событие принципиально централизовано : оно срабатывает для всех типов навигации, независимо от того, выполнил ли пользователь действие (например, щелкнул ссылку, отправил форму или перешел вперед и назад) или когда навигация запускается программно (т. е. через интерфейс вашего сайта). код). В большинстве случаев это позволяет вашему коду переопределить поведение браузера по умолчанию для этого действия. Для одностраничных приложений это, скорее всего, означает, что пользователь будет оставаться на одной и той же странице и загружать или изменять содержимое сайта.

Прослушивателю 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
Истинно, если навигация осуществляется по тому же документу, а хэш — единственная часть URL-адреса, отличающаяся от текущего URL-адреса. В современных SPA хэш должен использоваться для ссылки на различные части текущего документа. Итак, если hashChange имеет значение true, вам, вероятно, не нужно перехватывать эту навигацию.
downloadRequest
Если это правда, навигация была инициирована ссылкой с атрибутом download . В большинстве случаев вам не нужно это перехватывать.
formData
Если это значение не равно нулю, то эта навигация является частью отправки формы 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 . Для получения дополнительной информации см. Прерываемая выборка .

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

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

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

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

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

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

Примеры

Событие "navigate" срабатывает для всех типов навигации, как упоминалось выше. (На самом деле в спецификации всех возможных типов есть длинное приложение .)

Хотя для многих сайтов наиболее распространенным случаем является нажатие пользователем <a href="..."> , есть два примечательных, более сложных типа навигации, которые стоит рассмотреть.

Программная навигация

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

Вы можете вызвать navigation.navigate('/another_page') из любого места вашего кода, чтобы вызвать навигацию. Это будет обрабатываться централизованным прослушивателем событий, зарегистрированным в прослушивателе "navigate" , и ваш централизованный прослушиватель будет вызываться синхронно.

Это задумано как улучшенное объединение старых методов, таких как location.assign() и ему подобных, а также методов History API pushState() и replaceState() .

Метод navigation.navigate() возвращает объект, который содержит два экземпляра Promise в { committed, finished } . Это позволяет инициатору дождаться, пока переход не будет «зафиксирован» (видимый URL-адрес изменится и станет доступен новый элемент NavigationHistoryEntry ) или «завершится» (все обещания, возвращаемые intercept({ handler }) , будут завершены — или отклонены из-за сбой или прерывание другой навигации).

Метод navigate также имеет объект параметров, где вы можете установить:

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

В частности, info может быть полезна, например, для обозначения конкретной анимации, вызывающей появление следующей страницы. (Альтернативой может быть установка глобальной переменной или включение ее как часть #hash. Оба варианта немного неудобны.) Примечательно, что эта 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" , текущая спецификация API навигации не запускает "navigate" при первой загрузке страницы. А для сайтов, которые используют рендеринг на стороне сервера (SSR) для всех состояний, это может быть хорошо — ваш сервер может возвращать правильное начальное состояние, что является самым быстрым способом доставки контента вашим пользователям. Но сайтам, которые используют клиентский код для создания своих страниц, возможно, потребуется создать дополнительную функцию для инициализации своей страницы.

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

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

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

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

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

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

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

Рекомендации

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

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