Vue.js: el framework progresivo
Vue.js se autodefine como "el framework progresivo": puedes añadirlo a una página existente o usarlo para construir una SPA completa. Este tutorial recorre la Composition API, reactividad, componentes, routing y estado global. Prerequisito conceptual: JavaScript.
¿Qué es Vue?
Creado por Evan You en 2014, Vue creció desde una biblioteca de capa de vista hasta un framework con su propio ecosistema. Su característica más distintiva es la progresividad: puedes usarlo como un simple <script> para mejorar una página, o escalar a una aplicación compleja con Vue Router y Pinia.
- Curva de aprendizaje suave: el template es HTML familiar y la reactividad es automática.
- Vite por defecto: el andamiaje oficial usa Vite, con recarga instantánea en desarrollo.
- Un solo archivo por componente (SFC):
.vueagrupa template, script y estilos. - Composition API: lógica reutilizable con composables, sin mixins.
<script setup> como sintaxis recomendada. La Options API (data(), methods) sigue soportada, pero todo este tutorial usa el enfoque moderno.Crear proyecto con Vite
El andamiaje oficial es create-vue (la herramienta de Vue para Vite):
npm create vue@latest
# Te pregunta qué incluir (Router, Pinia, ESLint...).
# Para empezar: Vue Router sí, el resto opcional.
cd mi-app
npm install
npm run dev # http://localhost:5173El resultado es una SPA con Vite: src/main.ts crea la app y monta el componente raíz:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')La estructura típica de un proyecto Vue:
mi-app/
├── index.html
├── vite.config.ts
├── src/
│ ├── main.ts # bootstrap: createApp + plugins
│ ├── App.vue # componente raíz
│ ├── router/index.ts # rutas
│ ├── stores/ # stores de Pinia
│ ├── components/ # componentes reutilizables
│ └── views/ # vistas de las rutas
└── package.jsonComposition API y <script setup>
Un componente Vue es un archivo .vue con tres bloques. Con <script setup>, las variables declaradas están disponibles directamente en el template:
<script setup lang="ts">
const mensaje = 'Hola desde Vue'
const items = ['uno', 'dos', 'tres']
</script>
<template>
<h1>{{ mensaje }}</h1>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</template><script setup> no exporta nada: las variables y funciones son locales al template. El resultado es código más corto y sin "esta confusión" de this.Reactividad
La reactividad de Vue detecta los accesos y cambios a variables reactivas y re-renderiza lo que dependa de ellas. Las tres herramientas principales:
ref()— valor reactivo. En el template se usa sin.value, en código JavaScript con.value.reactive()— objeto reactivo, se accede directamente.computed()— valor derivado que se recalcula solo cuando cambian sus dependencias.
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
const contador = ref(0)
const estado = reactive({ nombre: 'Ana', edad: 28 })
// En JavaScript hay que usar .value
const incrementar = () => contador.value++
const nombreCompleto = computed(
() => `${estado.nombre}, ${estado.edad} años`
)
</script>
<template>
<button @click="incrementar">Clics: {{ contador }}</button>
<p>{{ nombreCompleto }}</p>
</template>reactive() solo funciona con objetos; con primitivos usa ref(). Al destructurar un objeto reactivo pierdes la reactividad — usa toRefs() si necesitas extraer propiedades.Directivas
Las directivas son atributos con prefijo v- que controlan el DOM:
<!-- Condicional -->
<p v-if="usuario">Bienvenido, {{ usuario.nombre }}</p>
<p v-else>Inicia sesión</p>
<!-- Listas: siempre con :key -->
<ul>
<li v-for="(tarea, indice) in tareas" :key="tarea.id">
{{ indice }}. {{ tarea.titulo }}
</li>
</ul>
<!-- Two-way binding en inputs -->
<input v-model="busqueda" placeholder="Buscar..." />v-if / v-else-if / v-else— renderizado condicional (crea/destruye el elemento).v-for— iteración, siempre con:keyestable.v-model— two-way binding en inputs, selects y textareas.v-show— alternativa que solo alternadisplay, sin crear/destruir.v-bind(:) — enlaza atributos;v-on(@) — escucha eventos.
v-if con v-for en el mismo elemento: Vue los evalúa con v-if primero, lo que puede sorprender. Prefiere envolver con <template> o filtrar antes en un computed.Eventos y modificadores
Los eventos se escuchan con @evento (equivalente a v-on:evento). Vue añade modificadores que simplifican casos comunes:
<script setup lang="ts">
const enviar = () => console.log('Enviado')
const guardarPosicion = (x: number, y: number) =>
console.log({ x, y })
</script>
<template>
<!-- .prevent evita el comportamiento por defecto (recargar la página) -->
<form @submit.prevent="enviar">
<input type="text" />
<button type="submit">Enviar</button>
</form>
<!-- .stop detiene la propagación; pasar argumento -->
<button @click.stop="guardarPosicion($event.clientX, $event.clientY)">
Clic
</button>
<!-- Modificadores de teclado y ratón -->
<input @keyup.enter="enviar" />
<div @click.ctrl="enviar">Ctrl + clic</div>
</template>Los modificadores más usados: .prevent, .stop, .self, .once, teclas como .enter o .esc, y para v-model .lazy (actualiza al perder foco) y .number (convierte a número).
Props y emits
Los componentes reciben datos del padre con defineProps y avisan con defineEmits. Ambos son macros del compilador: no necesitan importarse:
<!-- CardTarea.vue -->
<script setup lang="ts">
defineProps<{ tarea: { id: number; titulo: string; done: boolean } }>()
const emit = defineEmits<{
(e: 'toggle', id: number): void
(e: 'eliminar', id: number): void
}>()
</script>
<template>
<div class="tarea" :class="{ completada: tarea.done }">
<span>{{ tarea.titulo }}</span>
<button @click="emit('toggle', tarea.id)">Cambiar</button>
<button @click="emit('eliminar', tarea.id)">Eliminar</button>
</div>
</template>El componente padre lo usa escuchando los eventos como si fueran DOM:
<script setup lang="ts">
import { ref } from 'vue'
import CardTarea from './CardTarea.vue'
const tareas = ref([
{ id: 1, titulo: 'Estudiar Vue', done: false },
])
const alternar = (id: number) => {
const tarea = tareas.value.find((t) => t.id === id)
if (tarea) tarea.done = !tarea.done
}
</script>
<template>
<CardTarea
v-for="tarea in tareas"
:key="tarea.id"
:tarea="tarea"
@toggle="alternar"
@eliminar="(id) => tareas = tareas.filter((t) => t.id !== id)"
/>
</template>prop directamente: es de solo lectura y Vue te avisará. Para estados mutables inicializados desde un prop, crea un ref local o un computed con getter y setter.Slots
Los slots permiten pasar contenido arbitrario desde el padre al interior de un componente. Es la composición por contenido, ideal para layouts y componentes envolventes:
<!-- Card.vue -->
<script setup lang="ts">
defineProps<{ titulo: string }>()
</script>
<template>
<div class="card">
<h3 class="card-titulo">{{ titulo }}</h3>
<!-- Slot por defecto -->
<slot />
</div>
</template><script setup lang="ts">
import Card from './Card.vue'
</script>
<template>
<Card titulo="Bienvenida">
<p>Este contenido viene del padre.</p>
</Card>
</template>Los slots nombrados permiten varias zonas:
<!-- PanelLayout.vue -->
<template>
<section class="panel">
<header><slot name="cabecera" /></header>
<main><slot /></main>
<footer><slot name="pie" /></footer>
</section>
</template>
<!-- Uso -->
<PanelLayout>
<template #cabecera><h2>Título del panel</h2></template>
<p>Contenido principal.</p>
<template #pie>Pie de página.</template>
</PanelLayout>Los slots con scoped props (usando <slot :dato="valor" />) dejan que el padre reciba datos del hijo dentro del slot — potente para listas y tablas personalizables.
Ciclo de vida
Los hooks de ciclo de vida se importan de Vue y se llaman dentro de <script setup>:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const usuarios = ref([])
onMounted(async () => {
const res = await fetch('https://api.example.com/usuarios')
usuarios.value = await res.json()
})
onUnmounted(() => {
// Limpieza: intervalos, listeners, conexiones
limpiarTodo()
})
</script>onMounted— el DOM del componente ya está insertado. El lugar típico para fetch.onUnmounted— limpieza de suscripciones, timers y listeners.onBeforeUnmount— último momento antes de destruir.watch— reacciona a cambios de una fuente reactiva (no es un hook de ciclo de vida pero convive con ellos).
created de la Options API no existe: el cuerpo de <script setup> se ejecuta antes del montaje, así que el fetch en el top-level funciona igual. Úsalo con onMounted para tener acceso al DOM.Routing con Vue Router
Vue Router mapea URLs a componentes. Se configura con createRouter y createWebHistory:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Inicio from '../views/Inicio.vue'
import UsuarioDetalle from '../views/UsuarioDetalle.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Inicio },
{ path: '/usuarios/:id', component: UsuarioDetalle },
],
})
export default routerEl componente raíz incluye RouterView (zona donde se renderiza la ruta) y los enlaces se hacen con RouterLink:
<!-- App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<nav>
<RouterLink to="/">Inicio</RouterLink>
<RouterLink to="/usuarios/1">Usuario 1</RouterLink>
</nav>
<RouterView />
</template>Para leer parámetros y reaccionar a cambios de ruta se usa useRoute y useRouter:
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const id = ref(route.params.id as string)
watch(
() => route.params.id,
(nuevoId) => {
id.value = nuevoId as string
cargarDatos(nuevoId)
}
)
const irAInicio = () => router.push('/')
</script>/usuarios/1 a /usuarios/2, el componente se reutiliza (no se remonta). Por eso lees el parámetro con watch en vez de solo onMounted.Estado global con Pinia
Cuando varias vistas comparten estado (sesión, carrito, filtros), Pinia es el store oficial de Vue. Un store se define con defineStore:
// src/stores/usuario.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUsuarioStore = defineStore('usuario', () => {
// Estado
const nombre = ref('')
const rol = ref<'invitado' | 'admin'>('invitado')
// Getters (derivados)
const esAdmin = computed(() => rol.value === 'admin')
// Acciones (métodos)
function iniciarSesion(nuevoNombre: string, nuevoRol: 'invitado' | 'admin') {
nombre.value = nuevoNombre
rol.value = nuevoRol
}
function cerrarSesion() {
nombre.value = ''
rol.value = 'invitado'
}
return { nombre, rol, esAdmin, iniciarSesion, cerrarSesion }
})Cualquier componente lo consume con el hook que genera Pinia:
<script setup lang="ts">
import { useUsuarioStore } from '../stores/usuario'
const usuario = useUsuarioStore()
</script>
<template>
<p v-if="usuario.esAdmin">Panel de administración</p>
<p v-else>{{ usuario.nombre || 'Invitado' }}</p>
<button @click="usuario.iniciarSesion('Ana', 'admin')">Entrar</button>
</template><script setup>: ref para estado, computed para getters y funciones para acciones. Si dominas un componente, ya dominas un store. La integración con DevTools y el soporte de TypeScript son nativos.Vue te permite empezar pequeño y crecer de forma incremental: un <script> en una página, un SFC aquí, un router allá, y Pinia cuando el estado lo pida. Esa es la esencia de "framework progresivo".
Ejercicios
Vue es una puerta de entrada excelente al desarrollo frontend moderno: su reactividad automática y su sintaxis legible te dejan centrarte en el problema, no en el framework. Cuando el proyecto crezca, Vue Router y Pinia escalan contigo sin que tengas que reescribir nada.