Tema
Plantillas y directivas
Las plantillas de Vue son HTML con superpoderes. Con la interpolación (dobles llaves) muestras datos y con las directivas (atributos que empiezan con v-) enlazas atributos, muestras u ocultas elementos, repites listas, escuchas eventos y conectas formularios. Es lo que más usarás día a día.
Concepto
Una directiva es un atributo especial que le dice a Vue cómo debe comportarse un elemento según tus datos:
| Directiva | Atajo | Para qué |
|---|---|---|
v-bind:attr | :attr | Enlazar un atributo a un dato |
v-on:evento | @evento | Escuchar eventos |
v-if / v-else-if / v-else | Renderizar condicionalmente | |
v-show | Mostrar u ocultar con CSS | |
v-for | Repetir elementos | |
v-model | Enlace bidireccional en formularios | |
v-html | Insertar HTML (con cuidado) | |
v-once | Renderizar una sola vez |
Interpolación de texto
Las dobles llaves (sintaxis mustache) muestran el valor de una expresión de JavaScript como texto:
vue
<script setup>
import { ref } from 'vue'
const usuario = ref({ nombre: 'Ana López', edad: 28 })
const precio = ref(1499.5)
</script>
<template>
<p>Hola, {{ usuario.nombre }}</p>
<p>En 5 años tendrás {{ usuario.edad + 5 }} años</p>
<p>{{ usuario.edad >= 18 ? 'Mayor de edad' : 'Menor de edad' }}</p>
<p>Precio: {{ precio.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' }) }}</p>
<p>{{ usuario.nombre.split(' ')[0].toUpperCase() }}</p>
</template>Reglas:
- Sólo expresiones (algo que produce un valor): operaciones, ternarios, llamadas a funciones. No sentencias como
ifofor. - El contenido se escapa: si el texto contiene HTML, se muestra como texto (protección contra XSS).
- En la plantilla, los
refse desenvuelven solos: escribesusuario.nombre, nousuario.value.nombre.
v-bind: atributos dinámicos
vue
<script setup>
import { ref } from 'vue'
const imagen = ref('https://i.pravatar.cc/100?img=12')
const enlace = ref('https://vuejs.org')
const deshabilitado = ref(true)
const atributos = { id: 'principal', 'aria-label': 'Botón principal' }
</script>
<template>
<!-- Forma larga y atajo -->
<img v-bind:src="imagen" alt="Avatar">
<img :src="imagen" :alt="`Avatar número ${12}`" width="100">
<a :href="enlace" target="_blank" rel="noopener">Documentación</a>
<!-- Booleanos: si es false, el atributo no se agrega -->
<button :disabled="deshabilitado">Enviar</button>
<!-- Varios atributos a la vez desde un objeto -->
<button v-bind="atributos">Guardar</button>
<!-- Vue 3.4+: si la variable se llama igual que el atributo, puedes abreviar -->
<img :src alt="Abreviado"> <!-- equivale a :src="src" (si existiera una variable src) -->
</template>Error común
Escribir src="imagen" sin los dos puntos: el navegador buscará un archivo llamado literalmente "imagen". Con :src="imagen" Vue evalúa la variable.
Clases y estilos dinámicos
class y style aceptan objetos y arreglos:
vue
<script setup>
import { ref, computed } from 'vue'
const activo = ref(true)
const error = ref(false)
const tamaño = ref('grande')
const color = ref('#42b883')
const progreso = ref(65)
const clasesBoton = computed(() => ({
btn: true,
'btn-activo': activo.value,
'btn-error': error.value
}))
</script>
<template>
<!-- Objeto: la clase se aplica si el valor es verdadero -->
<div :class="{ activo: activo, 'con-error': error }">Objeto</div>
<!-- Arreglo: combina clases fijas y dinámicas -->
<div :class="['tarjeta', `tarjeta-${tamaño}`, { activo }]">Arreglo</div>
<!-- class normal y :class se combinan -->
<button class="base" :class="clasesBoton">Botón</button>
<!-- Estilos en línea: propiedades en camelCase o entre comillas -->
<div :style="{ color: color, fontSize: '18px', 'background-color': '#f0fdf4' }">Texto</div>
<!-- Valores calculados -->
<div class="barra">
<div class="relleno" :style="{ width: `${progreso}%` }"></div>
</div>
</template>
<style scoped>
.barra { height: 8px; background: #e5e7eb; border-radius: 99px; overflow: hidden; }
.relleno { height: 100%; background: #42b883; transition: width 0.4s; }
</style>v-on: eventos
vue
<script setup>
import { ref } from 'vue'
const contador = ref(0)
const mensaje = ref('')
function saludar(nombre) {
alert(`Hola, ${nombre}`)
}
function manejarEnvio() {
mensaje.value = 'Formulario enviado sin recargar la página'
}
</script>
<template>
<!-- Expresión en línea -->
<button @click="contador++">Clics: {{ contador }}</button>
<!-- Nombre de función -->
<button @click="manejarEnvio">Método</button>
<!-- Llamada con argumentos; $event es el objeto evento nativo -->
<button @click="saludar('Ana')">Saludar</button>
<input @input="mensaje = $event.target.value" placeholder="Escribe…">
<!-- Modificadores de evento -->
<form @submit.prevent="manejarEnvio"> <!-- preventDefault -->
<button>Enviar</button>
</form>
<div @click="contador = 0">
<button @click.stop="contador++">No reinicia</button> <!-- stopPropagation -->
</div>
<button @click.once="saludar('una vez')">Sólo una vez</button>
<!-- Modificadores de teclado -->
<input @keyup.enter="manejarEnvio" @keyup.esc="mensaje = ''" placeholder="Enter o Escape">
<input @keydown.ctrl.s.prevent="manejarEnvio" placeholder="Ctrl+S para guardar">
<p>{{ mensaje }}</p>
</template>Modificadores útiles: .prevent, .stop, .once, .self, .capture, .passive; teclas: .enter, .esc, .tab, .space, .up, .down, .delete, y combinaciones con .ctrl, .shift, .alt, .meta.
v-if, v-else y v-show
vue
<script setup>
import { ref } from 'vue'
const estado = ref('cargando') // 'cargando' | 'error' | 'listo'
const usuario = ref(null)
const menuAbierto = ref(false)
</script>
<template>
<!-- v-if: el elemento se crea o se destruye -->
<p v-if="estado === 'cargando'">Cargando…</p>
<p v-else-if="estado === 'error'" role="alert">Ocurrió un error</p>
<p v-else>¡Datos listos!</p>
<!-- <template> agrupa varios elementos sin agregar un contenedor al DOM -->
<template v-if="usuario">
<h2>{{ usuario.nombre }}</h2>
<p>{{ usuario.email }}</p>
</template>
<button v-else @click="usuario = { nombre: 'Ana', email: 'ana@correo.com' }">Iniciar sesión</button>
<!-- v-show: siempre está en el DOM; sólo cambia display: none -->
<button @click="menuAbierto = !menuAbierto">Menú</button>
<nav v-show="menuAbierto">…enlaces…</nav>
</template>v-if | v-show | |
|---|---|---|
| Qué hace | Crea/destruye el elemento | Cambia display |
| Costo inicial | Bajo si empieza en falso | Siempre renderiza |
| Costo al alternar | Más alto | Muy bajo |
| Úsalo para | Condiciones que cambian poco | Elementos que se alternan seguido (menús, pestañas) |
v-for: listas
vue
<script setup>
import { ref } from 'vue'
const productos = ref([
{ id: 1, nombre: 'Café de Chiapas', precio: 249, stock: 12 },
{ id: 2, nombre: 'Taza de barro', precio: 120, stock: 0 },
{ id: 3, nombre: 'Prensa francesa', precio: 549, stock: 4 }
])
const perfil = { nombre: 'Ana', ciudad: 'Puebla', rol: 'Admin' }
</script>
<template>
<!-- Arreglo: (elemento, índice) -->
<ul>
<li v-for="(p, i) in productos" :key="p.id">
{{ i + 1 }}. {{ p.nombre }} — ${{ p.precio }}
<span v-if="p.stock === 0" class="agotado">Agotado</span>
</li>
</ul>
<!-- Objeto: (valor, clave) -->
<dl>
<template v-for="(valor, clave) in perfil" :key="clave">
<dt>{{ clave }}</dt>
<dd>{{ valor }}</dd>
</template>
</dl>
<!-- Rango de números -->
<span v-for="n in 5" :key="n">⭐</span>
</template>La prop key
Siempre usa :key con un valor único y estable (el id), no el índice, si la lista puede reordenarse, filtrarse o editarse. Vue la usa para saber qué elemento es cuál al actualizar.
No combines v-if y v-for en el mismo elemento
En Vue 3, v-if se evalúa antes que v-for, así que no puede usar la variable del bucle. Filtra con una propiedad computada o pon el v-if en un elemento interno:
vue
<script setup>
import { computed } from 'vue'
// ...
const disponibles = computed(() => productos.value.filter((p) => p.stock > 0))
</script>
<template>
<li v-for="p in disponibles" :key="p.id">{{ p.nombre }}</li>
</template>v-model: formularios
v-model crea un enlace bidireccional: el input muestra el dato y, al escribir, actualiza el dato.
vue
<script setup>
import { ref } from 'vue'
const nombre = ref('')
const edad = ref(18)
const mensaje = ref('')
const pais = ref('mx')
const acepta = ref(false)
const envio = ref('normal')
const intereses = ref([])
const etiquetas = ref([])
</script>
<template>
<form @submit.prevent class="formulario">
<input v-model.trim="nombre" placeholder="Nombre"> <!-- .trim quita espacios -->
<input v-model.number="edad" type="number" min="0"> <!-- .number convierte a número -->
<textarea v-model.lazy="mensaje" rows="3"></textarea> <!-- .lazy actualiza al salir -->
<select v-model="pais">
<option value="mx">México</option>
<option value="co">Colombia</option>
<option value="ar">Argentina</option>
</select>
<label><input v-model="acepta" type="checkbox"> Acepto los términos</label>
<label><input v-model="envio" type="radio" value="normal"> Normal</label>
<label><input v-model="envio" type="radio" value="express"> Express</label>
<!-- Varios checkboxes con el mismo v-model → arreglo -->
<label v-for="op in ['Vue', 'React', 'Node']" :key="op">
<input v-model="intereses" type="checkbox" :value="op"> {{ op }}
</label>
<!-- Select múltiple → arreglo -->
<select v-model="etiquetas" multiple>
<option>Urgente</option>
<option>Cliente</option>
<option>Interno</option>
</select>
<button :disabled="!acepta || !nombre">Enviar</button>
</form>
<pre>{{ { nombre, edad, mensaje, pais, acepta, envio, intereses, etiquetas } }}</pre>
</template>
<style scoped>
.formulario { display: grid; gap: 0.75rem; max-width: 360px; }
</style>v-html y seguridad
vue
<template>
<!-- Inserta HTML real. ⚠️ Sólo con contenido confiable o sanitizado -->
<div v-html="contenidoDelCMS"></div>
</template>XSS
Nunca uses v-html con texto escrito por usuarios sin sanitizarlo (por ejemplo con DOMPurify). Un atacante podría inyectar scripts. La interpolación normal es segura porque escapa el contenido.
Caso real: tabla filtrable
vue
<script setup>
import { ref, computed } from 'vue'
const busqueda = ref('')
const soloActivos = ref(false)
const usuarios = ref([
{ id: 1, nombre: 'Ana Torres', email: 'ana@empresa.com', rol: 'admin', activo: true },
{ id: 2, nombre: 'Luis Pérez', email: 'luis@empresa.com', rol: 'editor', activo: true },
{ id: 3, nombre: 'Marta Ruiz', email: 'marta@empresa.com', rol: 'lector', activo: false }
])
const visibles = computed(() => {
const q = busqueda.value.toLowerCase()
return usuarios.value.filter(
(u) => (u.nombre.toLowerCase().includes(q) || u.email.includes(q)) && (!soloActivos.value || u.activo)
)
})
</script>
<template>
<div class="contenedor">
<div class="filtros">
<input v-model="busqueda" type="search" placeholder="Buscar por nombre o email">
<label><input v-model="soloActivos" type="checkbox"> Sólo activos</label>
</div>
<table v-if="visibles.length">
<thead>
<tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr>
</thead>
<tbody>
<tr v-for="u in visibles" :key="u.id" :class="{ inactivo: !u.activo }">
<td>{{ u.nombre }}</td>
<td>{{ u.email }}</td>
<td><span class="rol" :class="`rol-${u.rol}`">{{ u.rol }}</span></td>
<td>{{ u.activo ? '✅ Activo' : '⛔ Inactivo' }}</td>
</tr>
</tbody>
</table>
<p v-else>No hay usuarios que coincidan.</p>
<p class="total">{{ visibles.length }} de {{ usuarios.length }} usuarios</p>
</div>
</template>
<style scoped>
.contenedor { max-width: 720px; margin: 2rem auto; font-family: system-ui; }
.filtros { display: flex; gap: 1rem; margin-bottom: 1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.6rem; border-bottom: 1px solid #e5e7eb; }
.inactivo { opacity: 0.5; }
.rol { padding: 2px 8px; border-radius: 99px; font-size: 0.8rem; }
.rol-admin { background: #ede9fe; color: #5b21b6; }
.rol-editor { background: #dcfce7; color: #166534; }
.rol-lector { background: #f3f4f6; color: #374151; }
.total { color: #6b7280; font-size: 0.9rem; }
</style>Buena práctica
Mantén la lógica fuera de la plantilla: si una expresión se vuelve larga, conviértela en una propiedad computada con un nombre descriptivo (lo verás a fondo en Reactividad).
Ejercicios
- Crea un componente con una lista de tareas: input con
v-modelque agregue al presionar Enter, cada tarea con un checkbox para completarla (tachada con:class) y un botón para eliminarla. - Crea un selector de colores con tres botones (rojo, verde, azul) que cambien el fondo de una caja con
:style; el botón activo debe tener la claseactivo.
Ver solución
vue
<!-- 1. ListaTareas.vue -->
<script setup>
import { ref } from 'vue'
const nueva = ref('')
const tareas = ref([])
function agregar() {
const texto = nueva.value.trim()
if (!texto) return
tareas.value.push({ id: Date.now(), texto, hecha: false })
nueva.value = ''
}
function eliminar(id) {
tareas.value = tareas.value.filter((t) => t.id !== id)
}
</script>
<template>
<input v-model="nueva" @keyup.enter="agregar" placeholder="Nueva tarea y Enter">
<ul>
<li v-for="t in tareas" :key="t.id">
<input v-model="t.hecha" type="checkbox">
<span :class="{ hecha: t.hecha }">{{ t.texto }}</span>
<button @click="eliminar(t.id)">✕</button>
</li>
</ul>
<p v-if="!tareas.length">No hay tareas 🎉</p>
</template>
<style scoped>
.hecha { text-decoration: line-through; color: #9ca3af; }
</style>vue
<!-- 2. SelectorColor.vue -->
<script setup>
import { ref } from 'vue'
const colores = { rojo: '#ef4444', verde: '#22c55e', azul: '#3b82f6' }
const actual = ref('verde')
</script>
<template>
<div>
<button
v-for="(hex, nombre) in colores"
:key="nombre"
:class="{ activo: actual === nombre }"
@click="actual = nombre"
>
{{ nombre }}
</button>
<div class="caja" :style="{ backgroundColor: colores[actual] }"></div>
</div>
</template>
<style scoped>
.caja { width: 160px; height: 100px; margin-top: 1rem; border-radius: 12px; transition: background-color 0.3s; }
.activo { font-weight: 700; outline: 2px solid #111; }
</style>Resumen
- La interpolación con dobles llaves muestra expresiones como texto escapado.
:attrenlaza atributos (incluidosclassystylecon objetos/arreglos);@eventoescucha eventos con modificadores.v-if/v-elsecrean o destruyen elementos;v-showsólo los oculta.v-forrepite elementos con una:keyúnica; no lo combines conv-ifen el mismo elemento.v-modelenlaza formularios en ambos sentidos, con modificadores.trim,.numbery.lazy.