Padronização do roteamento do lado do cliente com uma API totalmente nova que reformula completamente a criação de aplicativos de página única.
Os aplicativos de página única (SPAs) são definidos por um recurso principal: a reescrita dinâmica do 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 com a API History (ou, em casos limitados, ajustando a parte #hash do site), ela é uma API desajeitada desenvolvida muito antes de os SPAs serem a norma, e a Web pede uma abordagem completamente nova. A API Navigation é uma API proposta que reformula completamente esse espaço, em vez de tentar apenas 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 em um nível alto. Para ler a proposta técnica, consulte o relatório de rascunho no repositório do WICG (link 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 é 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 de forma programática (ou seja, pelo código do seu site).
Na maioria dos casos, ele permite que seu 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 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 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:
- Chame
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 vai criar um objeto de transição, navigation.transition, que outros códigos podem usar para acompanhar o progresso da navegação.
Normalmente, intercept() e preventDefault() são permitidos, mas há casos em que não podem ser chamados.
Não é possível processar navegações via intercept() se a navegação for entre origens.
Além disso, 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. Não é permitido prender os usuários no seu 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á acionado.
Ele é informativo. Por exemplo, seu código pode 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 usando APIs mais antigas.
Se você já escreveu o roteamento do seu próprio SPA usando a API History, 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 é exaustivo. Os links podem aparecer e desaparecer 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 usar um mapa de imagem. Sua página pode lidar com isso, mas há uma longa lista de possibilidades que podem ser simplificadas, algo que a nova API Navigation realiza.
Além disso, o código acima não lida com a navegação para frente/para trás. Há outro evento para isso, "popstate".
Pessoalmente, a API History muitas vezes parece que pode ajudar com essas possibilidades.
No entanto, ele só tem 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", a menos que você configure manualmente listeners para eventos de clique, por exemplo, como demonstrado acima.
Como decidir o que fazer com uma navegação
O navigateEvent contém muitas informações sobre a navegação que você pode usar 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. Navegações entre origens e travessias entre documentos não podem ser interceptadas.
destination.url- Provavelmente a informação mais importante a ser considerada 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 diferente do URL atual.
Em SPAs modernos, o hash deve ser usado para vincular diferentes partes do documento atual. Portanto, se
hashChangefor verdadeiro, provavelmente não será necessário interceptar essa navegação. downloadRequest- Se 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 fará parte de um envio de formulário POST.
Não se esqueça disso ao lidar com a navegação.
Se você quiser processar apenas navegações GET, evite interceptar navegações em que
formDatanão é nulo. Confira o exemplo sobre como processar envios de formulários mais adiante no artigo. navigationType- É um dos campos
"reload","push","replace"ou"traverse". Se for"traverse", essa navegação não poderá ser cancelada porpreventDefault().
Por exemplo, a função shouldNotIntercept usada no primeiro exemplo pode ser algo 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
);
}
Interceptando
Quando o código chama intercept({ handler }) no 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. Em seguida, ele chama seu callback handler.
Se o handler retornar uma promessa (o que acontece automaticamente com funções assíncronas), essa promessa vai 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 apresenta um conceito semântico que o navegador entende: uma navegação de SPA está ocorrendo, ao longo do tempo, mudando o documento de um URL e estado anteriores para um novo. Isso tem vários benefícios em potencial, incluindo 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 da chamada de callback handler.
Se você não atualizar o DOM imediatamente, isso vai criar um período em que 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 de URL está sendo discutida no GitHub, mas geralmente é recomendável atualizar imediatamente a página 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ê está respondendo instantaneamente ao usuário.
Sinais de cancelamento
Como é possível fazer trabalho assíncrono em um manipulador intercept(), a navegação pode se tornar 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 "Parar" 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 cancelável.
A versão curta é que ele basicamente fornece um objeto que dispara um evento quando você precisa parar de trabalhar.
É 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 economiza a largura de banda do usuário e rejeita o Promise retornado por fetch(), impedindo que o código a seguir execute 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 a uma nova entrada de histórico (quando navigationEvent.navigationType é "push" ou "replace"), isso significa tentar rolar até a parte indicada pelo fragmento de URL (a parte depois do #) ou redefinir a rolagem para a parte de cima da página.
Para recarregamentos e travessias, isso significa restaurar a posição de rolagem para onde ela estava na última vez que essa entrada do histórico foi mostrada.
Por padrão, isso acontece quando a promessa retornada pelo handler é resolvida, mas se fizer sentido rolar antes, você pode 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, é possível desativar completamente 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 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.
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 manipulador intercept() é chamado, uma destas duas situações acontece:
- Se o
Promiseretornado atender aos requisitos (ou se você não chamouintercept()), a API Navigation vai acionar"navigatesuccess"com umEvent. - Se o
Promiseretornado for rejeitado, a API vai acionar"navigateerror"com umErrorEvent.
Esses eventos permitem que seu 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, assim:
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 seu código que está configurando uma nova página.
Basta await fetch() sabendo que, se a rede estiver indisponível, o erro será encaminhado para "navigateerror".
Entradas de navegação
navigation.currentEntry fornece acesso à entrada atual.
É um objeto que descreve onde o usuário está agora.
Essa entrada inclui o URL atual, 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.
Ela ainda está no mesmo slot.
Por outro lado, se um usuário pressionar "Voltar" e reabrir a mesma página, o key vai mudar porque essa nova entrada cria um novo slot.
Para um desenvolvedor, key é útil porque a API Navigation permite navegar diretamente o usuário até uma entrada com uma chave correspondente.
Você pode manter o estado, mesmo em 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 apresenta uma noção de "estado", que são informações fornecidas pelo desenvolvedor e armazenadas de forma permanente na entrada de histórico atual, mas que não são diretamente visíveis para o usuário.
Isso é extremamente semelhante, mas melhorado em relação a history.state na API History.
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 será undefined.
Definir estado
Embora os objetos de estado possam ser mutáveis, 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});
Em que newState pode ser qualquer objeto clonável.
Se você quiser atualizar o estado da entrada atual, é melhor fazer uma navegação que a substitua:
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());
});
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 seu código fica sabendo dela, como quando o usuário ativa ou desativa um elemento <details> ou muda o estado de uma entrada de formulário. Nesses casos, talvez seja necessário atualizar o estado para que essas mudanças sejam preservadas em recarregamentos e travessias. 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ê estiver reagindo a mudanças de estado em "currententrychange", talvez esteja dividindo ou até duplicando seu código de manipulação de estado entre o evento "navigate" e o evento "currententrychange", enquanto o navigation.reload({state: newState}) permite que você lide 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 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 outra pessoa, 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 a lista completa de entradas que um usuário navegou ao usar seu site pela chamada navigation.entries(), que retorna uma matriz de instantâneo de entradas.
Isso pode ser usado, por exemplo, para 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 seus estados.
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 de uma limpeza geral, mas também durante a navegação. Por exemplo, se você voltar 10 lugares e depois avançar, 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 longo apêndice 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') de qualquer lugar no código para causar uma navegação.
Isso será processado pelo listener de eventos centralizado registrado no listener "navigate", e seu listener centralizado será chamado de forma síncrona.
Isso é uma agregação aprimorada de métodos mais antigos, como location.assign() e outros, 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 }) foram concluídas ou rejeitadas devido a falha ou substituição 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 de histórico, conforme 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 ao evento de navegação vianavigateEvent.info.
Em particular, info pode ser útil para, por exemplo, indicar uma animação específica que faz a próxima página aparecer.
A alternativa pode ser definir uma variável global ou incluí-la como parte do #hash. As duas opções são um pouco estranhas.)
Vale lembrar que esse info não será reproduzido novamente se um usuário causar uma navegação mais tarde, por exemplo, usando os botões "Voltar" e "Avançar".
Na verdade, ele sempre será undefined nesses casos.
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().
Todos esses métodos são processados, assim 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 adicional, a navegação ainda é processada centralmente pelo listener "navigate".
Para detectar o envio de formulários, procure a propriedade formData no NavigateEvent.
Confira um exemplo que transforma qualquer envio de formulário em um que permanece na página atual via 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 a renderização do lado do servidor (SSR, na sigla em inglês) em todos os estados, isso pode ser adequado. O servidor pode retornar o estado inicial correto, que é a maneira mais rápida de disponibilizar conteúdo aos usuários.
No entanto, sites que usam código do lado do cliente para criar páginas talvez precisem criar uma função adicional 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 são mais bem documentadas na especificação, mas, na prática, reduzem a confusão dos desenvolvedores.
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 reorganização programática da lista de entradas pelas quais 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 um estado temporário. Por exemplo, como desenvolvedor, eu poderia:
- fazer uma pergunta ao usuário navegando até um 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 é perfeito para modais ou intersticiais temporários: o novo URL é algo que um usuário pode usar o gesto de retorno para sair, mas não pode avançar acidentalmente para abri-lo 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 em relação a casos extremos e como ela foi implementada de maneira diferente em vários navegadores. Esperamos que você envie seu feedback sobre a nova API Navigation.
Referências
- WICG/navigation-api
- Posição da Mozilla sobre padrões
- Objetivo de prototipagem
- Análise da TAG
- Entrada do ChromeStatus
Agradecimentos
Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin por revisarem esta postagem.