Roteamento moderno no lado do cliente: API Navigation

Padronização do roteamento do lado do cliente por meio de uma API totalmente nova que reformula completamente a criação de aplicativos de página única.

Compatibilidade com navegadores

  • Chrome: 102.
  • Edge: 102.
  • Firefox: não é compatível.
  • Safari: não é compatível.

Origem

Os aplicativos de página única, ou SPAs, são definidos por um recurso principal: reescrever dinamicamente o conteúdo à medida que o usuário interage com o site, em vez do método padrão de carregar páginas totalmente novas do servidor.

Embora os SPAs tenham conseguido oferecer esse recurso usando a API History (ou, em casos limitados, ajustando a parte #hash do site), ela é uma API desajeitada desenvolvida muito antes dos SPAs se tornarem a norma. A Web está clamando por uma abordagem completamente nova. A API Navigation é uma API proposta que reformula completamente esse espaço, em vez de tentar corrigir as falhas da API History. Por exemplo, a restauração de rolagem corrigiu a API History em vez de tentar reinventá-la.

Esta postagem descreve a API Navigation de maneira geral. Para ler a proposta técnica, consulte o Draft Report no repositório do WICG (em inglês).

Exemplo de uso

Para usar a API Navigation, comece adicionando um listener "navigate" ao objeto navigation global. Esse evento é centralizado: ele é acionado para todos os tipos de navegação, seja quando o usuário realiza uma ação (como clicar em um link, enviar um formulário ou voltar e avançar) ou quando a navegação é acionada programaticamente (ou seja, pelo código do site). Na maioria dos casos, ele permite que o código substitua o comportamento padrão do navegador para essa ação. Para SPAs, isso provavelmente significa manter o usuário na mesma página e carregar ou mudar o conteúdo do site.

Um NavigateEvent é transmitido para o listener "navigate", que contém informações sobre a navegação, como o URL de destino, e permite que você responda à navegação em um local centralizado. Um listener "navigate" básico pode ser assim:

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});
  }
});

Você pode lidar com a navegação de duas maneiras:

  • Chamar intercept({ handler }) (conforme descrito acima) para processar a navegação.
  • Chamar preventDefault(), que pode cancelar a navegação completamente.

Este exemplo chama intercept() no evento. O navegador chama o callback handler, que configura o próximo estado do site. Isso vai criar um objeto de transição, navigation.transition, que outro código pode usar para acompanhar o progresso da navegação.

intercept() e preventDefault() geralmente são permitidos, mas há casos em que não é possível fazer chamadas. Não é possível processar navegações por intercept() se elas forem entre origens. E não é possível cancelar uma navegação usando preventDefault() se o usuário estiver pressionando os botões "Voltar" ou "Avançar" no navegador. Não é possível prender os usuários no seu site. Confira a discussão no GitHub.

Mesmo que você não consiga interromper ou interceptar a navegação, o evento "navigate" ainda será acionado. Ele é informativo, então seu código pode, por exemplo, registrar um evento do Google Analytics para indicar que um usuário está saindo do seu site.

Por que adicionar outro evento à plataforma?

Um listener de eventos "navigate" centraliza o processamento de mudanças de URL em um SPA. Isso é difícil de fazer com APIs mais antigas. Se você já escreveu o roteamento para sua própria SPA usando a API History, pode ter adicionado um código como este:

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

Isso é bom, mas não é exaustivo. Os links podem aparecer e desaparecer da sua página, e eles não são a única maneira de os usuários navegarem pelas páginas. Por exemplo, eles podem enviar um formulário ou até mesmo usar um mapa de imagem. Sua página pode lidar com esses itens, mas há uma longa cauda de possibilidades que podem ser simplificadas, algo que a nova API Navigation consegue fazer.

Além disso, o exemplo acima não lida com a navegação para frente/para trás. Há outro evento para isso, "popstate".

