Programação Por Equipe DevBras

Capa do artigo: Trocar o miolo da página com AJAX sem perder link, erro e segurança

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

  1. A URL é a fonte da verdade. Os filtros viram parâmetros de query: /catalogo?categoria=furadeiras&preco_max=500.
  2. O servidor renderiza essa URL inteira. Abrir o link direto entrega a página completa já filtrada, sem depender de script.
  3. A mesma rota devolve só o fragmento quando a requisição pede, por um cabeçalho que o seu JavaScript envia.
  4. O JavaScript cancela a requisição anterior antes de disparar a nova, com AbortController.
  5. 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.
  6. Depois de trocar, atualiza a URL com pushState e 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 Vary quando 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 Vary com 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-live anuncia 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?

Avalie este artigo

Referência: http://lucasmoreira.com.br/2013/06/23/usar-jquery-ajax-e-php-para-atualizar-div-sem-refresh/