Roteamento moderno no lado do cliente: API Navigation

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

Compatibilidade com navegadores

  • 102
  • 102
  • x
  • x

Original

Aplicativos de página única, ou SPAs, são definidos por um recurso principal: reescrever dinamicamente seu 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 por meio da API History (ou, em alguns casos, ajustando a parte do #hash do site), ela é uma API lenta desenvolvida muito antes dos SPAs serem a norma, e a Web está em busca de uma abordagem completamente nova. A API de navegação é uma API proposta que reformula completamente esse espaço, em vez de tentar simplesmente corrigir as partes irregulares 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 em alto nível. Se você quiser ler a proposta técnica, confira o rascunho do relatório 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 é fundamentalmente centralizado: ele é disparado para 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 ir e voltar) ou quando a navegação é acionada de maneira programática (ou seja, 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 SPAs, isso provavelmente significa 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(), o que pode cancelar a navegação completamente.

Este exemplo chama intercept() no evento. O navegador chama o callback handler, que deve configurar 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 podem ser chamados. Não é possível processar navegações usando intercept() se elas forem de origem cruzada. Além disso, 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 prenda os usuários no site. Isso está sendo discutido no GitHub.

Mesmo que não seja possível interromper ou interceptar a navegação, o evento "navigate" ainda será disparado. Ele é informativo e, portanto, seu código pode, por exemplo, registrar um evento do Google Analytics para indicar que um usuário está saindo do site.

Por que adicionar outro evento à plataforma?

Um listener de eventos "navigate" centraliza o processamento de alterações de URL dentro de um SPA. Essa é uma proposta difícil quando APIs mais antigas são usadas. Se você já criou o roteamento para seu próprio 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 é aceitável, mas não exaustivo. Os links podem entrar e sair da 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 imagens. Sua página pode lidar com isso, mas há uma longa cauda de possibilidades que poderiam ser simplificadas, algo que a nova API de navegação alcança.

Além disso, o comando acima não processa a navegação de avanço e retorno. Há outro evento para isso, "popstate".

Pessoalmente, a API History muitas vezes sente que pode 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. 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 principais propriedades são:

canIntercept
Se for "false", não será possível interceptar a navegação. As navegações de origem cruzada e as travessias entre documentos não podem ser interceptadas.
destination.url
Essa é provavelmente a informação mais importante a ser considerada ao lidar com a navegação.
hashChange
Verdadeiro se a navegação for do mesmo documento e o hash for a única parte do URL diferente do URL atual. Nos SPAs modernos, o hash deve ser usado para vincular diferentes partes do documento atual. Portanto, se hashChange for verdadeiro, você provavelmente não vai precisar interceptar essa navegação.
downloadRequest
Se esse for o caso, a navegação foi iniciada por um link com um atributo download. Na maioria dos casos, não é necessário interceptar isso.
formData
Se o valor não for nulo, essa 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 aquelas em que formData não é nulo. Confira o exemplo sobre como lidar com envios de formulários posteriormente 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 poderia ser assim:

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 seu código chama intercept({ handler }) de dentro do listener "navigate", ele informa ao navegador que está preparando a página para o novo estado atualizado e que a navegação pode levar algum tempo.

O navegador começa capturando a posição de rolagem do estado atual. Assim, ele pode ser restaurado posteriormente e, em seguida, chama o callback handler. Se a handler retornar uma promessa (o que acontece automaticamente com async functions), essa promessa vai informar ao navegador quanto tempo leva a navegação 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);
      },
    });
  }
});

Dessa forma, essa API introduz um conceito semântico que o navegador entende: uma navegação de SPA está ocorrendo, com o tempo, mudando o documento de um URL e 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 uma 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". No momento, isso não acontece quando o usuário navega usando os botões "Voltar/Avançar", mas isso será corrigido em breve.

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

Uma maneira de atrasar a mudança do URL está sendo discutido no GitHub, 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 só evita problemas de resolução de URL, como também gera um resultado rápido porque você responde ao usuário instantaneamente.

Cancelar indicadores

Como é possível fazer um 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 em algum código realiza outra navegação. Nesse caso, a navegação antiga é abandonada e substituída pela nova.
  • O usuário clica no botão "stop" do 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 anulável.

Em resumo, ela 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 cancelará solicitações de rede em andamento se a navegação for interrompida. Isso vai salvar a largura de banda do usuário e rejeitar o Promise retornado por fetch(), impedindo qualquer código a seguir de ações como atualizar o DOM para mostrar uma navegação de página inválida.

Este é o exemplo anterior, mas com getArticleContent in-line, mostrando como 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);
      },
    });
  }
});

Manipulação de rolagem

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

Em navegações para uma nova entrada do 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 a rolagem para a parte de cima da página.

Para atualizações e travessias, isso significa restaurar a posição de rolagem para onde estava na última vez que essa 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, 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 completamente o processamento de rolagem automática definindo a opção scroll de intercept() como "manual":

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