Pessoalmente, a API History parece ajudar com essas possibilidades. No entanto, ele tem apenas duas áreas de superfície: responder se o usuário pressionar "Voltar" ou "Avançar" no navegador, além de enviar e substituir URLs. Ele não tem uma analogia com "navigate", exceto se você configurar manualmente listeners para eventos de clique, por exemplo, como demonstrado acima.

Como decidir como lidar com uma navegação

O navigateEvent contém muitas informações sobre a navegação que podem ser usadas para decidir como lidar com uma navegação específica.

As principais propriedades são:

canIntercept
Se esse valor for falso, não será possível interceptar a navegação. As navegações entre origens e as transições entre documentos não podem ser interceptadas.
destination.url
Provavelmente a informação mais importante a considerar ao lidar com a navegação.
hashChange
Verdadeiro se a navegação for no mesmo documento e o hash for a única parte do URL que é diferente do URL atual. Em SPAs modernos, o hash deve ser usado para vincular diferentes partes do documento atual. Portanto, se hashChange for verdadeiro, provavelmente não será necessário interceptar essa navegação.
downloadRequest
Se isso for verdadeiro, a navegação foi iniciada por um link com um atributo download. Na maioria dos casos, não é necessário interceptar isso.
formData
Se não for nulo, essa navegação faz parte de um envio de formulário POST. Considere isso ao lidar com a navegação. Se você quiser processar apenas navegações GET, evite interceptar navegações em que formData não seja nulo. Confira o exemplo de como processar envios de formulários mais adiante neste artigo.
navigationType
É um dos campos "reload", "push", "replace" ou "traverse". Se for "traverse", essa navegação não poderá ser cancelada usando preventDefault().

Por exemplo, a função shouldNotIntercept usada no primeiro exemplo pode ser algo como:

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
  );
}

Interceptação

Quando o código chama intercept({ handler }) no listener "navigate", ele informa ao navegador que está preparando a página para o estado novo e atualizado e que a navegação pode levar algum tempo.

O navegador começa capturando a posição de rolagem do estado atual para que ela possa ser restaurada mais tarde, se necessário, e depois chama o callback handler. Se o handler retornar uma promessa (o que acontece automaticamente com funções assíncronas), essa promessa informará ao navegador quanto tempo a navegação leva e se ela foi bem-sucedida.

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);
      },
    });
  }
});

Assim, essa API introduz um conceito semântico que o navegador entende: uma navegação de SPA está ocorrendo no momento, ao longo do tempo, mudando o documento de um URL e estado anterior para um novo. Isso tem vários benefícios potenciais, incluindo acessibilidade: os navegadores podem mostrar o início, o fim ou a possível falha de uma navegação. O Chrome, por exemplo, ativa o indicador de carregamento nativo e permite que o usuário interaja com o botão de interrupção. Isso não acontece quando o usuário navega usando os botões "Voltar" e "Avançar", mas será corrigido em breve.

Ao interceptar navegações, o novo URL vai entrar em vigor logo antes de o callback handler ser chamado. Se você não atualizar o DOM imediatamente, o conteúdo antigo será exibido junto com o novo URL. Isso afeta coisas como a resolução de URL relativo ao buscar dados ou carregar novos subrecursos.

Uma maneira de atrasar a mudança do URL está sendo discutida no GitHub, mas geralmente é recomendável atualizar a página imediatamente com algum tipo de marcador de posição para o conteúdo recebido:

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);
      },
    });
  }
});

Isso não apenas evita problemas de resolução de URL, mas também parece rápido porque você responde instantaneamente ao usuário.

Sinais de interrupção

Como é possível fazer trabalho assíncrono em um gerenciador intercept(), é possível que a navegação se torne redundante. Isso acontece quando:

  • O usuário clica em outro link ou algum código realiza outra navegação. Nesse caso, a navegação antiga é abandonada em favor da nova.
  • O usuário clica no botão "stop" no navegador.

Para lidar com qualquer uma dessas possibilidades, o evento transmitido ao listener "navigate" contém uma propriedade signal, que é um AbortSignal. Para mais informações, consulte Busca interrompível.

