Capítulo 17 — Integração Frontend + API¶
Vídeo curto explicativo (link será adicionado posteriormente)
17.1 — Arquitetura de uma SPA simples¶
Vídeo curto explicativo (link será adicionado posteriormente)
Nos capítulos anteriores, cada script JavaScript era um arquivo único responsável por tudo: buscar dados, manipular o DOM, gerenciar estado e lidar com eventos. Para projetos pequenos, isso é suficiente. À medida que a aplicação cresce — múltiplas telas, diversas chamadas de API, estado compartilhado entre componentes —, a ausência de organização transforma o código em um conjunto frágil e difícil de manter.
Este capítulo apresenta os padrões de organização que tornam projetos front-end reais sustentáveis, sem a necessidade de frameworks como React ou Vue — apenas JavaScript moderno com módulos ES6.
17.1.1 — O que é uma Single Page Application¶
Uma SPA (Single Page Application) é uma aplicação web que carrega um único documento HTML e atualiza dinamicamente seu conteúdo via JavaScript — sem recarregar a página ao navegar entre seções. A navegação é interceptada pelo JavaScript, que renderiza o conteúdo correto com base na URL atual.
Aplicação tradicional (MPA): SPA:
┌──────────────────────┐ ┌──────────────────────┐
│ /index.html │ │ /index.html │
│ /produtos.html │ vs. │ │
│ /produto-detalhe.html│ │ JavaScript controla │
│ /carrinho.html │ │ o que é exibido │
│ /sobre.html │ │ com base na URL │
└──────────────────────┘ └──────────────────────┘
Cada navegação = Navegação = JS
novo request ao servidor atualiza o DOM
Vantagens de uma SPA: - Navegação mais rápida (sem recarregar a página inteira) - Experiência mais fluida para o usuário - Reutilização de componentes entre telas - Estado da aplicação preservado entre navegações
Limitações que devem ser conhecidas: - SEO mais complexo (conteúdo renderizado via JS) - Carregamento inicial mais lento - Gestão de histórico do navegador requer atenção
17.1.2 — Separação de responsabilidades¶
O princípio de separação de responsabilidades (separation of concerns) divide o código em camadas com funções bem definidas:
┌─────────────────────────────────────────────────┐
│ UI Layer │
│ Renderização de HTML, manipulação de DOM, │
│ event listeners, estados visuais │
│ Arquivos: pages/, components/ │
└─────────────────────┬───────────────────────────┘
│ chama
┌─────────────────────▼───────────────────────────┐
│ State Layer │
│ Estado global da aplicação, notificação │
│ de mudanças para a UI │
│ Arquivo: store.js │
└─────────────────────┬───────────────────────────┘
│ chama
┌─────────────────────▼───────────────────────────┐
│ Service Layer │
│ Comunicação com APIs, cache, transformação │
│ de dados, tratamento de erros de rede │
│ Arquivos: services/ │
└─────────────────────────────────────────────────┘
Regra fundamental: a camada de UI nunca faz fetch() diretamente. Ela chama funções da camada de serviços. A camada de serviços nunca manipula o DOM. Essa separação permite testar cada camada isoladamente e trocar implementações sem cascata de mudanças.
17.1.3 — Módulos ES6: import e export¶
Os módulos ES6 permitem dividir o código em arquivos com escopos isolados, exportando apenas o que deve ser público:
// ── services/api.js ──────────────────────────────────────
// Export nomeado: exporta uma função específica
export async function buscarDados(url, opcoes = {}) {
const resposta = await fetch(url, opcoes);
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);
return resposta.json();
}
export function construirUrl(base, params = {}) {
const url = new URL(base);
Object.entries(params).forEach(([k, v]) => {
if (v !== null && v !== undefined) url.searchParams.set(k, v);
});
return url.toString();
}
// Export padrão: um único export principal por arquivo
export default class ApiClient {
constructor(baseUrl, opcoesPadrao = {}) {
this.baseUrl = baseUrl;
this.opcoesPadrao = opcoesPadrao;
}
async get(endpoint, params = {}) {
const url = construirUrl(`${this.baseUrl}${endpoint}`, params);
return buscarDados(url, this.opcoesPadrao);
}
async post(endpoint, dados) {
return buscarDados(`${this.baseUrl}${endpoint}`, {
...this.opcoesPadrao,
method: 'POST',
headers: { 'Content-Type': 'application/json',
...this.opcoesPadrao.headers },
body: JSON.stringify(dados)
});
}
}
// ── app.js — importando ──────────────────────────────────
// Import nomeado
import { buscarDados, construirUrl } from './services/api.js';
// Import padrão
import ApiClient from './services/api.js';
// Import misto
import ApiClient, { buscarDados } from './services/api.js';
// Import com alias
import { buscarDados as fetchData } from './services/api.js';
// Import de namespace
import * as Api from './services/api.js';
// Api.buscarDados(...), Api.construirUrl(...)
// Import dinâmico (lazy loading — carrega quando necessário)
const { renderizarGrafico } = await import('./components/grafico.js');
Importante: módulos ES6 só funcionam com o protocolo HTTP/HTTPS — não funcionam com
file://. Para desenvolvimento local, é necessário um servidor local simples. A extensão Live Server do VS Code resolve isso com um clique.
<!-- Declarar o script de entrada como módulo -->
<script type="module" src="js/app.js"></script>
17.1.4 — Organização de arquivos para projetos com API¶
projeto/
├── index.html
├── css/
│ ├── variables.css
│ ├── base.css
│ ├── components.css
│ └── pages.css
└── js/
├── app.js ← entrada: inicializa router e estado global
├── router.js ← roteamento baseado em hash
├── store.js ← estado global simplificado
├── services/
│ ├── api.js ← cliente HTTP genérico
│ ├── produtos.js ← serviços específicos de produtos
│ └── usuarios.js ← serviços específicos de usuários
├── components/
│ ├── card.js ← componente de card reutilizável
│ ├── modal.js ← componente modal
│ └── paginacao.js ← componente de paginação
└── pages/
├── listagem.js ← página de listagem
├── detalhe.js ← página de detalhe
└── formulario.js ← página de formulário
17.2 — Renderização dinâmica de dados¶
Vídeo curto explicativo (link será adicionado posteriormente)
17.2.1 — Do JSON ao HTML: padrões de renderização¶
O processo de converter dados JSON em HTML é o núcleo do desenvolvimento frontend com APIs. Existem três abordagens principais, cada uma com trade-offs:
// Abordagem 1: innerHTML com template literal
// Rápida e legível, mas requer cuidado com XSS
function renderizarCard(produto) {
return `
<article class="card" data-id="${produto.id}">
<img src="${produto.imagem}" alt="${escapar(produto.nome)}" loading="lazy" />
<div class="card__corpo">
<h2 class="card__titulo">${escapar(produto.nome)}</h2>
<p class="card__preco">R$ ${produto.preco.toFixed(2)}</p>
</div>
</article>
`;
}
// Abordagem 2: createElement (sem risco de XSS, mais verboso)
function criarCardSeguro(produto) {
const article = document.createElement('article');
article.className = 'card';
article.dataset.id = produto.id;
const img = document.createElement('img');
img.src = produto.imagem;
img.alt = produto.nome; // textContent é safe por padrão
img.loading = 'lazy';
const titulo = document.createElement('h2');
titulo.className = 'card__titulo';
titulo.textContent = produto.nome; // textContent nunca interpreta HTML
article.appendChild(img);
article.appendChild(titulo);
return article;
}
// Abordagem 3: <template> HTML (melhor performance, reutilizável)
// HTML: <template id="card-template">...</template>
function criarCardComTemplate(produto) {
const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.card__titulo').textContent = produto.nome;
clone.querySelector('.card__preco').textContent =
`R$ ${produto.preco.toFixed(2)}`;
clone.querySelector('img').src = produto.imagem;
clone.querySelector('img').alt = produto.nome;
clone.querySelector('.card').dataset.id = produto.id;
return clone;
}
// Função auxiliar de escape para uso seguro com innerHTML
function escapar(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
17.2.2 — Renderização de listas com map() e Fragment¶
// Renderização eficiente de listas usando DocumentFragment
function renderizarLista(container, itens, renderizarItem) {
container.innerHTML = '';
const fragment = document.createDocumentFragment();
itens.forEach(item => fragment.appendChild(renderizarItem(item)));
container.appendChild(fragment);
}
// Renderização com innerHTML (para HTML complexo)
function renderizarListaHTML(container, itens, renderizarItem) {
container.innerHTML = itens.map(renderizarItem).join('');
}
// Exemplo de uso
const produtos = await ProdutosService.listar();
const lista = document.querySelector('#lista-produtos');
renderizarListaHTML(lista, produtos, renderizarCard);
17.2.3 — Atualização parcial do DOM¶
Rerenderizar a lista inteira a cada mudança é ineficiente e destrói o estado visual (scroll position, foco, animações em andamento). A atualização parcial preserva o que não mudou:
// Atualizar apenas um item específico na lista
function atualizarItemNaLista(id, novosDados) {
const itemEl = document.querySelector(`[data-id="${id}"]`);
if (!itemEl) return;
// Atualizar apenas os campos que mudaram
const tituloEl = itemEl.querySelector('.card__titulo');
if (tituloEl && novosDados.nome) {
tituloEl.textContent = novosDados.nome;
}
const precoEl = itemEl.querySelector('.card__preco');
if (precoEl && novosDados.preco !== undefined) {
precoEl.textContent = `R$ ${novosDados.preco.toFixed(2)}`;
}
}
// Remover item sem re-renderizar a lista
function removerItemDaLista(id) {
const itemEl = document.querySelector(`[data-id="${id}"]`);
if (!itemEl) return;
// Animação de saída antes de remover
itemEl.classList.add('saindo');
itemEl.addEventListener('animationend', () => itemEl.remove(), { once: true });
}
// Adicionar item sem re-renderizar a lista
function adicionarItemNaLista(container, novoItem, renderizarItem) {
const novoEl = renderizarItem(novoItem);
novoEl.classList.add('entrando');
container.prepend(novoEl); // adiciona no início
}
17.2.4 — Exercício prático: listagem com filtro e ordenação¶
// Estado local da listagem
const estadoListagem = {
todos: [], // dados originais da API
filtrados: [], // dados após filtros
filtros: {
busca: '',
categoria: '',
precoMax: Infinity,
},
ordenacao: {
campo: 'nome',
direcao: 'asc',
},
pagina: 1,
porPagina: 12,
};
// Aplicar filtros e ordenação
function aplicarFiltrosOrdenacao() {
let resultado = [...estadoListagem.todos];
// Filtros
const { busca, categoria, precoMax } = estadoListagem.filtros;
if (busca) {
const termo = busca.toLowerCase();
resultado = resultado.filter(p =>
p.nome.toLowerCase().includes(termo) ||
p.descricao?.toLowerCase().includes(termo)
);
}
if (categoria) {
resultado = resultado.filter(p => p.categoria === categoria);
}
if (precoMax < Infinity) {
resultado = resultado.filter(p => p.preco <= precoMax);
}
// Ordenação
const { campo, direcao } = estadoListagem.ordenacao;
resultado.sort((a, b) => {
let valA = a[campo];
let valB = b[campo];
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
const comparacao = valA < valB ? -1 : valA > valB ? 1 : 0;
return direcao === 'asc' ? comparacao : -comparacao;
});
estadoListagem.filtrados = resultado;
estadoListagem.pagina = 1; // voltar à primeira página ao filtrar
renderizarPaginaAtual();
atualizarContador();
}
// Renderizar apenas a página atual
function renderizarPaginaAtual() {
const { filtrados, pagina, porPagina } = estadoListagem;
const inicio = (pagina - 1) * porPagina;
const fim = inicio + porPagina;
const itensPagina = filtrados.slice(inicio, fim);
const container = document.querySelector('#lista-produtos');
const ui = new EstadoUI('#lista-produtos');
if (!itensPagina.length) {
ui.vazio('Nenhum produto encontrado para os filtros aplicados.');
return;
}
renderizarListaHTML(container, itensPagina, renderizarCard);
renderizarPaginacao(filtrados.length);
}
function atualizarContador() {
const el = document.querySelector('#contador-resultados');
if (el) {
el.textContent = `${estadoListagem.filtrados.length} produto(s) encontrado(s)`;
}
}
// Event listeners para filtros
document.querySelector('#busca-produto')?.addEventListener('input',
debounce((e) => {
estadoListagem.filtros.busca = e.target.value;
aplicarFiltrosOrdenacao();
}, 300)
);
document.querySelector('#filtro-categoria')?.addEventListener('change', (e) => {
estadoListagem.filtros.categoria = e.target.value;
aplicarFiltrosOrdenacao();
});
document.querySelector('#ordenar-por')?.addEventListener('change', (e) => {
const [campo, direcao] = e.target.value.split(':');
estadoListagem.ordenacao = { campo, direcao };
aplicarFiltrosOrdenacao();
});
// Carregamento inicial
async function inicializarListagem() {
const ui = new EstadoUI('#lista-produtos');
ui.carregando('Carregando produtos...');
try {
estadoListagem.todos = await ProdutosService.listar();
estadoListagem.filtrados = [...estadoListagem.todos];
renderizarPaginaAtual();
await popularFiltros();
} catch (erro) {
ui.erro('Erro ao carregar produtos.', inicializarListagem);
}
}
17.3 — Busca dinâmica com API¶
Vídeo curto explicativo (link será adicionado posteriormente)
17.3.1 — Debounce: evitando requisições excessivas¶
Sem debounce, cada tecla digitada em um campo de busca dispara uma requisição. Em uma digitação de 10 caracteres, isso gera 10 requisições — 9 das quais são desnecessárias:
// Implementação de debounce
function debounce(fn, espera) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), espera);
};
}
// Implementação de throttle (para scroll, resize)
function throttle(fn, limite) {
let emEspera = false;
return function(...args) {
if (emEspera) return;
fn.apply(this, args);
emEspera = true;
setTimeout(() => { emEspera = false; }, limite);
};
}
// Busca com debounce de 300ms
const buscarComDebounce = debounce(async (termo) => {
if (!termo.trim()) {
limparResultados();
return;
}
await executarBusca(termo);
}, 300);
document.querySelector('#campo-busca').addEventListener('input', (e) => {
buscarComDebounce(e.target.value);
});
17.3.2 — Cancelamento de requisições com AbortController¶
O debounce evita requisições desnecessárias, mas não resolve o problema de race condition: se o usuário digitar "re", depois "reac" rapidamente, e a resposta de "re" chegar depois da de "reac", o resultado errado será exibido.
// Solução: cancelar a requisição anterior ao iniciar uma nova
let controladorAtual = null;
async function executarBusca(termo) {
// Cancelar requisição anterior se ainda estiver em andamento
if (controladorAtual) {
controladorAtual.abort();
}
controladorAtual = new AbortController();
const { signal } = controladorAtual;
const ui = new EstadoUI('#resultados-busca');
ui.carregando(`Buscando "${termo}"...`);
try {
const url = construirUrl('https://api.github.com/search/repositories', {
q: termo,
sort: 'stars',
per_page: 10
});
const resposta = await fetch(url, { signal });
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);
const dados = await resposta.json();
controladorAtual = null; // limpa após sucesso
if (!dados.items.length) {
ui.vazio(`Nenhum resultado para "${termo}".`);
return;
}
ui.sucesso(renderizarResultadosBusca(dados.items, dados.total_count));
} catch (erro) {
// AbortError é esperado — não é um erro real
if (erro.name === 'AbortError') return;
ui.erro('Erro ao buscar. Tente novamente.');
console.error('Erro na busca:', erro);
}
}
17.3.3 — Cache simples no cliente¶
// Cache em memória usando Map
class CacheAPI {
#cache = new Map();
#ttl; // tempo de vida em ms
constructor(ttlMs = 5 * 60 * 1000) { // 5 minutos padrão
this.#ttl = ttlMs;
}
set(chave, valor) {
this.#cache.set(chave, {
valor,
expira: Date.now() + this.#ttl
});
}
get(chave) {
const item = this.#cache.get(chave);
if (!item) return null;
if (Date.now() > item.expira) {
this.#cache.delete(chave);
return null;
}
return item.valor;
}
has(chave) { return this.get(chave) !== null; }
clear() { this.#cache.clear(); }
delete(chave) { this.#cache.delete(chave); }
}
// Wrapper fetch com cache
const cache = new CacheAPI(2 * 60 * 1000); // 2 minutos
async function buscarComCache(url) {
// Retornar do cache se disponível
if (cache.has(url)) {
return cache.get(url);
}
const dados = await buscarDados(url);
cache.set(url, dados);
return dados;
}
17.3.4 — Exercício prático: busca de repositórios no GitHub¶
<div class="busca-github">
<form class="busca-form" id="form-github" novalidate>
<div class="busca-campo">
<label for="busca-repo" class="sr-only">Buscar repositórios</label>
<input
type="search"
id="busca-repo"
placeholder="Buscar repositórios no GitHub..."
autocomplete="off"
aria-label="Buscar repositórios no GitHub"
/>
</div>
<div class="busca-filtros">
<select id="linguagem-filtro" aria-label="Filtrar por linguagem">
<option value="">Todas as linguagens</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value="java">Java</option>
</select>
<select id="ordenar-github" aria-label="Ordenar por">
<option value="stars">Mais estrelas</option>
<option value="forks">Mais forks</option>
<option value="updated">Atualizado recentemente</option>
</select>
</div>
</form>
<p id="total-resultados" aria-live="polite" class="sr-only"></p>
<div id="resultados-github"></div>
</div>
// Serviço GitHub
class GitHubService {
static BASE = 'https://api.github.com';
static #cache = new CacheAPI(5 * 60 * 1000);
static async buscarRepositorios(params) {
const { termo, linguagem, ordenar = 'stars', pagina = 1 } = params;
let query = termo;
if (linguagem) query += ` language:${linguagem}`;
const url = construirUrl(`${this.BASE}/search/repositories`, {
q: query,
sort: ordenar,
order: 'desc',
per_page: 12,
page: pagina,
});
if (this.#cache.has(url)) return this.#cache.get(url);
const resposta = await fetch(url, {
headers: { 'Accept': 'application/vnd.github.v3+json' }
});
if (resposta.status === 403) {
throw new Error('Limite de requisições excedido. Aguarde um momento.');
}
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);
const dados = await resposta.json();
this.#cache.set(url, dados);
return dados;
}
static async buscarUsuario(login) {
const url = `${this.BASE}/users/${login}`;
if (this.#cache.has(url)) return this.#cache.get(url);
const dados = await buscarDados(url);
this.#cache.set(url, dados);
return dados;
}
}
// Renderização
function renderizarRepositorio(repo) {
const linguagemHtml = repo.language
? `<span class="repo__linguagem">${escapar(repo.language)}</span>`
: '';
return `
<article class="repo-card" data-id="${repo.id}">
<div class="repo-card__cabecalho">
<img
src="${repo.owner.avatar_url}"
alt="${escapar(repo.owner.login)}"
class="repo-card__avatar"
loading="lazy"
/>
<div>
<h3 class="repo-card__titulo">
<a href="${repo.html_url}" target="_blank" rel="noopener noreferrer"
class="repo-card__link">
${escapar(repo.full_name)}
</a>
</h3>
${linguagemHtml}
</div>
</div>
${repo.description ? `
<p class="repo-card__descricao">
${escapar(repo.description)}
</p>
` : ''}
<div class="repo-card__stats">
<span title="Estrelas">⭐ ${formatarNumero(repo.stargazers_count)}</span>
<span title="Forks">🍴 ${formatarNumero(repo.forks_count)}</span>
<span title="Issues abertas">🐛 ${formatarNumero(repo.open_issues_count)}</span>
<span title="Última atualização">
📅 ${formatarData(repo.updated_at)}
</span>
</div>
</article>
`;
}
// Controlador da busca
let abortController = null;
const cacheBusca = new CacheAPI(2 * 60 * 1000);
const buscarComDebounce = debounce(async () => {
const termo = document.getElementById('busca-repo').value.trim();
const linguagem = document.getElementById('linguagem-filtro').value;
const ordenar = document.getElementById('ordenar-github').value;
if (!termo) {
document.getElementById('resultados-github').innerHTML = '';
return;
}
if (abortController) abortController.abort();
abortController = new AbortController();
const ui = new EstadoUI('#resultados-github');
ui.carregando('Buscando repositórios...');
try {
const dados = await GitHubService.buscarRepositorios({
termo, linguagem, ordenar
});
document.getElementById('total-resultados').textContent =
`${dados.total_count.toLocaleString('pt-BR')} repositórios encontrados`;
if (!dados.items.length) {
ui.vazio('Nenhum repositório encontrado.');
return;
}
const html = `
<p class="resultados-info">
${dados.total_count.toLocaleString('pt-BR')} resultados
${linguagem ? `em ${linguagem}` : ''}
</p>
<div class="repos-grid">
${dados.items.map(renderizarRepositorio).join('')}
</div>
`;
ui.sucesso(html);
abortController = null;
} catch (erro) {
if (erro.name === 'AbortError') return;
ui.erro(erro.message || 'Erro ao buscar repositórios.', buscarComDebounce);
}
}, 400);
// Inicializar
['busca-repo', 'linguagem-filtro', 'ordenar-github'].forEach(id => {
document.getElementById(id)?.addEventListener('input', buscarComDebounce);
document.getElementById(id)?.addEventListener('change', buscarComDebounce);
});
// Utilitários
function formatarNumero(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return n.toString();
}
function formatarData(iso) {
return new Date(iso).toLocaleDateString('pt-BR', {
day: '2-digit', month: 'short', year: 'numeric'
});
}
17.4 — Aplicação completa: listagem, detalhe e busca¶
Vídeo curto explicativo (link será adicionado posteriormente)
17.4.1 — Roteamento simples via hash¶
O roteamento baseado em hash (#) utiliza a parte da URL após # para determinar qual "página" exibir — sem recarregar o documento:
// router.js
class Router {
#rotas = new Map();
#rotaAtual = null;
#rotaNotFound = null;
// Registrar rota
on(caminho, handler) {
this.#rotas.set(caminho, handler);
return this;
}
notFound(handler) {
this.#rotaNotFound = handler;
return this;
}
// Inicializar: ouvir mudanças de hash e rota inicial
inicializar() {
window.addEventListener('hashchange', () => this.#navegar());
this.#navegar(); // processar rota inicial
return this;
}
#navegar() {
const hash = window.location.hash.slice(1) || '/'; // remove o #
const [caminho, ...query] = hash.split('?');
// Buscar rota exata
if (this.#rotas.has(caminho)) {
const params = Object.fromEntries(
new URLSearchParams(query.join('?'))
);
this.#rotas.get(caminho)(params);
this.#rotaAtual = caminho;
return;
}
// Buscar rota com parâmetros (ex.: /produtos/:id)
for (const [padrao, handler] of this.#rotas) {
const match = this.#matchRota(padrao, caminho);
if (match) {
handler(match);
this.#rotaAtual = caminho;
return;
}
}
// Rota não encontrada
this.#rotaNotFound?.();
}
#matchRota(padrao, caminho) {
const partesP = padrao.split('/');
const partesC = caminho.split('/');
if (partesP.length !== partesC.length) return null;
const params = {};
for (let i = 0; i < partesP.length; i++) {
if (partesP[i].startsWith(':')) {
params[partesP[i].slice(1)] = decodeURIComponent(partesC[i]);
} else if (partesP[i] !== partesC[i]) {
return null;
}
}
return params;
}
// Navegar programaticamente
static ir(caminho) {
window.location.hash = caminho;
}
}
// app.js — configurando o router
import Router from './router.js';
import { renderizarListagem } from './pages/listagem.js';
import { renderizarDetalhe } from './pages/detalhe.js';
const router = new Router();
router
.on('/', () => renderizarListagem())
.on('/produtos', () => renderizarListagem())
.on('/produtos/:id', ({ id }) => renderizarDetalhe(id))
.on('/sobre', () => renderizarSobre())
.notFound( () => renderizarNotFound())
.inicializar();
17.4.2 — Tela de listagem com paginação¶
// pages/listagem.js
import ProdutosService from '../services/produtos.js';
import { renderizarCard } from '../components/card.js';
import Router from '../router.js';
export async function renderizarListagem(params = {}) {
const app = document.getElementById('app');
const pagina = parseInt(params.pagina) || 1;
const busca = params.busca || '';
app.innerHTML = `
<section class="listagem" aria-labelledby="titulo-listagem">
<header class="listagem__cabecalho">
<h1 id="titulo-listagem">Produtos</h1>
<form class="listagem__busca" id="form-busca">
<input type="search" name="busca" value="${escapar(busca)}"
placeholder="Buscar produtos..." aria-label="Buscar" />
<button type="submit" class="btn btn--primario">Buscar</button>
</form>
</header>
<div id="conteudo-listagem" aria-live="polite" aria-busy="true">
<div class="skeleton-grid">
${Array(12).fill('<div class="skeleton skeleton--card"></div>').join('')}
</div>
</div>
</section>
`;
// Form de busca
document.getElementById('form-busca').addEventListener('submit', (e) => {
e.preventDefault();
const termo = e.target.busca.value.trim();
Router.ir(`/produtos?busca=${encodeURIComponent(termo)}&pagina=1`);
});
try {
const resultado = await ProdutosService.listar({ busca, pagina });
const conteudo = document.getElementById('conteudo-listagem');
conteudo.setAttribute('aria-busy', 'false');
if (!resultado.dados.length) {
conteudo.innerHTML = `
<div class="estado estado--vazio">
<p>Nenhum produto encontrado${busca ? ` para "${escapar(busca)}"` : ''}.</p>
${busca ? `<a href="#/produtos" class="btn btn--secundario">Ver todos</a>` : ''}
</div>
`;
return;
}
conteudo.innerHTML = `
<p class="listagem__total">${resultado.total} produto(s)</p>
<div class="grade-produtos" id="grade-produtos">
${resultado.dados.map(renderizarCard).join('')}
</div>
${renderizarPaginacao(resultado.total, resultado.porPagina, pagina, busca)}
`;
// Delegação de eventos nos cards
document.getElementById('grade-produtos').addEventListener('click', (e) => {
const card = e.target.closest('[data-id]');
if (card) Router.ir(`/produtos/${card.dataset.id}`);
});
} catch (erro) {
document.getElementById('conteudo-listagem').innerHTML = `
<div class="estado estado--erro" role="alert">
<p>Erro ao carregar produtos. Tente novamente.</p>
<button type="button" class="btn btn--secundario"
onclick="renderizarListagem(${JSON.stringify({ busca, pagina })})">
Tentar novamente
</button>
</div>
`;
}
}
function renderizarPaginacao(total, porPagina, paginaAtual, busca = '') {
const totalPaginas = Math.ceil(total / porPagina);
if (totalPaginas <= 1) return '';
const parametroBusca = busca ? `busca=${encodeURIComponent(busca)}&` : '';
const botoes = Array.from({ length: totalPaginas }, (_, i) => {
const p = i + 1;
const ativo = p === paginaAtual;
return `
<li>
<a
href="#/produtos?${parametroBusca}pagina=${p}"
class="paginacao__botao ${ativo ? 'paginacao__botao--ativo' : ''}"
aria-label="Página ${p}"
${ativo ? 'aria-current="page"' : ''}
>${p}</a>
</li>
`;
}).join('');
return `
<nav class="paginacao" aria-label="Paginação">
<ul class="paginacao__lista">
${paginaAtual > 1 ? `
<li>
<a href="#/produtos?${parametroBusca}pagina=${paginaAtual - 1}"
class="paginacao__botao" aria-label="Página anterior">
← Anterior
</a>
</li>
` : ''}
${botoes}
${paginaAtual < totalPaginas ? `
<li>
<a href="#/produtos?${parametroBusca}pagina=${paginaAtual + 1}"
class="paginacao__botao" aria-label="Próxima página">
Próxima →
</a>
</li>
` : ''}
</ul>
</nav>
`;
}
17.4.3 — Tela de detalhe com parâmetros de rota¶
// pages/detalhe.js
import ProdutosService from '../services/produtos.js';
import Router from '../router.js';
export async function renderizarDetalhe(id) {
const app = document.getElementById('app');
// Esqueleto imediato
app.innerHTML = `
<section class="detalhe" aria-labelledby="titulo-detalhe">
<a href="#/produtos" class="voltar-link">← Voltar</a>
<div id="conteudo-detalhe" aria-live="polite" aria-busy="true">
<div class="skeleton-detalhe">
<div class="skeleton skeleton--imagem"></div>
<div class="skeleton-info">
<div class="skeleton skeleton--titulo"></div>
<div class="skeleton skeleton--texto"></div>
<div class="skeleton skeleton--texto skeleton--curto"></div>
</div>
</div>
</div>
</section>
`;
try {
const produto = await ProdutosService.buscarPorId(id);
const conteudo = document.getElementById('conteudo-detalhe');
conteudo.setAttribute('aria-busy', 'false');
conteudo.innerHTML = `
<article class="produto-detalhe">
<div class="produto-detalhe__galeria">
<img
src="${escapar(produto.imagem)}"
alt="${escapar(produto.nome)}"
class="produto-detalhe__imagem"
/>
</div>
<div class="produto-detalhe__info">
<span class="produto-detalhe__categoria">
${escapar(produto.categoria)}
</span>
<h1 class="produto-detalhe__titulo" id="titulo-detalhe">
${escapar(produto.nome)}
</h1>
<p class="produto-detalhe__preco">
R$ ${produto.preco.toFixed(2)}
</p>
<div class="produto-detalhe__avaliacao" aria-label="Avaliação">
${renderizarEstrelas(produto.avaliacao)}
<span>(${produto.totalAvaliacoes} avaliações)</span>
</div>
<p class="produto-detalhe__descricao">
${escapar(produto.descricao)}
</p>
<div class="produto-detalhe__acoes">
<div class="quantidade">
<button type="button" class="btn-quantidade" id="btn-menos"
aria-label="Diminuir quantidade">−</button>
<input type="number" id="quantidade" value="1" min="1"
max="${produto.estoque}" aria-label="Quantidade" />
<button type="button" class="btn-quantidade" id="btn-mais"
aria-label="Aumentar quantidade">+</button>
</div>
<button type="button" class="btn btn--primario btn--bloco"
id="btn-adicionar">
Adicionar ao carrinho
</button>
</div>
<p class="produto-detalhe__estoque">
${produto.estoque > 0
? `${produto.estoque} unidades disponíveis`
: '<strong>Esgotado</strong>'}
</p>
</div>
</article>
`;
inicializarControlesQuantidade(produto.estoque);
document.getElementById('btn-adicionar').addEventListener('click', () => {
const qtd = parseInt(document.getElementById('quantidade').value);
adicionarAoCarrinho(produto, qtd);
});
} catch (erro) {
document.getElementById('conteudo-detalhe').innerHTML = `
<div class="estado estado--erro" role="alert">
<p>${erro.status === 404 ? 'Produto não encontrado.' : 'Erro ao carregar produto.'}</p>
<a href="#/produtos" class="btn btn--secundario">Ver todos os produtos</a>
</div>
`;
}
}
function renderizarEstrelas(nota) {
return Array.from({ length: 5 }, (_, i) => {
if (i < Math.floor(nota)) return '★';
if (i < nota) return '⯨'; // meia estrela
return '☆';
}).join('');
}
function inicializarControlesQuantidade(max) {
const input = document.getElementById('quantidade');
document.getElementById('btn-menos').addEventListener('click', () => {
input.value = Math.max(1, parseInt(input.value) - 1);
});
document.getElementById('btn-mais').addEventListener('click', () => {
input.value = Math.min(max, parseInt(input.value) + 1);
});
}
17.4.4 — Estado global simples sem framework¶
// store.js — estado global reativo sem framework
class Store {
#estado;
#ouvintes = new Map();
constructor(estadoInicial) {
this.#estado = estadoInicial;
}
// Ler estado (imutável externamente)
get(chave) {
return structuredClone(this.#estado[chave]);
}
getAll() {
return structuredClone(this.#estado);
}
// Atualizar estado
set(chave, valor) {
const anterior = this.#estado[chave];
this.#estado[chave] = valor;
// Notificar ouvintes
this.#notificar(chave, valor, anterior);
this.#notificar('*', this.#estado, this.#estado);
}
// Atualização parcial de objeto
merge(chave, parcial) {
const atual = this.#estado[chave];
if (typeof atual !== 'object') throw new Error(`${chave} não é objeto`);
this.set(chave, { ...atual, ...parcial });
}
// Inscrever em mudanças
subscribe(chave, callback) {
if (!this.#ouvintes.has(chave)) {
this.#ouvintes.set(chave, new Set());
}
this.#ouvintes.get(chave).add(callback);
// Retorna função de cancelamento
return () => this.#ouvintes.get(chave)?.delete(callback);
}
#notificar(chave, novoValor, valorAnterior) {
this.#ouvintes.get(chave)?.forEach(cb => cb(novoValor, valorAnterior));
}
}
// Estado global da aplicação
export const store = new Store({
usuario: null,
carrinho: { itens: [], total: 0 },
tema: localStorage.getItem('tema') || 'claro',
notificacoes: [],
});
// Persistir tema no localStorage
store.subscribe('tema', (novoTema) => {
localStorage.setItem('tema', novoTema);
document.documentElement.dataset.tema = novoTema;
});
// Atualizar badge do carrinho ao mudar
store.subscribe('carrinho', (carrinho) => {
const badge = document.getElementById('badge-carrinho');
if (badge) {
const total = carrinho.itens.reduce((s, i) => s + i.quantidade, 0);
badge.textContent = total;
badge.hidden = total === 0;
}
});
// Uso
store.set('tema', 'escuro');
store.merge('carrinho', {
itens: [...store.get('carrinho').itens, { id: 1, nome: 'Produto', quantidade: 1 }]
});
17.4.5 — Exercício prático: catálogo de filmes com OMDb API¶
// Documentação: https://www.omdbapi.com
// Requer chave de API gratuita (1000 req/dia)
class OMDbService {
static #apiKey = 'SUA_CHAVE_AQUI';
static BASE = 'https://www.omdbapi.com';
static #cache = new CacheAPI(10 * 60 * 1000); // 10 min
static async buscar(params) {
const url = construirUrl(this.BASE, {
apikey: this.#apiKey,
...params
});
if (this.#cache.has(url)) return this.#cache.get(url);
const dados = await buscarDados(url);
if (dados.Response === 'False') {
throw new Error(dados.Error || 'Erro na API');
}
this.#cache.set(url, dados);
return dados;
}
static async pesquisar(titulo, tipo = '', pagina = 1) {
return this.buscar({
s: titulo,
type: tipo || undefined,
page: pagina
});
}
static async detalhe(imdbId) {
return this.buscar({ i: imdbId, plot: 'full' });
}
}
// Página de catálogo de filmes
export async function renderizarCatalogo(params = {}) {
const app = document.getElementById('app');
const busca = params.busca || 'Matrix';
const tipo = params.tipo || '';
const pagina = parseInt(params.pagina) || 1;
app.innerHTML = `
<main class="catalogo" aria-labelledby="titulo-catalogo">
<header class="catalogo__cabecalho">
<h1 id="titulo-catalogo">🎬 Catálogo de Filmes</h1>
<form id="form-busca-filmes">
<input type="search" id="busca-filmes" value="${escapar(busca)}"
placeholder="Buscar filmes, séries..." />
<select id="tipo-filtro">
<option value="">Todos</option>
<option value="movie" ${tipo === 'movie' ? 'selected' : ''}>Filmes</option>
<option value="series" ${tipo === 'series' ? 'selected' : ''}>Séries</option>
</select>
<button type="submit" class="btn btn--primario">Buscar</button>
</form>
</header>
<div id="resultados-filmes" aria-live="polite" aria-busy="true">
${gerarSkeletons(6, 'skeleton--card')}
</div>
</main>
`;
document.getElementById('form-busca-filmes').addEventListener('submit', (e) => {
e.preventDefault();
const t = document.getElementById('busca-filmes').value.trim();
const tp = document.getElementById('tipo-filtro').value;
Router.ir(`/?busca=${encodeURIComponent(t)}&tipo=${tp}&pagina=1`);
});
const ui = new EstadoUI('#resultados-filmes');
try {
const resultado = await OMDbService.pesquisar(busca, tipo, pagina);
const filmes = resultado.Search;
const total = parseInt(resultado.totalResults);
ui.sucesso(`
<p class="catalogo__total">${total.toLocaleString('pt-BR')} resultado(s)</p>
<div class="filmes-grade">
${filmes.map(renderizarCardFilme).join('')}
</div>
${renderizarPaginacao(total, 10, pagina, `busca=${encodeURIComponent(busca)}&tipo=${tipo}`)}
`);
// Click nos cards → detalhe
document.querySelector('.filmes-grade')?.addEventListener('click', (e) => {
const card = e.target.closest('[data-imdb]');
if (card) Router.ir(`/filme/${card.dataset.imdb}`);
});
} catch (erro) {
ui.erro(erro.message || 'Erro ao buscar filmes.');
}
}
function renderizarCardFilme(filme) {
const poster = filme.Poster !== 'N/A'
? filme.Poster
: 'https://via.placeholder.com/300x445?text=Sem+imagem';
return `
<article class="filme-card" data-imdb="${filme.imdbID}" tabindex="0"
role="button" aria-label="${escapar(filme.Title)} (${filme.Year})">
<img src="${poster}" alt="Poster: ${escapar(filme.Title)}" loading="lazy" />
<div class="filme-card__info">
<h3 class="filme-card__titulo">${escapar(filme.Title)}</h3>
<span class="filme-card__ano">${filme.Year}</span>
<span class="filme-card__tipo">${traduzirTipo(filme.Type)}</span>
</div>
</article>
`;
}
function traduzirTipo(tipo) {
return { movie: 'Filme', series: 'Série', episode: 'Episódio' }[tipo] || tipo;
}
function gerarSkeletons(n, classe) {
return `<div class="grade-skeleton">${
Array(n).fill(`<div class="skeleton ${classe}"></div>`).join('')
}</div>`;
}
17.5 — Boas práticas e próximos passos¶
Vídeo curto explicativo (link será adicionado posteriormente)
17.5.1 — Separando camada de serviços da camada de UI¶
// services/produtos.js — camada de serviços isolada
import { buscarDados, construirUrl } from './api.js';
const BASE = 'https://fakestoreapi.com';
export default class ProdutosService {
static async listar(params = {}) {
const url = construirUrl(`${BASE}/products`, params);
const dados = await buscarDados(url);
// Transformação: adapta o formato da API ao formato interno da app
return {
dados: dados.map(normalizarProduto),
total: dados.length,
porPagina: params.limit || dados.length
};
}
static async buscarPorId(id) {
const dados = await buscarDados(`${BASE}/products/${id}`);
return normalizarProduto(dados);
}
static async listarCategorias() {
return buscarDados(`${BASE}/products/categories`);
}
}
// Normalização: garante que os dados têm a forma que a UI espera
// independentemente de mudanças na API
function normalizarProduto(p) {
return {
id: p.id,
nome: p.title, // 'title' na API → 'nome' na app
descricao: p.description,
preco: p.price,
imagem: p.image,
categoria: p.category,
avaliacao: p.rating?.rate ?? 0,
totalAvaliacoes: p.rating?.count ?? 0,
estoque: Math.floor(Math.random() * 50) + 1 // simulado
};
}
17.5.2 — Variáveis de ambiente e segurança de chaves de API¶
// ⚠️ NUNCA expor chaves de API sensíveis no frontend
// Todo código JavaScript enviado ao browser é público e legível
// Chaves de APIs PÚBLICAS (leitura apenas, sem permissões perigosas)
// podem ser expostas no frontend com precaução:
const CONFIG = {
OMDB_KEY: 'abc123', // apenas leitura, sem risco financeiro
MAPS_KEY: 'xyz789', // restringir por domínio no painel da API
WEATHER_KEY: 'def456',
};
// Chaves com permissões de escrita ou acesso a dados sensíveis
// NUNCA devem estar no frontend — usar proxy no backend:
// Frontend → Seu Backend → API externa (com chave segura)
// Para projetos acadêmicos: usar variáveis em arquivo de configuração
// que não é commitado no git
// config.js (no .gitignore):
export const API_KEY = 'SUA_CHAVE_AQUI';
// config.example.js (versionado):
export const API_KEY = 'SUBSTITUA_PELA_SUA_CHAVE';
// .gitignore:
// config.js
// .env
17.5.3 — O que vem depois: frameworks modernos¶
Com os conceitos deste capítulo dominados — módulos, serviços, roteamento, estado, renderização dinâmica —, a transição para frameworks modernos é natural. Eles resolvem os mesmos problemas com mais elegância e produtividade:
| Conceito (Vanilla JS) | React | Vue | Angular |
|---|---|---|---|
| Template literals | JSX | Template syntax | Template syntax |
createElement manual |
Componentes | Componentes | Componentes |
store.js customizado |
useState / Redux | Pinia / Vuex | NgRx / Services |
Router customizado |
React Router | Vue Router | Angular Router |
ProdutosService |
hooks customizados | Composables | Services |
EventEmitter customizado |
Context API | emit/props | EventEmitter |
O código escrito neste capítulo não é descartado ao aprender um framework — ele expõe os fundamentos que os frameworks abstraem. Desenvolvedores que aprendem React sem entender o DOM e o ciclo de renderização manual têm dificuldade em depurar problemas reais. A jornada deste curso foi deliberada.
Referências: - MDN — JavaScript Modules - MDN — History API - OMDb API - Fake Store API
Atividades — Capítulo 17¶
1. Por que é importante separar a camada de serviços da camada de UI?
2. O que é uma race condition em buscas dinâmicas e como o AbortController resolve esse problema?
3. Qual é a vantagem de normalizar os dados da API em uma camada de serviços antes de passá-los para a UI?
- GitHub Classroom: Construir um catálogo de filmes com a OMDb API (ou JSONPlaceholder como alternativa) que implemente: roteamento por hash com telas de listagem e detalhe, busca dinâmica com debounce e AbortController, paginação funcional, todos os estados de UI (carregando, sucesso, erro, vazio) e camada de serviços separada da UI. (link será adicionado)
:material-arrow-left: Voltar ao Capítulo 16 — Consumo de APIs :material-arrow-right: Ir ao Capítulo 18 — Projeto Final