Explicar o que é o Vue 3, o que significa "framework progressivo" e a diferença entre Options API e Composition API.
Criar uma instância Vue via CDN com createApp e entender o ciclo montar/renderizar.
Criar um projeto Vue com Vite usando npm create vue@latest e explicar cada arquivo gerado.
Distinguir ref() de reactive() e explicar por que .value existe.
Usar as diretivas v-bind, v-on, v-model, v-if/v-else-if/v-else, v-show, v-for (com :key) e v-text/v-html corretamente, cada uma com seus casos de uso e armadilhas.
Descrever as fases do ciclo de vida de um componente e usar os hooks onMounted e onUnmounted.
Construir a primeira versão navegável do UniEventos: lista, busca, filtro e inscrição.
Na Aula 01 revisamos o JavaScript moderno que o Vue exige o tempo todo — const/let, arrow functions, desestruturação, spread, map/filter/reduce, módulos ES e async/await. Também montamos, à mão, uma pequena lista de eventos manipulando o DOM com document.createElement e innerHTML, e comparamos esse estilo imperativo com o estilo declarativo, em que você descreve o que a tela deve mostrar e o framework cuida do como.
Hoje esse estilo declarativo ganha nome, ferramenta e projeto: Vue 3, criado com Vite, com a primeira versão navegável do UniEventos no fim da aula. Tudo o que você escreveu na Aula 01 continua valendo — o Vue não substitui o JavaScript, ele organiza o JavaScript que você já sabe.
Ambiente instalado na Aula 01: Node 22 LTS, VS Code com Vue - Official/ESLint/Prettier, Git.
Repositório do projeto autoral criado com README.md.
⚠️ Atenção
Verifique agora, antes de começar: node -v precisa mostrar uma versão 22.18.0 ou superior (ou 24.12.0+). O create-vue desta aula exige isso.
Vue é um framework progressivo para construir interfaces de usuário. "Progressivo" significa que você pode adotá-lo aos poucos: usar só para uma parte reativa de uma página HTML existente (como faremos daqui a pouco, via CDN) ou para uma aplicação inteira, com build, roteamento e gerenciamento de estado (como faremos a partir de hoje mesmo, com Vite).
O ecossistema Vue que usaremos no semestre:
Peça
Papel
Quando entra
Vue 3 (core)
Reatividade + renderização de componentes
Hoje
Vite
Servidor de desenvolvimento e bundler
Hoje
Vue Router
Navegação entre "páginas" da SPA
Aula 04
Pinia
Estado compartilhado entre componentes
Aula 06
Vuetify
Biblioteca de componentes visuais prontos (Material Design)
Aula 04
Axios
Cliente HTTP para consumir APIs
Aula 06
Vamos usar a versão 3.5.41 do Vue, instalada via Vite 8.2.1 com o plugin @vitejs/plugin-vue 6.0.8 — as versões fixadas para esta trilha, as mesmas em que todos os exemplos deste material foram testados.
🧠 Você sabia?
O Vue foi criado por Evan You em 2014, um ex-funcionário do Google que trabalhava com AngularJS e queria algo mais leve para prototipar interfaces rapidamente. A ideia deu tão certo que hoje o Vue é mantido por uma organização independente (a Vue.js), financiada por patrocinadores e por uma comunidade global — sem estar amarrado a nenhuma big tech, diferente do React (Meta) ou do Angular (Google). É por isso que a documentação oficial é, historicamente, uma das mais elogiadas do ecossistema JavaScript: escrever documentação clara sempre foi parte da estratégia de adoção do projeto.
O Vue 3 oferece duas formas de escrever a lógica de um componente. Elas produzem o mesmo resultado; mudam a organização do código.
Options API — organiza o componente em "opções" fixas: data() (estado), methods (funções), computed (Aula 03), mounted() (ciclo de vida) etc. É o estilo herdado do Vue 2, ainda muito usado e citado no plano de curso.
JavaScript
// Options API — cada preocupação vai em uma "caixa" pré-definidaexportdefault{data(){return{contador:0,}},methods:{incrementar(){this.contador++},},mounted(){console.log('componente montado, contador =',this.contador)},}
Composition API — organiza o componente por funções que você importa e compõe livremente (ref, reactive, onMounted...), agrupando por funcionalidade em vez de por tipo de opção. É o padrão do Vue 3 moderno e o que o create-vue gera por padrão, dentro da sintaxe açucarada <script setup>.
Vue SFC
<script setup>
// Composition API com <script setup> — tudo neste bloco já é
// automaticamente exposto ao <template>, sem "return" manual
import { ref, onMounted } from 'vue'
const contador = ref(0)
function incrementar() {
contador.value++
}
onMounted(() => {
console.log('componente montado, contador =', contador.value)
})
</script>
📌 Vale gravarEsta trilha usa Composition API com <script setup> do início ao fim, porque é o padrão gerado pelo create-vue e o que você vai encontrar em qualquer projeto Vue 3 novo. Nos primeiros exemplos de hoje mostramos o equivalente em Options API lado a lado — vale reconhecer os dois estilos, já que você vai encontrá-los em código real — mas a partir da Aula 03 falamos só Composition API.
Antes de qualquer ferramenta de build, vamos ver o Vue rodando com o mínimo possível: um único arquivo HTML.
HTML
<!-- cdn/index.html --><!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"/><title>Primeiro contato com Vue</title></head><body><divid="app"><h1>{{ titulo }}</h1><p>Você clicou {{ contador }} vez(es).</p><buttonv-on:click="incrementar">Clicar</button></div><scriptsrc="https://unpkg.com/vue@3.5.41/dist/vue.global.js"></script><script>const{createApp}=VuecreateApp({data(){return{titulo:'Olá, Vue!',contador:0,}},methods:{incrementar(){this.contador++},},}).mount('#app')</script></body></html>
Abra este arquivo direto no navegador (funciona com file://, sem precisar de servidor, porque não há módulos ES aqui). Três ideias novas:
createApp({...}) recebe um objeto de configuração — no estilo Options API — e devolve uma instância da aplicação Vue.
.mount('#app') diz ao Vue: "assuma o controle deste elemento do DOM e tudo dentro dele". A partir daqui, o Vue passa a gerenciar esse pedaço de página.
{{ titulo }} é interpolação de texto: insere o valor da variável reativa titulo no HTML. Sempre que titulo muda, o texto na tela muda sozinho — sem innerHTML, sem addEventListener manual.
🔎 Por baixo do capô{{ }} só funciona dentro do elemento montado (#app e seus descendentes). Fora dele, o Vue nem olha para o HTML — por isso o <h1> do exemplo fica dentro de <div id="app">.
O mesmo exemplo, agora em <script setup> (o estilo que usaremos a partir de agora), ainda via CDN mas em módulo ES:
Note que aqui, sem <script setup> (que só existe dentro de arquivos .vue compilados pelo Vite), precisamos declarar setup() manualmente e retornar o que o template usa. É exatamente esse return que o <script setup> elimina automaticamente — daí o nome "açúcar sintático".
CDN é ótimo para aprender o conceito, mas nenhuma aplicação real desta trilha vai ser um único HTML. A partir de agora usamos Vite com Single File Components (.vue).
O create-vue (versão 3.23.0) pergunta interativamente o nome do projeto e quais recursos incluir. Para o UniEventos que vamos construir ao longo da trilha, as respostas são:
Texto
✔ Project name: … unieventos-web
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
💡 Dica
Já habilitamos --router e --pinia mesmo sem usá-los ainda — eles só entram em cena nas Aulas 04 e 06, mas evita reconfigurar o projeto depois. Os arquivos que eles geram (src/router/index.js, src/stores/counter.js) ficam parados até lá.
Compare com o createApp(...).mount('#app') do exemplo CDN: é a mesma API. A diferença é que aqui App vem de um componente .vue importado, e app.use(...) registra plugins (Pinia, Router) que ainda não vamos usar hoje.
Rode o projeto:
Terminal
npmrundev
O Vite sobe um servidor local (normalmente http://localhost:5173) com hot module replacement: você edita um arquivo .vue e a tela atualiza sozinha, sem recarregar a página inteira.
<script setup> — lógica do componente em Composition API. Tudo declarado aqui (variáveis, funções) fica automaticamente disponível no <template>, sem return explícito — é o compilador de SFC do Vue, via @vitejs/plugin-vue, que escreve esse return implícito por você na hora do build.
<template> — o HTML do componente, com as diretivas do Vue.
<style scoped> — CSS que se aplica somente a este componente (o Vue adiciona um atributo único a cada elemento na hora do build, isolando o CSS). Sem scoped, o estilo vaza para a aplicação inteira.
⚠️ Atenção<script setup> só existe dentro de arquivos .vue processados pelo Vite — não existe fora desse contexto. Por isso o exemplo CDN da Seção 2 usou setup() { return {...} } explícito.
<script setup>
import { ref } from 'vue'
// ref() envolve um valor primitivo (ou qualquer valor) em um objeto reativo
const contadorVagas = ref(40)
function reduzirVaga() {
// DENTRO do <script>, é preciso acessar/alterar via .value
contadorVagas.value--
console.log(contadorVagas.value)
}
</script>
<template>
<!-- NO <template>, o Vue "desembrulha" automaticamente — sem .value -->
<p>Vagas: {{ contadorVagas }}</p>
<button @click="reduzirVaga">Inscrever</button>
</template>
🔎 Por baixo do capô
Um valor primitivo em JavaScript (number, string, boolean) não tem como "avisar" ninguém quando muda — não há como interceptar contador = contador + 1 para uma variável solta. ref() resolve isso guardando o valor dentro de um objeto ({ value: 40 }) e tornando esse objeto reativo. É por isso que, no script, você sempre acessa .value. No <template>, o compilador do Vue já sabe que uma variável vinda de ref() precisa ser desembrulhada e faz isso por você automaticamente.
<script setup>
import { reactive } from 'vue'
// reactive() torna um OBJETO inteiro reativo, sem precisar de .value
const evento = reactive({
titulo: 'Semana da Computação',
vagas: 40,
inscritos: 12,
})
function inscrever() {
// acesso direto às propriedades, sem .value
evento.inscritos++
}
</script>
<template>
<p>{{ evento.titulo }}: {{ evento.inscritos }}/{{ evento.vagas }}</p>
<button @click="inscrever">Inscrever</button>
</template>
🔎 Por baixo do capôreactive() usa um Proxy do JavaScript (recurso nativo do ES2015) para interceptar leituras e escritas nas propriedades do objeto. Toda vez que você lê evento.titulo, o Proxy registra "alguém depende disso"; toda vez que você escreve evento.inscritos = ..., o Proxy avisa "isso mudou, quem depende precisa atualizar". Vamos detalhar esse mecanismo na Aula 03, no box de padrão de projeto Proxy.
Objeto ou array com várias propriedades relacionadas
reactive() (ou ref() também funciona para objetos — é uma escolha de estilo)
Precisa substituir o valor inteiro depois (ex.: eventos = novaLista)
ref() — reactive() não permite reatribuir a variável inteira sem perder a reatividade
Vue SFC
<script setup>
import { ref } from 'vue'
// Para uma LISTA que será recarregada inteira (ex.: vinda de uma API),
// ref() é mais seguro: dá para trocar o array inteiro sem perder reatividade.
const eventos = ref([])
async function carregarEventos() {
eventos.value = [
{ id: 1, titulo: 'Semana da Computação' },
{ id: 2, titulo: 'Oficina de Vue.js' },
]
}
</script>
⚠️ Atenção
Se você usasse reactive([]) e depois tentasse eventos = [...] (reatribuir a variável), perderia a conexão reativa — o template continuaria olhando para o array antigo. Com reactive, mude o conteúdo (eventos.push(...), eventos.splice(...)), nunca a referência.
Equivalência com Options API — o data() que você viu na Seção 1.1 usa reatividade automática em tudo que ele retorna, sem você escolher entre ref e reactive:
JavaScript
// Options API — Vue decide a reatividade por trás das cortinasexportdefault{data(){return{contadorVagas:40,// equivale a um refevento:{titulo:'Semana da Computação',inscritos:12},// equivale a um reactive}},methods:{inscrever(){this.evento.inscritos++// this.<propriedade>, sem .value},},}
<script setup>
import { ref } from 'vue'
const evento = ref({
titulo: 'Oficina de Vue.js',
imagemUrl: '/img/oficina-vue.jpg',
linkInativo: true,
})
</script>
<template>
<!-- forma completa -->
<img v-bind:src="evento.imagemUrl" v-bind:alt="evento.titulo" />
<!-- atalho ":" — é o que se usa no dia a dia -->
<img :src="evento.imagemUrl" :alt="evento.titulo" />
<!-- vinculando um atributo booleano -->
<button :disabled="evento.linkInativo">Ver detalhes</button>
</template>
v-bind conecta um atributo do HTML a uma expressão JavaScript reativa. Sem ele, src="evento.imagemUrl" seria só o texto literal "evento.imagemUrl" — não avaliaria a expressão.
<script setup>
import { ref } from 'vue'
const contador = ref(0)
function incrementar() {
contador.value++
}
function tratarEnvio() {
console.log('formulário enviado, sem recarregar a página')
}
</script>
<template>
<!-- forma completa -->
<button v-on:click="incrementar">+1</button>
<!-- atalho "@" — o que se usa no dia a dia -->
<button @click="incrementar">+1</button>
<!-- modificador .prevent: chama event.preventDefault() automaticamente -->
<form @submit.prevent="tratarEnvio">
<button type="submit">Enviar</button>
</form>
<!-- modificador .stop: chama event.stopPropagation() -->
<div @click="console.log('clique no pai')">
<button @click.stop="console.log('clique só no botão')">Não propaga</button>
</div>
<!-- modificador .once: o handler roda só na primeira vez -->
<button @click.once="console.log('só uma vez')">Clique único</button>
<!-- modificador de tecla: só dispara com Enter -->
<input @keyup.enter="incrementar" placeholder="Pressione Enter" />
</template>
5.3 v-model — vinculação bidirecional em formulários¶
v-model é açúcar sintático que combina v-bind (mostra o valor) com v-on (atualiza o valor a cada mudança), poupando você de escrever os dois manualmente.
Vue SFC
<script setup>
import { ref } from 'vue'
const busca = ref('')
const observacoes = ref('')
const aceitaTermos = ref(false)
const categoriasEscolhidas = ref([])
const categoriaSelecionada = ref('palestra')
const email = ref('')
const vagas = ref(0)
</script>
<template>
<!-- input de texto -->
<input v-model="busca" type="text" placeholder="Buscar evento..." />
<p>Buscando por: {{ busca }}</p>
<!-- textarea -->
<textarea v-model="observacoes" placeholder="Observações"></textarea>
<!-- checkbox único: liga a uma variável boolean -->
<label>
<input v-model="aceitaTermos" type="checkbox" />
Aceito os termos
</label>
<!-- vários checkboxes: liga a um array — cada "value" marcado entra no array -->
<label><input v-model="categoriasEscolhidas" type="checkbox" value="palestra" /> Palestra</label>
<label><input v-model="categoriasEscolhidas" type="checkbox" value="minicurso" /> Minicurso</label>
<p>Selecionadas: {{ categoriasEscolhidas }}</p>
<!-- radio: só um valor por grupo de "name" implícito pelo v-model -->
<label><input v-model="categoriaSelecionada" type="radio" value="palestra" /> Palestra</label>
<label><input v-model="categoriaSelecionada" type="radio" value="workshop" /> Workshop</label>
<!-- select -->
<select v-model="categoriaSelecionada">
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
<!-- modificadores -->
<!-- .trim: remove espaços das pontas automaticamente -->
<input v-model.trim="email" type="email" placeholder="seu@email.com" />
<!-- .number: converte o valor digitado para Number — repare no ref próprio, numérico -->
<input v-model.number="vagas" type="number" />
<p>Vagas (tipo): {{ typeof vagas }}</p>
<!-- .lazy: sincroniza no evento "change" (ao sair do campo), não a cada tecla -->
<input v-model.lazy="busca" type="text" />
</template>
💡 Dicav-model é o par perfeito para o formulário de inscrição do UniEventos que vamos montar hoje: o valor do campo de busca já fica disponível como variável reativa, sem escrever um único addEventListener.
<script setup>
import { ref } from 'vue'
const mostrarDetalhes = ref(false)
</script>
<template>
<button @click="mostrarDetalhes = !mostrarDetalhes">Alternar detalhes</button>
<!-- o elemento SEMPRE existe no DOM; v-show só alterna display: none -->
<div v-show="mostrarDetalhes">
<p>Estes são os detalhes completos do evento.</p>
</div>
</template>
v-if
v-show
Como funciona
Remove/insere o elemento no DOM
Alterna display: none via CSS
Custo de alternar
Mais caro (recria o elemento)
Mais barato (só troca CSS)
Custo inicial se falso
Mais barato (nem renderiza)
Mais caro (sempre renderiza)
Quando usar
Condição muda raramente
Condição alterna com frequência (ex.: abrir/fechar painel)
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra' },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso' },
{ id: 3, titulo: 'Hackathon de Tecnologia', categoria: 'workshop' },
])
</script>
<template>
<ul>
<!-- CORRETO: key única e estável (o id do dado, nunca o índice) -->
<li v-for="evento in eventos" :key="evento.id">
{{ evento.titulo }} — {{ evento.categoria }}
</li>
</ul>
<!-- v-for também expõe o índice, como segundo parâmetro -->
<ol>
<li v-for="(evento, indice) in eventos" :key="evento.id">
{{ indice + 1 }}. {{ evento.titulo }}
</li>
</ol>
</template>
O bug do índice como :key:
Vue SFC
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação' },
{ id: 2, titulo: 'Oficina de Vue.js' },
{ id: 3, titulo: 'Hackathon de Tecnologia' },
])
function removerPrimeiro() {
eventos.value.shift() // remove o item do início
}
</script>
<template>
<!-- ERRADO: usar o índice como key -->
<div v-for="(evento, indice) in eventos" :key="indice">
<input type="checkbox" /> {{ evento.titulo }}
</div>
<button @click="removerPrimeiro">Remover o primeiro</button>
</template>
Se cada <input type="checkbox"> tiver estado próprio (marcado por quem interage) e você remover o primeiro item da lista, o Vue reaproveitará os elementos DOM pelo índice: o segundo item (índice 0 agora) herda o checkbox que estava marcado no antigo primeiro item, mesmo sendo um dado diferente. O texto atualiza corretamente, mas o estado interno do elemento (checkbox marcado, valor de input, foco) fica errado, porque o Vue pensa que é "o mesmo elemento" da posição 0.
⚠️ Atenção
Use sempre um identificador estável e único do dado (evento.id) como :key, nunca o índice do v-for. O índice muda quando a lista é reordenada, filtrada ou tem itens removidos — e o Vue usa a key exatamente para saber "isso é o mesmo item de antes ou é outro?".
🔬 Investigue
Rode o exemplo do bug acima (com :key="indice") no navegador. Abra a aba Elements do DevTools, marque o checkbox do primeiro item e clique em "Remover o primeiro". Observe qual <input> do DOM continua marcado — o do texto que você via na tela, ou o que ficou na mesma posição? Agora troque :key="indice" de volta para :key="evento.id", repita o teste e compare o <div> que o Vue recria (ou não) na aba Elements a cada clique.
<script setup>
import { ref } from 'vue'
const descricaoSimples = ref('Evento sobre Vue.js')
const descricaoComHtml = ref('<strong>Evento</strong> sobre Vue.js')
</script>
<template>
<!-- v-text é equivalente a {{ }}, mas substitui o conteúdo inteiro do elemento -->
<p v-text="descricaoSimples"></p>
<!-- interpolação normal: sempre trata o conteúdo como TEXTO puro (escapa HTML) -->
<p>{{ descricaoComHtml }}</p>
<!-- renderiza literalmente: <strong>Evento</strong> sobre Vue.js -->
<!-- v-html: injeta HTML de verdade, interpretado pelo navegador -->
<p v-html="descricaoComHtml"></p>
<!-- renderiza: Evento (em negrito) sobre Vue.js -->
</template>
⚠️ Atenção — risco de XSSv-html injeta HTML bruto na página, sem escapar. Se o conteúdo vier de um usuário (comentário, campo de formulário, dado de API não confiável) e contiver <script> ou atributos como onerror=, isso executa no navegador de quem visualiza a página — um ataque de Cross-Site Scripting (XSS). Use v-html só com conteúdo que você controla (texto formatado vindo do seu próprio backend, sanitizado). Para exibir texto de usuário, use interpolação {{ }} normal, que sempre escapa.
Todo componente Vue passa por fases previsíveis, do momento em que é criado até ser destruído.
Texto
criação do componente
│
▼
onBeforeMount() ← ainda não existe no DOM real
│
▼
[Vue monta o componente no DOM]
│
▼
onMounted() ← já existe no DOM, pode acessar elementos, disparar fetch
│
▼
(o componente vive aqui — reage a mudanças de estado)
│
├──► dado reativo muda
│ │
│ ▼
│ onBeforeUpdate() ← estado já mudou, DOM ainda não
│ │
│ ▼
│ [Vue re-renderiza o DOM]
│ │
│ ▼
│ onUpdated() ← DOM já reflete o novo estado
│ │
│ └──► volta a "viver" aqui
│
▼
onBeforeUnmount() ← componente prestes a ser removido
│
▼
[Vue remove o componente do DOM]
│
▼
onUnmounted() ← já foi removido; hora de limpar recursos
Vue SFC
<!-- src/components/DemoCicloDeVida.vue -->
<script setup>
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'
const segundos = ref(0)
let intervaloId = null
onBeforeMount(() => {
console.log('[ciclo] onBeforeMount — ainda não está no DOM')
})
onMounted(() => {
console.log('[ciclo] onMounted — já está no DOM, iniciando o relógio')
// setInterval é um recurso "externo" ao Vue — precisa ser limpo manualmente
intervaloId = setInterval(() => {
segundos.value++
}, 1000)
})
onBeforeUpdate(() => {
console.log('[ciclo] onBeforeUpdate — segundos mudou para', segundos.value, 'mas o DOM ainda não')
})
onUpdated(() => {
console.log('[ciclo] onUpdated — DOM já mostra', segundos.value)
})
onBeforeUnmount(() => {
console.log('[ciclo] onBeforeUnmount — componente prestes a sumir')
})
onUnmounted(() => {
console.log('[ciclo] onUnmounted — limpando o setInterval')
// ESSENCIAL: sem isso, o timer continua rodando mesmo após o
// componente sumir da tela — um vazamento de memória clássico.
clearInterval(intervaloId)
})
</script>
<template>
<p>Segundos desde a montagem: {{ segundos }}</p>
</template>
Equivalência com Options API:
Composition API
Options API
onBeforeMount
beforeMount()
onMounted
mounted()
onBeforeUpdate
beforeUpdate()
onUpdated
updated()
onBeforeUnmount
beforeUnmount()
onUnmounted
unmounted()
JavaScript
// Options API — os mesmos hooks, como métodos especiais do objetoexportdefault{data(){return{segundos:0,intervaloId:null}},mounted(){console.log('mounted')this.intervaloId=setInterval(()=>{this.segundos++},1000)},unmounted(){clearInterval(this.intervaloId)},}
📌 Vale gravaronMounted é, de longe, o hook mais usado na prática — é onde disparamos requisições fetch (Aula 03) porque é o primeiro momento em que temos garantia de que o DOM existe. onUnmounted é onde limpamos qualquer recurso externo (setInterval, addEventListener em window, conexões abertas) para não vazar memória quando o componente sai de cena.
🧩 Padrão de projeto em uso — Observer (comportamental)¶
O padrão Observer define uma relação um-para-muitos entre um objeto (o subject, que muda de estado) e vários observers, que são notificados automaticamente sempre que o subject muda — sem que o subject precise conhecer os observers individualmente.
Um Observer "na mão", em JavaScript puro:
JavaScript
// Um EventTarget simplificado — a base do Observer em JS puroclassContadorObservavel{constructor(){this.valor=0this.observadores=[]}observar(funcaoCallback){this.observadores.push(funcaoCallback)}incrementar(){this.valor++// notifica TODOS os observadores registradosthis.observadores.forEach((callback)=>callback(this.valor))}}constcontador=newContadorObservavel()contador.observar((valor)=>console.log('UI A atualizada:',valor))contador.observar((valor)=>console.log('UI B atualizada:',valor))contador.incrementar()// dispara os dois observadores
É exatamente isso que o sistema de reatividade do Vue faz por baixo dos panos. Quando você escreve {{ contador }} no template, o Vue registra esse trecho do DOM como um "observador" da variável contador. Quando você escreve contador.value++, o Vue percorre a lista de observadores daquela variável (os pedaços de template que a usam) e re-renderiza só eles — sem você escrever observar() ou notificar() manualmente. ref e reactive são, na essência, subjects observáveis; cada trecho do template que os lê vira, automaticamente, um observer. Vamos abrir esse mecanismo com mais detalhe na Aula 03, quando falarmos do padrão Proxy.
Vamos construir, dentro do projeto unieventos-web criado na Seção 3, a primeira tela funcional: lista de eventos com busca, filtro por categoria e inscrição.
Passo 1 — dados de exemplo. Crie um arquivo separado só com os dados, para manter o componente organizado (o mesmo raciocínio do módulo eventos.js da Aula 01).
JavaScript
// src/data/eventos.jsexportconsteventosIniciais=[{id:1,titulo:'Semana da Computação',categoria:'palestra',dataHora:'2030-09-10T19:00:00',local:'Auditório Central',vagas:40,inscritos:12,},{id:2,titulo:'Oficina de Vue.js',categoria:'minicurso',dataHora:'2030-08-20T14:00:00',local:'Laboratório 3',vagas:25,inscritos:25,},{id:3,titulo:'Hackathon de Tecnologia',categoria:'workshop',dataHora:'2030-10-05T08:00:00',local:'Bloco B',vagas:60,inscritos:18,},{id:4,titulo:'Introdução a IA',categoria:'palestra',dataHora:'2030-08-18T19:30:00',local:'Auditório Central',vagas:80,inscritos:55,},]
Passo 2 — o componente principal. Ainda usamos filter "na mão" dentro de uma função (vamos trocar por computed, que faz cache, na Aula 03 — por hoje o objetivo é praticar diretivas).
⚠️ Atenção
Repare que obterEventosFiltrados() é chamada três vezes no template (na v-for, e de novo para checar se está vazio). Cada chamada refaz o filter duas vezes do zero — funciona, mas é desperdício de processamento e, pior, dificulta manter os resultados sincronizados. Vamos resolver isso com computed() já na próxima aula.
Abra http://localhost:5173 e confira os cinco comportamentos:
Os quatro eventos aparecem na tela, cada card com título, categoria e vagas restantes.
Digitar "vue" no campo de busca reduz a lista enquanto você digita, sem apertar nada.
Trocar o filtro de categoria combina com a busca (os dois critérios valem ao mesmo tempo).
Clicar em "Inscrever-se" aumenta inscritos em 1 e as vagas restantes caem na hora; quando chegam a zero, o card mostra "Evento lotado" e o botão fica desabilitado.
Uma busca sem resultado mostra a mensagem de lista vazia, e não uma área em branco.
Resultado esperado: os cinco funcionam sem uma única linha de document.querySelector — é o ponto da aula. Se a tela não reagir a um clique, o suspeito nº 1 é um .value esquecido dentro do <script setup> (no template ele é automático; no script, não).
Resultado esperado: no script, console.log(contadorVagas) imprime o objeto ref inteiro (algo como RefImpl { value: 40, ... }), não o número puro — porque fora do <template> o Vue não desembrulha automaticamente. Na tela aparece Vagas: 40, porque o compilador do template desembrulha refs de nível superior sozinho.
A2. Complete a linha que falta para que o aviso "Últimas vagas!" apareça só quando restarem de 1 a 5 vagas (nem lotado, nem mais de 5):
A3. Em uma frase: por que v-show é mais indicado que v-if para um painel de filtros avançados que o usuário abre e fecha várias vezes na mesma visita à página?
Resultado esperado: porque v-show só alterna display: none via CSS (barato de alternar repetidamente), enquanto v-if recria o elemento inteiro no DOM a cada troca — mais caro quando a alternância é frequente.
A4. Ache o erro nas linhas abaixo — depois de chamar recarregar(), o <template> não mostra a nova lista de eventos:
Resultado esperado: reactive() torna o conteúdo do array reativo, mas reatribuir a variável inteira (eventos = [...]) quebra a conexão — o template continua olhando para o array antigo, agora sem ninguém apontando para ele. (Se você escrever const eventos = reactive([]), como é o mais comum, o sintoma nem chega a ser esse: a própria reatribuição estoura TypeError: Assignment to constant variable. O let acima existe para o bug aparecer em silêncio, que é o caso difícil de achar.) A correção troca reactive([]) por ref([]) e usa eventos.value = [...], ou mantém reactive e faz eventos.splice(0, eventos.length, ...novaLista) para trocar o conteúdo sem trocar a referência.
A5. Preveja o que aparece na tela para cada linha abaixo, usando o que a Seção 5.7 explica sobre v-html:
Resultado esperado: a primeira linha mostra o texto literal <em>promoção</em> hoje (a interpolação {{ }} sempre escapa HTML); a segunda mostra a palavra "promoção" em itálico, porque v-html interpreta as tags como HTML de verdade.
B1. Contador de inscritos totais. Adicione, logo abaixo do <h1>, um parágrafo mostrando quantas pessoas estão inscritas somando todos os eventos, usando reduce (Aula 01).
Resultado esperado: um número que aumenta a cada clique em "Inscrever-se".
Dica
eventos.value.reduce((total, evento) => total + evento.inscritos, 0) dentro de uma função chamada no template, ou direto em uma expressão de interpolação.
B2. Botão de limpar filtros. Adicione um botão que zera busca e categoriaFiltro de uma vez.
Resultado esperado: clicar no botão limpa o campo de texto e volta o select para "Todas as categorias".
B3. Destacar evento quase lotado com v-show. Mostre um aviso "Últimas vagas!" com v-show (não v-if) quando vagasRestantes(evento) <= 5 && vagasRestantes(evento) > 0.
Resultado esperado: o aviso aparece/some conforme inscrições, sem recriar o elemento no DOM (confira no DevTools, aba Elements).
B4. Modificador .once em uma mensagem de boas-vindas. Adicione um botão "Ver dica" que mostra um alerta apenas na primeira vez que for clicado, usando @click.once.
Resultado esperado: cliques seguintes não fazem nada.
Dica
O template só enxerga o que está declarado no <script setup> — globais do navegador como alertnão estão nessa lista, e @click.once="alert('...')" compila para _ctx.alert(...), quebrando com alert is not a function. Declare a função no script e passe o nome dela:
Vue SFC
<script setup>
function mostrarDica() {
alert('Dica: use os filtros para encontrar eventos mais rápido!')
}
</script>
<template>
<button @click.once="mostrarDica">Ver dica</button>
</template>
C1. Corrigir uma :key proposital. Troque temporariamente :key="evento.id" por :key="indiceDoLoop" (usando a forma v-for="(evento, indiceDoLoop) in ..."), adicione um <input type="checkbox"> dentro de cada card, marque alguns, filtre por categoria e observe o comportamento estranho dos checkboxes. Depois desfaça a mudança e prove, na aba Elements do DevTools, que o elemento correto agora é reaproveitado pelo id, não pela posição.
Resultado esperado: você reproduz o bug descrito na Seção 5.6, documenta em uma frase por que ele acontece, e confirma que voltar para :key="evento.id" resolve — inclusive filtrando a lista com um checkbox marcado.
Dica
O bug aparece quando a lista filtrada muda de tamanho/ordem — os checkboxes "grudam" na posição do DOM, não no evento. Na aba Elements, observe qual <div> o Vue recria (ou não) a cada filtro, com cada uma das duas versões da :key.
O formulário de criação de evento do UniEventos usa <input v-model.number="vagas" type="text"> para o campo de vagas. Um colega testou digitando "quarenta" em vez de 40 — e o card passou a mostrar "NaN vaga(s) restante(s)" em vez de recusar a entrada. Descubra por que isso acontece e feche a brecha.
Critérios de pronto
Reproduzido: digitar um texto não numérico no campo de vagas e confirmar, no Console, que o valor vira NaN.
O formulário passa a rejeitar (ou impedir) uma entrada não numérica antes de criar o evento, com uma mensagem clara para quem está digitando.
Um comentário de 2 linhas no código explica por que v-model.number sozinho, em um <input type="text">, permite isso.
Testado que uma entrada numérica válida (ex.: 40) continua criando o evento normalmente.
Pistas
v-model.number tenta converter o texto digitado para Number — e Number('quarenta') não é um erro, é NaN, que passa despercebido por muitas condições (if (vagas) não pega NaN como você esperaria).
Compare <input type="number"> com <input type="text"> quanto ao que o próprio navegador já bloqueia de digitar.
Number.isNaN(valor) é o jeito certo de checar — nunca valor === NaN (essa comparação é sempre false, mesmo quando valor é NaN).
Guarde o mouse. Navegue pela tela de eventos construída hoje usando só Tab, Shift+Tab, Enter e as setas. Encontre pelo menos três barreiras de acessibilidade por teclado — foco que desaparece visualmente, campo sem rótulo associado, ordem de tabulação que não segue a leitura da tela — e corrija cada uma.
Critérios de pronto
Uma lista com pelo menos três barreiras encontradas, cada uma citando o elemento afetado (ex.: "select de categoria — sem <label for> associado").
Todo campo de formulário (input, select) tem um <label> associado, via for/id ou envolvendo o campo.
Um indicador visual de foco continua visível em todos os elementos interativos — se algum CSS tinha outline: none, ele ganhou um substituto (:focus-visible com contorno ou sombra) em vez de simplesmente remover o indicador.
A ordem de tabulação (Tab repetido a partir do topo) segue a ordem visual e lógica da página, sem saltos estranhos.
Pistas
No navegador, clique em qualquer lugar vazio da página e pressione Tab repetidamente a partir do topo — anote a ordem em que o foco se move.
Um <label> sem for correspondente ao id do campo (ou sem envolver o <input>) não é associado a ele — clicar no texto do rótulo não foca o campo, e leitores de tela não anunciam o rótulo certo.
A aba Lighthouse do DevTools tem uma categoria de acessibilidade que já aponta boa parte desses problemas automaticamente — rode antes de procurar na mão, para conferir depois se a correção resolveu.
⭐⭐⭐
⭐⭐⭐ A primeira tela navegável do seu projeto autoral¶
vueprojetojavascript
Aplique tudo desta aula — ref/reactive, v-bind/v-on/v-model, v-if/v-show, v-for com :key estável e um hook de ciclo de vida real — na entidade principal do seu projeto autoral, com a mesma profundidade da Seção "Mão na massa" de hoje.
Critérios de pronto
App.vue do seu projeto lista pelo menos 5 itens reais do seu domínio, com um campo de busca (v-model) e pelo menos um filtro (v-model em <select>).
v-for usa :key estável (o id do dado), nunca o índice do laço.
Um hook onMounted dispara algo real (ex.: um setInterval que atualiza um "atualizado há N segundos", ou um log de auditoria) e onUnmounted limpa esse recurso — comprovado no Console, sem erros nem timers acumulando.
Pelo menos uma ação de interação (@click) muda um estado reativo (reservar, favoritar, adicionar — o verbo do seu domínio) refletido imediatamente na tela.
Prints em sequência (ou um vídeo curto) mostrando: app carregado, busca funcionando, filtro funcionando, a ação de interação, e o Console com os logs do ciclo de vida.
Pistas
Reaproveite a estrutura do App.vue da Seção "Mão na massa" — troque eventos pelas entidades do seu domínio, campo por campo.
Para provar a limpeza do onUnmounted, o hot module replacement do Vite já desmonta/remonta o componente a cada edição salva — abra o Console, edite um espaço em branco no arquivo e observe os logs de onMounted/onUnmounted se alternando.
Não esqueça a :key com o id real do seu dado, nunca o índice do v-for — é o erro mais comum desta aula, e o item C1 do Laboratório mostra exatamente o que dá errado.
Próxima aula (03): aprofundamos v-for, resolvemos o antipadrão v-for + v-if juntos, introduzimos computed() (com cache de verdade) e usamos onMounted() para carregar dados de uma fonte assíncrona, com estados de carregando/erro/vazio.