Basicamente, ele fornece um objeto que aciona um evento quando você precisa interromper o trabalho. É possível transmitir um AbortSignal para todas as chamadas feitas para fetch(), o que vai cancelar as solicitações de rede em andamento se a navegação for interrompida. Isso economiza a largura de banda do usuário e rejeita o Promise retornado por fetch(), impedindo que qualquer código seguinte realize ações como atualizar o DOM para mostrar uma navegação de página agora inválida.

Confira o exemplo anterior, mas com getArticleContent inline, mostrando como o AbortSignal pode ser usado com 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);
      },
    });
  }
});

Processamento de rolagem

Quando você intercept() uma navegação, o navegador tenta processar a rolagem automaticamente.

Para navegações para uma nova entrada de histórico (quando navigationEvent.navigationType é "push" ou "replace"), isso significa tentar rolar até a parte indicada pelo fragmento de URL (o bit após #) ou redefinir o rolagem para o topo da página.

Para recargas e transições, isso significa restaurar a posição de rolagem para onde ela estava na última vez que a entrada do histórico foi mostrada.

Por padrão, isso acontece quando a promessa retornada pela handler é resolvida, mas, se fizer sentido rolar mais cedo, chame 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);
      },
    });
  }
});

Como alternativa, é possível desativar totalmente o processamento automático de rolagem definindo a opção scroll de intercept() como "manual":

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

Processamento de foco

Quando a promessa retornada pela handler for resolvida, o navegador vai focar o primeiro elemento com o atributo autofocus definido ou o elemento <body> se nenhum elemento tiver esse atributo.

Para desativar esse comportamento, defina a opção focusReset de intercept() como "manual":

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

Eventos de sucesso e falha

Quando o gerenciador intercept() é chamado, uma destas duas coisas acontece:

  • Se o Promise retornado for atendido (ou se você não tiver chamado intercept()), a API Navigation vai acionar "navigatesuccess" com um Event.
  • Se o Promise retornado for rejeitado, a API vai disparar "navigateerror" com um ErrorEvent.

Esses eventos permitem que o código lide com sucesso ou falha de maneira centralizada. Por exemplo, você pode lidar com o sucesso ocultando um indicador de progresso exibido anteriormente, como este:

navigation.addEventListener('navigatesuccess', event => {
  loadingIndicator.hidden = true;
});

Ou você pode mostrar uma mensagem de erro em caso de falha:

navigation.addEventListener('navigateerror', event => {
  loadingIndicator.hidden = true; // also hide indicator
  showMessage(`Failed to load page: ${event.message}`);
});

O listener de eventos "navigateerror", que recebe um ErrorEvent, é particularmente útil, porque garante que todos os erros do código que configura uma nova página sejam recebidos. Você pode simplesmente await fetch() sabendo que, se a rede estiver indisponível, o erro será roteado para "navigateerror".

navigation.currentEntry fornece acesso à entrada atual. É um objeto que descreve onde o usuário está no momento. Essa entrada inclui o URL atual, os metadados que podem ser usados para identificar essa entrada ao longo do tempo e o estado fornecido pelo desenvolvedor.

Os metadados incluem key, uma propriedade de string exclusiva de cada entrada que representa a entrada atual e o slot dela. Essa chave permanece a mesma, mesmo que o URL ou o estado da entrada atual mude. Ele ainda está no mesmo slot. Por outro lado, se um usuário pressionar "Voltar" e reabrir a mesma página, key vai mudar, já que essa nova entrada cria um novo slot.

Para um desenvolvedor, key é útil porque a API Navigation permite que você navegue diretamente o usuário para uma entrada com uma chave correspondente. Você pode manter o foco nela, mesmo nos estados de outras entradas, para alternar facilmente entre as páginas.

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

Estado

A API Navigation mostra uma noção de "estado", que são informações fornecidas pelo desenvolvedor que são armazenadas de forma persistente na entrada de histórico atual, mas que não são diretamente visíveis para o usuário. Essa API é muito semelhante à history.state da API History, mas com melhorias.

