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.
Os 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 Navigation API é uma API proposta que reformula completamente esse espaço, em vez de tentar simplesmente corrigir as bordas 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 de maneira geral. Para ler a proposta técnica, consulte o Rascunho do relatório no repositório WICG (link 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 alterar 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 ficar 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.
Além disso, não é possível cancelar uma navegação pelo 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 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 alterações de URL dentro de um SPA.
Isso é difícil de fazer com APIs mais antigas.
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 é 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.
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 de origem cruzada e as travessias entre documentos não podem ser interceptadas.
destination.url
- Provavelmente a informação mais importante a considerar ao processar 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, a 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
- Essa é uma destas opções:
"reload"
,"push"
,"replace"
ou"traverse"
. Se for"traverse"
, essa navegação não poderá ser cancelada usandopreventDefault()
.
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 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 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 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 de parada. Isso não acontece quando o usuário navega usando os botões "Voltar" e "Avançar", mas será corrigido em breve.
Confirmação de navegação
Ao interceptar navegações, o novo URL entra em vigor pouco antes do 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 relativa 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.
Cancelar indicadores
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);
},
});
}
});
Manipulação de rolagem
Quando você usa intercept()
em 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 chamadointercept()
), a API Navigation vai acionar"navigatesuccess"
com umEvent
. - Se o
Promise
retornado for rejeitado, a API vai acionar"navigateerror"
com umErrorEvent
.
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 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 o recebimento de erros do código que está configurando uma nova página.
Você pode simplesmente await fetch()
sabendo que, se a rede estiver indisponível, o erro será roteado para "navigateerror"
.
Entradas de navegação
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ê leve o usuário diretamente a 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 permanente 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 qualquer outra) para retornar uma cópia do estado dela:
console.log(navigation.currentEntry.getState());
Por padrão, será undefined
.
Estado de 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});
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 é informado, 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 processá-lo 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 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 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 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 NavigationHistoryEntry
s 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"
é 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 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 no 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 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()
noNavigationHistoryEntry
.history
: que 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 pelonavigateEvent.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.
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 um key
que denota uma entrada específica no histórico do usuário, e navigate()
.
Ele 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 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, 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 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, reduz 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 a modificação ou reorganização programática da 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 último 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 na conclusão de uma tarefa
Isso pode ser perfeito para modais temporários ou intersticiais: o novo URL é algo que o 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
- WICG/navigation-api
- Posição de padrões do Mozilla
- Intent to Prototype (em inglês)
- Análise da TAG
- Entrada do Chromestatus
Agradecimentos
Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin por revisarem esta postagem.