Roteamento moderno no lado do cliente: API Navigation

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

Compatibilidade com navegadores

  • 102
  • 102
  • x
  • x

Origem

Os aplicativos de página única, ou SPAs, são definidos por um recurso principal: reescreve 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 pela API History (ou, em alguns casos, ajustando a parte #hash do site), essa é uma API complexa desenvolvida muito antes dos SPAs serem padrão. A Web está pedindo uma abordagem completamente nova. A API Navigation é uma API proposta que reformula completamente esse espaço, em vez de tentar simplesmente corrigir as arestas da API History. Por exemplo, a Restauração de rolagem corrigiu a API History em vez de tentar reinventá-la.

Nesta postagem, descrevemos a API Navigation em detalhes. Para ler a proposta técnica, confira o rascunho de relatório no repositório WICG.

Exemplo de uso

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

Um NavigateEvent é transmitido ao listener "navigate", que contém informações sobre a navegação, como o URL de destino, e permite responder à navegação em um local centralizado. Um listener "navigate" básico pode ter esta aparência:

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 seu callback handler, que precisa configurar o próximo estado do site. Isso 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 têm casos em que não podem ser chamados. Não é possível processar navegações via intercept() se forem de origem cruzada. E não é possível cancelar uma navegação via preventDefault() se o usuário estiver pressionando os botões "Voltar" ou "Avançar" no navegador. Além disso, não é possível enganar os usuários no seu site. Esse assunto está sendo discutido no GitHub (link em inglês).

Mesmo que não seja possível interromper nem interceptar a navegação, o evento "navigate" ainda será disparado. Ela é informativa, 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 gerenciamento de mudanças de URL dentro de um SPA. Essa é uma proposta difícil que usa APIs mais antigas. Se você já criou o roteamento para seu SPA usando a History API, talvez tenha 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 está completo. Os links podem aparecer na sua página, e 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 isso, mas há uma longa cauda de possibilidades que poderia ser simplesmente simplificada, algo que a nova API Navigation consegue.

Além disso, a navegação acima não é compatível com a navegação de avanço e retorno. Existe outro evento para isso, "popstate".

Pessoalmente, a API History muitas vezes sente que poderia ajudar de alguma forma com essas possibilidades. No entanto, há 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. Não há uma analogia com "navigate", exceto se você configurar manualmente listeners para eventos de clique, por exemplo, como demonstrado acima.

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 propriedades principais são:

canIntercept
Se for falso, não será possível interceptar a navegação. As navegações de origem cruzada e as travessias de documentos cruzados não podem ser interceptadas.
destination.url
Provavelmente, essa é a informação mais importante a ser considerada ao processar a navegação.
hashChange
Verdadeiro se a navegação for o mesmo documento e o hash for a única parte do URL diferente do URL atual. Nos SPAs modernos, o hash precisa ser vinculado a diferentes partes do documento. Portanto, se hashChange for verdadeiro, você provavelmente não vai precisar interceptar essa navegação.
downloadRequest
Se for verdadeiro, a navegação foi iniciada por um link com um atributo download. Na maioria dos casos, você não precisa interceptar isso.
formData
Se o valor não for nulo, a navegação fará 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 é nulo. Confira o exemplo sobre como processar envios de formulário mais adiante neste artigo.
navigationType
Essa é uma destas opções: "reload", "push", "replace" ou "traverse". Se for "traverse", essa navegação não poderá ser cancelada por 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 agora 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 possa ser restaurado posteriormente e, em seguida, chama o callback handler. Se a handler retornar uma promessa (que acontece automaticamente com async functions), essa promessa informa 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);
      },
    });
  }
});

Como tal, esta API introduz um conceito semântico que o navegador entende: uma navegação por SPA está ocorrendo, ao longo do tempo, mudando o documento de um URL e um estado anteriores para um novo. Isso tem vários benefícios em potencial, incluindo a 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 "Parar". Isso não acontece atualmente quando o usuário navega pelos botões voltar/avançar, mas isso será corrigido em breve.

Ao interceptar navegações, o novo URL entra em vigor pouco antes de o callback handler ser chamado. Se você não atualizar o DOM imediatamente, será criado um período em que o conteúdo antigo será exibido com o novo URL. Isso afeta coisas como a resolução relativa de URL ao buscar dados ou carregar novos sub-recursos.

Uma maneira de atrasar a mudança de URL está sendo discutida no GitHub (link em inglês), mas geralmente é recomendável atualizar imediatamente a página com algum tipo de marcador 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 apenas problemas de resolução de URL, como também aumenta a rapidez porque você responde instantaneamente ao usuário.

Cancelar indicadores

Como você pode fazer um trabalho assíncrono em um gerenciador intercept(), a navegação pode ficar redundante. Isso acontece quando:

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

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

A versão resumida é que, basicamente, ele fornece um objeto que dispara um evento quando você deve parar seu trabalho. É possível transmitir um AbortSignal para qualquer chamada feita para fetch(), o que cancela as solicitações de rede em andamento se a navegação for interrompida. Isso salvará a largura de banda do usuário e rejeitará a Promise retornada por fetch(), impedindo ações a seguir do código, como atualizar o DOM para mostrar uma navegação nas páginas 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);
      },
    });
  }
});

Gerenciamento de rolagem

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

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

Em atualizações e travessias, isso significa restaurar a posição de rolagem para o local em que estava a última vez em que a entrada do histórico foi exibida.