Na API Navigation, é possível chamar o método .getState() da entrada atual (ou de qualquer entrada) para retornar uma cópia do estado dela:

console.log(navigation.currentEntry.getState());

Por padrão, ele é undefined.

Estado de configuração

Embora os objetos de estado possam ser modificados, essas mudanças não são salvas com a entrada do histórico. Portanto:

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

A maneira correta de definir o estado é durante a navegação do script:

navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});

Onde newState pode ser qualquer objeto clonável.

Se você quiser atualizar o estado da entrada atual, é melhor realizar uma navegação que substitua a entrada atual:

navigation.navigate(location.href, {state: newState, history: 'replace'});

Em seguida, o listener de eventos "navigate" pode detectar essa mudança usando navigateEvent.destination:

navigation.addEventListener('navigate', navigateEvent => {
  console.log(navigateEvent.destination.getState());
});

Como atualizar o estado de forma síncrona

Em geral, é melhor atualizar o estado de forma assíncrona usando navigation.reload({state: newState}). Assim, o listener "navigate" pode aplicar esse estado. No entanto, às vezes, a mudança de estado já foi totalmente aplicada quando o código recebe a informação, como quando o usuário alterna um elemento <details> ou muda o estado de uma entrada de formulário. Nesses casos, é recomendável atualizar o estado para que essas mudanças sejam preservadas durante recarregar e percorrer. Isso é possível usando updateCurrentEntry():

navigation.updateCurrentEntry({state: newState});

Também há um evento para saber mais sobre essa mudança:

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

No entanto, se você reagir a mudanças de estado em "currententrychange", poderá estar dividindo ou até mesmo duplicando o código de processamento de estado entre o evento "navigate" e o evento "currententrychange", enquanto navigation.reload({state: newState}) permitiria que você o processasse em um só lugar.

Estado x parâmetros de URL

Como o estado pode ser um objeto estruturado, é tentador usá-lo para todo o estado do aplicativo. No entanto, em muitos casos, é melhor armazenar esse estado no URL.

Se você espera que o estado seja mantido quando o usuário compartilha o URL com outro usuário, armazene-o no URL. Caso contrário, o objeto de estado é a melhor opção.

Acessar todas as entradas

Mas a "entrada atual" não é tudo. A API também oferece uma maneira de acessar a lista completa de entradas que um usuário navegou enquanto usava seu site pela chamada navigation.entries(), que retorna uma matriz de snapshots de entradas. Isso pode ser usado para, por exemplo, mostrar uma interface diferente com base na forma como o usuário navegou até uma determinada página ou apenas para consultar os URLs anteriores ou os estados deles. Isso é impossível com a API History atual.

Também é possível detectar um evento "dispose" em NavigationHistoryEntrys individuais, que é acionado quando a entrada não faz mais parte do histórico do navegador. Isso pode acontecer como parte da limpeza geral, mas também pode acontecer durante a navegação. Por exemplo, se você voltar 10 lugares e depois navegar para frente, essas 10 entradas do histórico serão descartadas.

Exemplos

O evento "navigate" é acionado para todos os tipos de navegação, conforme mencionado acima. Na verdade, há um apêndice longo na especificação de todos os tipos possíveis.

Embora para muitos sites o caso mais comum seja quando o usuário clica em um <a href="...">, há dois tipos de navegação mais complexos que vale a pena abordar.

Navegação programática

A primeira é a navegação programática, em que a navegação é causada por uma chamada de método no código do lado do cliente.

É possível chamar navigation.navigate('/another_page') em qualquer lugar do código para causar uma navegação. Isso será processado pelo listener de evento centralizado registrado no listener "navigate", e o listener centralizado será chamado de forma síncrona.

O objetivo é melhorar a agregação de métodos mais antigos, como location.assign() e amigos, além dos métodos pushState() e replaceState() da API History.

