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.
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 porpreventDefault()
.
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.
Confirmação de navegação
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 chamouintercept()
), a API Navigation vai disparar"navigatesuccess"
com umEvent
. - Se a
Promise
retornada for rejeitada, a API vai disparar"navigateerror"
com umaErrorEvent
.
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"
.
Entradas de navegação
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 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 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()
noNavigationHistoryEntry
.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 vianavigateEvent.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.
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
- WICG/navigation-api (em inglês)
- Posição dos padrões do Mozilla
- Intenção de protótipos
- Revisão da TAG
- Entrada Chromestatus
Agradecimentos
Agradecemos a Thomas Steiner, Domenic Denicola e Nate Chapin pela análise desta postagem. Imagem principal do Unsplash, de Jeremy Zero.