Tratamento de foco

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

É possível 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 destas duas situações acontece:

  • Se o Promise retornado for atendido (ou se você não chamou intercept()), a API Navigation vai disparar "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 forma 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 talvez você veja 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 o recebimento de erros do 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 fornece acesso à entrada atual. Este é um objeto que descreve onde o usuário está naquele momento. Essa entrada inclui o URL atual, os metadados que podem ser usados para identificar a 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 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 reabrir a mesma página, o key será alterado quando essa nova entrada criar um novo slot.

Para um desenvolvedor, key é útil porque a API Navigation permite levar o usuário diretamente a uma entrada com uma chave correspondente. Você pode segurá-lo, mesmo nos estados de outras entradas, para alternar entre as páginas com facilidade.

// 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 traz uma noção de "estado", que são informações fornecidas pelo desenvolvedor armazenadas de forma persistente na entrada do histórico atual, mas que não ficam diretamente visíveis para o usuário. Isso é muito semelhante ao history.state na API History, mas foi aprimorado.

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

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

Por padrão, será undefined.

Estado da configuração

Embora os objetos de estado possam ser modificados, essas alterações 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});

Em que 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 via navigateEvent.destination:

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

Como atualizar o estado de maneira síncrona

Geralmente, é melhor atualizar o estado de forma assíncrona usando navigation.reload({state: newState}). Em seguida, o listener "navigate" poderá aplicar esse estado. No entanto, às vezes, a mudança de estado já foi totalmente aplicada quando o código é informado, por exemplo, quando o usuário alterna um elemento <details> ou muda o estado de uma entrada de formulário. Nesses casos, talvez você queira atualizar o estado para que essas mudanças sejam preservadas com atualizações e travessias. Isso é possível usando updateCurrentEntry():

navigation.updateCurrentEntry({state: newState});

Há também um evento para saber mais 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 no "currententrychange", pode estar dividindo ou até mesmo duplicando o código de gerenciamento de estado entre o evento "navigate" e o evento "currententrychange", enquanto navigation.reload({state: newState}) permitiria lidar com isso 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 seu aplicativo. No entanto, em muitos casos é melhor armazenar esse estado no URL.

Se você espera que o estado seja retido 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 em que um usuário navegou enquanto usava seu site com a chamada navigation.entries(), que retorna uma matriz de resumo de entradas. Isso pode ser usado para, por exemplo, mostrar uma interface diferente com base em como o usuário navegou até uma determinada página ou apenas para analisar 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 é disparado quando a entrada deixa de fazer parte do histórico do navegador. Isso pode acontecer como parte da limpeza geral, mas também acontece durante a navegação. Por exemplo, se você percorrer 10 lugares e avançar, 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 em muitos sites o caso mais comum seja quando o usuário clica em um <a href="...">, existem dois tipos de navegação importantes e mais complexos que vale a pena abordar.

Navegação programática

Primeiro, a navegação programática, em que ela é causada por uma chamada de método dentro do código do cliente.

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

O objetivo disso é uma agregação aprimorada 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 possa aguardar 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 pelo intercept({ handler }) tenham sido concluídas ou rejeitadas devido a uma falha ou interrompida 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 de histórico atual.
  • info: um objeto a ser transmitido para o evento de navegação pelo 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. Uma alternativa é definir uma variável global ou incluí-la como parte do #hash. As duas opções são um pouco estranhas.) Esse info não será reproduzido se um usuário iniciar a navegação posteriormente, por exemplo, por meio dos botões "Voltar" e "Avançar". Na verdade, sempre será undefined nesses casos.

Demonstração de abertura da esquerda ou da direita

O 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(). Também inclui back(), forward() e reload(). Todos esses métodos são processados, assim como navigate(), pelo listener de eventos centralizado "navigate".

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 ela inclua um payload extra, a navegação ainda é processada centralmente pelo listener "navigate".

Para detectar o envio de formulário, procure a propriedade formData no NavigateEvent. Confira um exemplo que simplesmente transforma qualquer envio de formulário em um que permaneça 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. Para sites que usam renderização no servidor (SSR, na sigla em inglês) para todos os estados, isso não é um problema. Seu servidor pode retornar o estado inicial correto, que é a maneira mais rápida de levar conteúdo aos usuários. Mas os sites que aproveitam o código do lado do cliente para criar páginas podem precisar criar uma função adicional para inicializá-las.

Outra escolha de design intencional da API Navigation é que ela opera somente 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 com 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 de navegação reimaginada lida com esses casos extremos desde o início.

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

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

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

Testar a API Navigation

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

Embora a API History clássica pareça simples, ela não é muito bem definida e tem vários problemas relacionados a casos extremos, além de ter sido implementada de maneira diferente nos navegadores. Esperamos que você considere o feedback sobre a nova API Navigation.

Referências

Agradecimentos

Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin por revisar esta postagem. Imagem principal do Unsplash, de Jeremy Zero.