Trocar o miolo da página com AJAX sem perder link, erro e segurança
Ao trocar o conteúdo de uma div via AJAX, mantenha o estado na URL, mostre falha em vez de conteúdo velho, descarte respostas atrasadas e não injete HTML sem escapar.
Na loja de ferramentas, o cliente filtra "furadeiras", depois "até R$ 500", e manda o link para o sócio aprovar a compra. O sócio abre e vê a página inicial do catálogo, sem filtro nenhum. A div trocou, o endereço não. Se a tela importa para alguém, ela precisa ter uma URL.
A resposta curta: ao substituir o conteúdo com AJAX, atualize a URL com history.pushState, faça o servidor entregar a mesma tela completa quando essa URL for aberta direto, mostre erro quando a requisição falhar, descarte respostas que chegaram fora de ordem e nunca injete na página HTML montado a partir de parâmetro sem escapar.
Por que o "sem refresh" quebra tanta coisa
O tutorial clássico faz um $.ajax para um PHP que devolve um pedaço de HTML e coloca esse pedaço na div com .html(). Funciona na demonstração. O navegador, porém, não sabe que a tela mudou: para ele, você continua na mesma página. Daí vêm quatro problemas.
- Endereço parado: copiar o link, recarregar ou abrir em outra aba volta para o estado inicial.
- Falha silenciosa: se a requisição dá erro, ninguém trata, e o conteúdo antigo continua na tela com cara de resultado novo.
- Corrida entre respostas: o cliente clica em "furadeiras" e logo depois em "serras". Se a resposta de furadeiras demorar mais, ela chega por último e sobrescreve a de serras. O filtro marcado diz uma coisa, a lista mostra outra.
- Injeção de HTML: se o PHP devolve algo como "Resultados para " seguido do termo buscado sem escapar, e o JavaScript insere com
innerHTML, você criou um XSS refletido na própria busca.
O botão voltar também entra nessa lista, e ele tem detalhes próprios. O post sobre fazer o botão voltar funcionar depois do AJAX trata do popstate em profundidade. Aqui o foco é a troca do conteúdo em si: o que entra na div, quando e com que garantia.
Como fazer a troca do jeito certo
- A URL é a fonte da verdade. Os filtros viram parâmetros de query:
/catalogo?categoria=furadeiras&preco_max=500. - O servidor renderiza essa URL inteira. Abrir o link direto entrega a página completa já filtrada, sem depender de script.
- A mesma rota devolve só o fragmento quando a requisição pede, por um cabeçalho que o seu JavaScript envia.
- O JavaScript cancela a requisição anterior antes de disparar a nova, com
AbortController. - Só troca o conteúdo se a resposta for 2xx. Em erro, mostra uma mensagem e mantém claro que a lista não foi atualizada.
- Depois de trocar, atualiza a URL com
pushStatee avisa leitores de tela que a lista mudou.
O servidor: uma rota, duas respostas
O ponto central é que o fragmento e a página completa saem do mesmo código. Assim, o que o AJAX mostra e o que o link abre nunca divergem.
<?php
$categoria = $_GET['categoria'] ?? '';
$precoMax = filter_input(INPUT_GET, 'preco_max', FILTER_VALIDATE_INT) ?: null;
$produtos = buscarProdutos($pdo, $categoria, $precoMax);
header('Vary: X-Fragmento');
if (($_SERVER['HTTP_X_FRAGMENTO'] ?? '') === '1') {
require __DIR__ . '/parciais/lista-produtos.php';
exit;
}
require __DIR__ . '/paginas/catalogo.php';
A página completa inclui o mesmo parciais/lista-produtos.php dentro do layout. O cabeçalho Vary importa: sem ele, um cache intermediário pode guardar o fragmento e entregá-lo para quem abriu o link direto, que recebe uma lista sem layout. Dentro do parcial, tudo que vem do usuário passa por escape:
<p><?= count($produtos) ?> resultado(s) para
<strong><?= htmlspecialchars($categoria, ENT_QUOTES, 'UTF-8') ?></strong></p>
<ul>
<?php foreach ($produtos as $p): ?>
<li>
<a href="/produto/<?= (int) $p['id'] ?>"><?= htmlspecialchars($p['nome'], ENT_QUOTES, 'UTF-8') ?></a>
</li>
<?php endforeach; ?>
</ul>
O buscarProdutos usa consulta parametrizada; o filtro de preço já chega como inteiro ou nulo.
O navegador: cancelar, conferir, trocar, registrar
const lista = document.getElementById('lista-produtos');
const aviso = document.getElementById('aviso-lista');
let controle = null;
async function carregar(url, registrarNoHistorico) {
if (controle) {
controle.abort();
}
controle = new AbortController();
lista.setAttribute('aria-busy', 'true');
aviso.textContent = 'Carregando...';
try {
const resp = await fetch(url, {
headers: { 'X-Fragmento': '1' },
signal: controle.signal
});
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const html = await resp.text();
lista.innerHTML = html;
aviso.textContent = 'Lista atualizada.';
if (registrarNoHistorico) {
history.pushState({}, '', url);
}
} catch (err) {
if (err.name === 'AbortError') {
return;
}
aviso.textContent = 'Não foi possível atualizar a lista. Os resultados abaixo são do filtro anterior.';
} finally {
lista.removeAttribute('aria-busy');
}
}
document.getElementById('filtros').addEventListener('change', function (e) {
const url = new URL(location.href);
const dados = new FormData(e.currentTarget);
url.search = new URLSearchParams(dados).toString();
carregar(url.toString(), true);
});
window.addEventListener('popstate', function () {
carregar(location.href, false);
});
Alguns detalhes que fazem diferença. O innerHTML aqui recebe HTML gerado pelo seu próprio servidor, que já escapou os dados; o que você nunca faz é montar HTML no JavaScript juntando texto vindo da URL ou do usuário. O elemento aviso-lista tem aria-live="polite" no HTML, então leitores de tela anunciam a mudança. E o erro é dito com todas as letras: a lista antiga fica, mas a pessoa sabe que ela é antiga.
O AbortController resolve a corrida: quando o segundo filtro dispara, o primeiro fetch é cancelado e cai no AbortError, que é ignorado de propósito. Só a resposta mais recente chega à tela.
Quando não usar troca de miolo
Nem toda interação merece AJAX. Se a "troca" é na verdade uma navegação para outra página, com outro título e outro propósito, um link comum é mais simples, mais rápido de manter e já funciona com voltar, favoritos e busca. A troca parcial compensa em filtros, paginação de listas e abas de uma mesma tela. E evite polling apertado, uma requisição a cada segundo só para "parecer vivo"; se o dado muda pouco, atualize quando a pessoa pede ou em intervalos que o servidor aguente.
Para paginação especificamente, cuidado com itens que mudam de página enquanto a pessoa navega; o post sobre paginação que não pula itens mostra a abordagem por cursor.
Erros comuns
- Trocar a div sem mudar a URL: o link compartilhado, o recarregar e a nova aba voltam para o início, e o cliente perde o que montou.
- Ignorar o erro da requisição: o conteúdo antigo fica na tela como se fosse o resultado do novo filtro, e a pessoa decide com dado errado.
- Não cancelar requisições anteriores: a resposta lenta chega por último e sobrescreve a mais recente; filtro e lista discordam.
- Montar HTML com texto da URL no JavaScript: qualquer link malicioso executa script na sua página.
- Esquecer o
Varyquando a mesma rota devolve fragmento e página: o CDN entrega o fragmento sem layout para quem abriu o link. - Página inicial vazia esperando o script: sem JavaScript, ou com ele falhando, não há conteúdo nenhum, e buscadores veem uma div vazia.
Checklist
- Cada combinação de filtros tem uma URL própria que abre a tela idêntica numa aba nova.
- O servidor renderiza a página completa para essa URL sem depender de JavaScript.
- Fragmento e página completa usam o mesmo template parcial.
- A resposta que varia por cabeçalho envia
Varycom esse cabeçalho. - Requisição anterior é cancelada antes de disparar a próxima.
- Resposta com erro mostra mensagem e não troca o conteúdo.
- Todo dado de usuário é escapado no servidor; o JavaScript não monta HTML com texto da URL.
- Uma região com
aria-liveanuncia que a lista mudou.
Escolha agora uma tela do seu sistema que troca conteúdo sem recarregar. Se você copiar a URL da barra e abrir numa janela anônima, aparece exatamente o que está na div?
Referência: http://lucasmoreira.com.br/2013/06/23/usar-jquery-ajax-e-php-para-atualizar-div-sem-refresh/