O método navigation.navigate() retorna um objeto que contém duas instâncias de Promise em { committed, finished }. Isso permite que o invocador aguarde até que a transição seja "confirmada" (o URL visível mudou e um novo NavigationHistoryEntry está disponível) ou "concluída" (todas as promessas retornadas por intercept({ handler }) estão completas ou rejeitadas devido a uma falha ou por serem precedidas por outra navegação).

O método navigate também tem um objeto de opções, em que você pode definir:

  • state: o estado da nova entrada de histórico, disponível pelo método .getState() no NavigationHistoryEntry.
  • history: que pode ser definido como "replace" para substituir a entrada de histórico atual.
  • info: um objeto a ser transmitido ao evento de navegação por navigateEvent.info.

Em particular, info pode ser útil para, por exemplo, indicar uma animação específica que faz com que a próxima página apareça. A alternativa pode ser definir uma variável global ou incluí-la como parte do #hash. Ambas as opções são um pouco estranhas.) Essa info não será reproduzida se um usuário causar a navegação mais tarde, por exemplo, usando os botões "Voltar" e "Avançar". Na verdade, ele sempre será undefined nesses casos.

Demonstração de abertura da esquerda ou da direita

navigation também tem vários outros métodos de navegação, todos retornando um objeto que contém { committed, finished }. Já mencionei traverseTo(), que aceita um key que denota uma entrada específica no histórico do usuário, e navigate(). Ele também inclui back(), forward() e reload(). Esses métodos são processados pelo listener de eventos "navigate" centralizado, assim como o navigate().

Envios de formulário

Em segundo lugar, o envio de <form> HTML por POST é um tipo especial de navegação, e a API Navigation pode interceptá-lo. Embora inclua um payload adicional, a navegação ainda é processada centralmente pelo listener "navigate".

O envio de formulários pode ser detectado procurando a propriedade formData no NavigateEvent. Confira um exemplo que simplesmente transforma qualquer envio de formulário em um que fica na página atual usando 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"
      },
    });
  }
});

O que está faltando?

Apesar da natureza centralizada do listener de eventos "navigate", a especificação atual da API Navigation não aciona "navigate" no primeiro carregamento de uma página. E para sites que usam a renderização do lado do servidor (SSR) para todos os estados, isso pode ser aceitável. O servidor pode retornar o estado inicial correto, que é a maneira mais rápida de enviar conteúdo aos usuários. No entanto, os sites que usam código do lado do cliente para criar páginas podem precisar criar uma função extra para inicializar a página.

Outra escolha de design intencional da API Navigation é que ela opera apenas em um único frame, ou seja, a página de nível superior ou um único <iframe> específico. Isso tem várias implicações interessantes que estão documentadas na especificação, mas, na prática, vai reduzir a confusão dos desenvolvedores. A API History anterior tem vários casos extremos confusos, como suporte a frames, e a API Navigation reinventada lida com esses casos extremos desde o início.

Por fim, ainda não há consenso sobre modificar ou reorganizar programaticamente a lista de entradas que o usuário navegou. Isso está em discussão, mas uma opção seria permitir apenas exclusões: entradas históricas ou "todas as entradas futuras". O segundo permitiria o estado temporário. Por exemplo, como desenvolvedor, eu poderia:

  • fazer uma pergunta ao usuário navegando para um novo URL ou estado;
  • permitir que o usuário conclua o trabalho ou volte atrás
  • remover uma entrada do histórico após a conclusão de uma tarefa

Isso pode ser perfeito para modais temporários ou intersticiais: o novo URL é algo que um usuário pode usar o gesto "Voltar" para sair, mas não pode avançar acidentalmente para abrir novamente (porque a entrada foi removida). Isso não é possível com a API History atual.

Testar a API Navigation

A API Navigation está disponível no Chrome 102 sem flags. Você também pode testar uma demonstração de Domenic Denicola.

Embora a API History clássica pareça simples, ela não é muito bem definida e tem um grande número de problemas relacionados a casos de uso e à forma como ela foi implementada de maneira diferente nos navegadores. Esperamos que você envie feedback sobre a nova API Navigation.

Referências

Agradecimentos

Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin por revisarem esta postagem.