Por padrão, isso acontece quando a promessa retornada pelo handler é resolvida. No entanto, se fizer sentido rolar a tela antes, você poderá chamar 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, você pode 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

Depois que a promessa retornada pelo 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.

Você pode desativar esse comportamento definindo 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 de duas coisas acontece:

  • Se o Promise retornado for atendido (ou você não chamou intercept()), a API Navigation vai disparar "navigatesuccess" com um Event.
  • Se a Promise retornada for rejeitada, a API vai disparar "navigateerror" com uma ErrorEvent.

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

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

Ou uma mensagem de erro pode ser exibida 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, é muito útil, porque garante o recebimento de erros do seu código que está configurando uma nova página. Você pode simplesmente await fetch() sabendo que, se a rede não estiver disponível, o erro será roteado para "navigateerror".

navigation.currentEntry dá acesso à entrada atual. Esse é 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 vai permanecer igual mesmo se o URL ou o estado da entrada atual mudar. Ainda está no mesmo slot. Por outro lado, se um usuário pressionar "Voltar" e abrir a mesma página novamente, o key mudará, quando essa nova entrada criar um novo slot.

Para um desenvolvedor, o método key é útil porque a API Navigation permite direcionar o usuário diretamente para uma entrada com uma chave de correspondência. Você pode segurá-lo, 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 maneira permanente na entrada atual do histórico, mas não ficam diretamente visíveis para o usuário. Isso é muito semelhante, mas foi melhorado do history.state na API History.

Na API Navigation, você pode 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 será undefined.

Estado da configuração

Embora os objetos de estado possam ser modificados, essas alterações não são salvas de volta 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 no script:

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

Em que newState pode ser qualquer objeto clonável.

Para atualizar o estado da entrada atual, é melhor executar uma navegação que substitua a entrada atual:

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

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

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

Como atualizar o estado de forma síncrona

Geralmente, é melhor atualizar o estado de forma assíncrona usando navigation.reload({state: newState}). Assim, o listener "navigate" poderá aplicar esse estado. No entanto, às vezes a mudança de estado já foi totalmente aplicada no momento em que o código detecta isso, como quando o usuário alterna um elemento <details> ou o usuário muda o estado de uma entrada de formulário. Nesses casos, pode ser necessário atualizar o estado para que essas mudanças sejam preservadas nas atualizações e travessias. Isso é possível usando updateCurrentEntry():

navigation.updateCurrentEntry({state: newState});

Há também um evento para saber sobre essa mudança:

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

No entanto, se você perceber que está reagindo a mudanças de estado em "currententrychange", pode estar dividindo ou até duplicando o código de entrega de estado entre o evento "navigate" e o evento "currententrychange", enquanto o navigation.reload({state: newState}) permitiria que você gerenciasse isso em um só lugar.

Parâmetros de estado x 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ê esperar que o estado seja mantido quando o usuário compartilhar 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

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

Você também pode 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 navegar adiante, essas 10 entradas do histórico serão descartadas.

Exemplos

O evento "navigate" é disparado 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 importantes e mais complexos que precisam ser abordados.

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.

Você pode chamar navigation.navigate('/another_page') de qualquer lugar no código para causar uma navegação. Isso será processado pelo listener de eventos centralizado registrado no listener "navigate", e o listener centralizado será chamado de forma síncrona.

Isso é feito para melhorar a agregação de métodos 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 tenha mudado e um novo NavigationHistoryEntry esteja disponível) ou "concluída" (todas as promessas retornadas por intercept({ handler }) sejam concluídas ou rejeitadas, devido a uma falha ou tenham sido interrompidas por outra navegação).

O método navigate também tem um objeto de opções, em que é possível definir:

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

info pode ser útil, por exemplo, para 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.) Este info não será reproduzido novamente se um usuário mais tarde iniciar a navegação, por exemplo, com os botões "Voltar" e "Avançar". Na verdade, ele sempre será undefined nesses casos.

Demonstração de abertura da esquerda ou direita

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

Envios de formulário

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

O envio do formulário pode ser detectado procurando a propriedade formData no NavigateEvent. Veja um exemplo que simplesmente transforma qualquer envio de formulário em um que permanece na página atual com 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. No caso de sites que usam renderização do lado do servidor (SSR, na sigla em inglês) para todos os estados, isso pode ser aceitável. Seu 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 o código do lado do cliente para criar as páginas talvez precisem criar uma função adicional para inicializar a página.

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

Por fim, ainda não há consenso sobre a modificação ou a reorganização programática da lista de entradas pelas quais o usuário navegou. Isso está em discussão no momento, mas uma opção pode ser permitir apenas exclusões: entradas históricas ou "todas as entradas futuras". O segundo permite o estado temporário. Por exemplo, como desenvolvedor, eu poderia:

  • faça uma pergunta ao usuário navegando para novo URL ou estado
  • permitir que o usuário conclua o trabalho (ou volte)
  • remover uma entrada do histórico ao concluir uma tarefa

Isso pode ser perfeito para modais ou intersticiais temporários: é possível sair do novo URL usando o gesto "Voltar", mas que não consegue avançar acidentalmente para abri-lo de novo (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 sinalizações. Você também pode acessar 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 em casos específicos e como foi implementada de maneira diferente em vários navegadores. Esperamos que você envie feedback sobre a nova API Navigation.

Referências

Agradecimentos

Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin pela análise desta postagem. Imagem principal do Unsplash, de Jeremy Zero.