Vue.js: De 0 a Profesional
Tutorial
Tutorial completo con mejores prácticas, modularidad y patrones modernos (2025)
1. ¿Qué es Vue y por qué usarlo?
Vue 3 es un framework progresivo para construir interfaces de usuario. A diferencia de React (que es una librería) o Angular (framework completo), Vue ocupa un punto intermedio: puedes adoptarlo incrementalmente.
Ventajas clave en 2025:
- Composition API: lógica reutilizable y tipado limpio
<script setup>: menos boilerplate que cualquier otro framework- Reactividad granular y eficiente (sin Virtual DOM diffing agresivo desde Vue 3.4+)
- Ecosistema maduro: Pinia, Vue Router, Vite, Nuxt 3
¿Viene de React? La Composition API es conceptualmente similar a los hooks. Los composables son los custom hooks de Vue. Pinia equivale a Zustand.
ref()≈useState().
2. Instalación y entorno de trabajo
Crear un proyecto nuevo (2025)
npm create vue@latest mi-proyecto
Selecciona las opciones recomendadas:
- ✅ TypeScript
- ✅ Vue Router
- ✅ Pinia
- ✅ Vitest
- ✅ ESLint + Prettier
cd mi-proyecto
npm install
npm run dev
Estructura de carpetas profesional
src/
├── assets/ # Imágenes, fuentes, estilos globales
├── components/ # Componentes reutilizables (atómicos)
│ ├── ui/ # Botones, inputs, modales (sin lógica de negocio)
│ └── shared/ # Componentes con algo de lógica compartida
├── composables/ # Lógica reutilizable (custom hooks)
├── layouts/ # Layouts de página (AppLayout, AuthLayout...)
├── pages/ # Vistas/páginas (mapeadas con Vue Router)
├── router/ # Configuración del router
├── stores/ # Stores de Pinia
├── services/ # Llamadas a API (fetch/axios abstraído)
├── types/ # Interfaces y tipos TypeScript
└── utils/ # Funciones puras de utilidad
3. Fundamentos: Composition API
<script setup> — La forma moderna
<!-- ❌ Options API (evitar en proyectos nuevos) -->
<script>
export default {
data() {
return { count: 0 }
},
methods: {
increment() { this.count++ }
}
}
</script>
<!-- ✅ Composition API con <script setup> -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
Props y Emits tipados
<script setup lang="ts">
// Props con TypeScript (sin defineProps genérico en Vue 3.3+)
const props = defineProps<{
title: string
count?: number
items: string[]
}>()
// Con valores por defecto
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
// Emits tipados
const emit = defineEmits<{
update: [value: number]
close: []
}>()
// Uso
emit('update', 42)
</script>
Ciclo de vida
<script setup lang="ts">
import { onMounted, onUnmounted, onBeforeMount } from 'vue'
onMounted(() => {
console.log('Componente montado en el DOM')
})
onUnmounted(() => {
console.log('Limpieza de efectos secundarios aquí')
})
</script>
4. Reactividad en profundidad
ref vs reactive
import { ref, reactive, computed, watch, watchEffect } from 'vue'
// ref: para primitivos y cualquier valor (recomendado por consistencia)
const name = ref('Ana')
console.log(name.value) // acceso mediante .value en JS/TS
// reactive: para objetos (pierde reactividad si se desestructura)
const state = reactive({ x: 0, y: 0 })
console.log(state.x) // no necesita .value
// ✅ Regla de oro: usa ref por defecto
// Usa reactive solo cuando tengas un grupo de valores fuertemente relacionados
computed — valores derivados
const firstName = ref('Ana')
const lastName = ref('García')
// ✅ computed es lazy y cacheado
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// computed con getter y setter
const reversedName = computed({
get: () => fullName.value.split('').reverse().join(''),
set: (val: string) => {
firstName.value = val.split(' ')[0]
}
})
watch y watchEffect
import { watch, watchEffect, ref } from 'vue'
const query = ref('')
const results = ref([])
// watch: control explícito sobre qué observar
watch(query, async (newVal, oldVal) => {
if (newVal.length > 2) {
results.value = await fetchResults(newVal)
}
}, {
debounce: 300 // Vue 3.5+: debounce nativo
})
// watchEffect: ejecuta inmediatamente y rastrea dependencias automáticamente
watchEffect(async () => {
// Todo lo que se lea aquí es tracked automáticamente
if (query.value.length > 2) {
results.value = await fetchResults(query.value)
}
})
// Limpiar efectos secundarios (WebSockets, timers...)
watchEffect((onCleanup) => {
const timer = setInterval(() => poll(), 1000)
onCleanup(() => clearInterval(timer))
})
Vue 3.5+: Reactive Props Destructure
<script setup lang="ts">
// A partir de Vue 3.5, las props son reactivas al desestructurar
const { count, title = 'Default' } = defineProps<{
count: number
title?: string
}>()
// count y title son reactivos sin necesidad de toRefs
</script>
5. Componentes: arquitectura y comunicación
Componente bien estructurado
<!-- components/ui/AppButton.vue -->
<script setup lang="ts">
type Variant = 'primary' | 'secondary' | 'danger'
type Size = 'sm' | 'md' | 'lg'
const props = withDefaults(defineProps<{
variant?: Variant
size?: Size
loading?: boolean
disabled?: boolean
}>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
:class="[
'btn',
`btn--${variant}`,
`btn--${size}`,
{ 'btn--loading': loading }
]"
:disabled="disabled || loading"
@click="emit('click', $event)"
>
<span v-if="loading" class="spinner" aria-hidden="true" />
<slot />
</button>
</template>
Comunicación entre componentes
<!-- Padre → Hijo: Props -->
<ChildComponent :user="currentUser" :items="list" />
<!-- Hijo → Padre: Emits -->
<ChildComponent @update:user="handleUpdate" />
<!-- Bidireccional con v-model (Vue 3) -->
<!-- Componente padre -->
<MyInput v-model="username" />
<!-- equivale a -->
<MyInput :modelValue="username" @update:modelValue="username = $event" />
<!-- Componente MyInput.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
Provide / Inject (para evitar prop drilling)
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// Componente raíz o layout
import { provide, ref } from 'vue'
import { ThemeKey } from '@/types/injection-keys'
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
// Componente nieto
import { inject } from 'vue'
import { ThemeKey } from '@/types/injection-keys'
const theme = inject(ThemeKey) // type-safe gracias a InjectionKey
Slots avanzados
<!-- components/DataTable.vue -->
<template>
<table>
<thead>
<slot name="header" :columns="columns" />
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" :index="index" />
</tr>
</tbody>
<tfoot>
<slot name="footer" />
</tfoot>
</table>
</template>
<!-- Uso con scoped slots -->
<DataTable :rows="users">
<template #header="{ columns }">
<th v-for="col in columns">{{ col.label }}</th>
</template>
<template #row="{ row, index }">
<td>{{ index + 1 }}</td>
<td>{{ row.name }}</td>
</template>
</DataTable>
6. Composables: lógica reutilizable
Los composables son la pieza más importante de la arquitectura Vue moderna. Equivalen a los custom hooks de React.
Convenciones
- Nombre con prefijo
use:useFetch,useLocalStorage,useDebounce - Retornan refs reactivos
- Se llaman en el
setup(top-level) - Pueden recibir argumentos reactivos (
MaybeRefOrGetter)
Composable básico: useFetch
// composables/useFetch.ts
import { ref, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
interface UseFetchOptions {
immediate?: boolean
}
export function useFetch<T>(
url: MaybeRefOrGetter<string>,
options: UseFetchOptions = { immediate: true }
) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json() as T
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}
if (options.immediate) {
watchEffect(() => {
toValue(url) // hace que watchEffect track cambios en url
execute()
})
}
return { data, error, isLoading, execute }
}
// Uso en componente
const { data: users, isLoading } = useFetch<User[]>('/api/users')
const endpoint = computed(() => `/api/users/${userId.value}`)
const { data: user } = useFetch<User>(endpoint) // reactivo a cambios
Composable con estado local: useCounter
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) {
const count = ref(initial)
const increment = (step = 1) => {
count.value = Math.min(count.value + step, max)
}
const decrement = (step = 1) => {
count.value = Math.max(count.value - step, min)
}
const reset = () => { count.value = initial }
const isAtMax = computed(() => count.value >= max)
const isAtMin = computed(() => count.value <= min)
return { count, increment, decrement, reset, isAtMax, isAtMin }
}
Composable con efectos secundarios: useEventListener
// composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'
export function useEventListener<K extends keyof WindowEventMap>(
target: EventTarget | Window,
event: K,
handler: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
) {
onMounted(() => target.addEventListener(event, handler as EventListener, options))
onUnmounted(() => target.removeEventListener(event, handler as EventListener, options))
}
// Uso
useEventListener(window, 'resize', () => {
console.log('Window resized:', window.innerWidth)
})
Composable de formularios: useForm
// composables/useForm.ts
import { reactive, ref } from 'vue'
type Validator<T> = (value: T) => string | null
interface FieldConfig<T> {
initial: T
validators?: Validator<T>[]
}
export function useForm<T extends Record<string, unknown>>(
config: { [K in keyof T]: FieldConfig<T[K]> }
) {
const values = reactive({} as T)
const errors = reactive({} as Record<keyof T, string | null>)
const isSubmitting = ref(false)
// Inicializar valores
for (const key in config) {
values[key] = config[key].initial as T[typeof key]
errors[key] = null
}
function validate(): boolean {
let valid = true
for (const key in config) {
const validators = config[key].validators ?? []
for (const validator of validators) {
const error = validator(values[key] as T[typeof key])
if (error) {
errors[key] = error
valid = false
break
}
errors[key] = null
}
}
return valid
}
async function handleSubmit(onSubmit: (values: T) => Promise<void>) {
if (!validate()) return
isSubmitting.value = true
try {
await onSubmit(values as T)
} finally {
isSubmitting.value = false
}
}
return { values, errors, isSubmitting, validate, handleSubmit }
}
// Uso
const { values, errors, handleSubmit } = useForm({
email: {
initial: '',
validators: [
(v) => !v ? 'Requerido' : null,
(v) => !v.includes('@') ? 'Email inválido' : null,
]
},
password: {
initial: '',
validators: [(v) => v.length < 8 ? 'Mínimo 8 caracteres' : null]
}
})
7. Vue Router moderno
Configuración con layouts y guards
// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
},
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
],
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: 'login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
meta: { requiresGuest: true },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
// Navigation Guard global
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.requiresGuest && auth.isLoggedIn) {
return { name: 'dashboard' }
}
})
export default router
Composable de router
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Navegar programáticamente
const goToDashboard = () => router.push({ name: 'dashboard' })
// Leer parámetros
const userId = computed(() => route.params.id as string)
const page = computed(() => Number(route.query.page ?? 1))
</script>
8. Pinia: estado global
Pinia es el reemplazo oficial de Vuex. Es simple, type-safe y compatible con Vue DevTools.
Store básico
// stores/users.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
import { UserService } from '@/services/UserService'
// Composition Store (recomendado sobre Options Store)
export const useUsersStore = defineStore('users', () => {
// State
const users = ref<User[]>([])
const currentUserId = ref<string | null>(null)
const isLoading = ref(false)
// Getters
const currentUser = computed(() =>
users.value.find(u => u.id === currentUserId.value) ?? null
)
const activeUsers = computed(() =>
users.value.filter(u => u.active)
)
// Actions
async function fetchUsers() {
isLoading.value = true
try {
users.value = await UserService.getAll()
} finally {
isLoading.value = false
}
}
async function updateUser(id: string, payload: Partial<User>) {
const updated = await UserService.update(id, payload)
const index = users.value.findIndex(u => u.id === id)
if (index !== -1) users.value[index] = updated
}
function setCurrentUser(id: string) {
currentUserId.value = id
}
return {
// State
users,
currentUserId,
isLoading,
// Getters
currentUser,
activeUsers,
// Actions
fetchUsers,
updateUser,
setCurrentUser,
}
})
Uso en componentes
<script setup lang="ts">
import { useUsersStore } from '@/stores/users'
import { storeToRefs } from 'pinia'
const store = useUsersStore()
// ✅ storeToRefs preserva reactividad al desestructurar
const { users, isLoading, currentUser } = storeToRefs(store)
// Las actions se pueden desestructurar directamente (no son reactivas)
const { fetchUsers, updateUser } = store
onMounted(() => fetchUsers())
</script>
9. Rendimiento y optimización
Lazy loading de componentes
// ✅ Carga diferida: el componente solo se descarga cuando se necesita
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
// Con loading y error states
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorFallback,
delay: 200, // ms antes de mostrar loading
timeout: 10000, // ms antes de mostrar error
})
v-memo y v-once
<template>
<!-- v-once: renderiza una sola vez, nunca actualiza -->
<AppHeader v-once />
<!-- v-memo: solo actualiza si cambian los valores de la lista -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected, item.name]">
<ExpensiveItem :item="item" />
</div>
</template>
shallowRef y shallowReactive
import { shallowRef, shallowReactive, triggerRef } from 'vue'
// Para grandes arrays/objetos donde no necesitas reactividad profunda
const bigList = shallowRef<Item[]>([])
// Para mutar y notificar manualmente
bigList.value.push(newItem)
triggerRef(bigList) // fuerza actualización
KeepAlive para preservar estado de componentes
<template>
<KeepAlive :include="['SearchResults', 'UserProfile']" :max="5">
<component :is="currentView" />
</KeepAlive>
</template>
10. Testing
Unit testing con Vitest + Vue Test Utils
// components/__tests__/AppButton.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AppButton from '../AppButton.vue'
describe('AppButton', () => {
it('renderiza el slot correctamente', () => {
const wrapper = mount(AppButton, {
slots: { default: 'Guardar' }
})
expect(wrapper.text()).toBe('Guardar')
})
it('emite click event', async () => {
const wrapper = mount(AppButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('está deshabilitado cuando loading es true', () => {
const wrapper = mount(AppButton, { props: { loading: true } })
expect(wrapper.attributes('disabled')).toBeDefined()
})
})
Testing de composables
// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('inicializa con valor por defecto', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('respeta el límite máximo', () => {
const { count, increment } = useCounter(0, { max: 3 })
increment(10)
expect(count.value).toBe(3)
})
})
Testing de stores de Pinia
// stores/__tests__/users.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUsersStore } from '../users'
import * as UserService from '@/services/UserService'
describe('useUsersStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('fetchUsers carga usuarios correctamente', async () => {
const mockUsers = [{ id: '1', name: 'Ana', active: true }]
vi.spyOn(UserService, 'getAll').mockResolvedValue(mockUsers)
const store = useUsersStore()
await store.fetchUsers()
expect(store.users).toEqual(mockUsers)
expect(store.isLoading).toBe(false)
})
})
11. Patrones avanzados
Patrón Renderless Components
<!-- components/RenderlessDataFetcher.vue -->
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const props = defineProps<{ url: string }>()
const { data, error, isLoading } = useFetch(computed(() => props.url))
</script>
<template>
<slot :data="data" :error="error" :isLoading="isLoading" />
</template>
<!-- Uso: total control sobre el template -->
<RenderlessDataFetcher url="/api/users">
<template #default="{ data, isLoading, error }">
<Spinner v-if="isLoading" />
<Alert v-else-if="error" :message="error.message" />
<UserList v-else :users="data" />
</template>
</RenderlessDataFetcher>
Plugin de Vue
// plugins/toast.ts
import type { App } from 'vue'
import ToastContainer from '@/components/ToastContainer.vue'
interface ToastPlugin {
success(message: string): void
error(message: string): void
}
export const toastPlugin = {
install(app: App) {
const toast: ToastPlugin = {
success(message) { /* ... */ },
error(message) { /* ... */ },
}
app.provide('toast', toast)
app.config.globalProperties.$toast = toast
}
}
// main.ts
app.use(toastPlugin)
// Uso en composable
export function useToast() {
return inject<ToastPlugin>('toast')!
}
Teleport: renderizar fuera del DOM del componente
<template>
<button @click="showModal = true">Abrir modal</button>
<!-- Se renderiza directamente en <body>, no donde está el componente -->
<Teleport to="body">
<AppModal v-if="showModal" @close="showModal = false">
Contenido del modal
</AppModal>
</Teleport>
</template>
12. Buenas prácticas y arquitectura de proyecto
Reglas de oro
1. Un componente, una responsabilidad
❌ UserDashboard.vue (hace demasiado)
✅ UserProfile.vue + UserStats.vue + UserActivity.vue
2. Tipado estricto siempre
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
3. Abstrae las llamadas a API en services
// services/UserService.ts
const BASE = '/api/users'
export const UserService = {
getAll: (): Promise<User[]> =>
fetch(BASE).then(r => r.json()),
getById: (id: string): Promise<User> =>
fetch(`${BASE}/${id}`).then(r => r.json()),
update: (id: string, data: Partial<User>): Promise<User> =>
fetch(`${BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(r => r.json()),
}
4. Nombres semánticos para composables y stores
✅ useCurrentUser, useProductSearch, useCartActions
❌ useData, useHelper, useMisc
5. Exports centralizados con index.ts
// components/ui/index.ts
export { default as AppButton } from './AppButton.vue'
export { default as AppInput } from './AppInput.vue'
export { default as AppModal } from './AppModal.vue'
// En cualquier lugar del proyecto:
import { AppButton, AppInput } from '@/components/ui'
6. Variables de entorno tipadas
// env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
7. Usa defineOptions para nombrar componentes
<script setup lang="ts">
defineOptions({ name: 'UserProfileCard' })
</script>
Checklist de Code Review Vue
- ¿Los componentes tienen menos de 200 líneas?
- ¿Toda la lógica reutilizable está en composables?
- ¿Las props y emits están correctamente tipados?
- ¿Se usa
storeToRefsal desestructurar stores? - ¿Las rutas usan lazy loading (
() => import(...)) - ¿Los efectos secundarios se limpian en
onUnmounted? - ¿Hay tests unitarios para composables y stores?
- ¿Se evita acceder directamente al DOM (usar
refen su lugar)?
Recursos imprescindibles
- Documentación oficial de Vue 3
- VueUse — colección de +200 composables listos para producción
- Pinia
- Vue Router
- Nuxt 3 — framework full-stack sobre Vue