Patrones y Mejores Prácticas de Vue.js
Guía completa para el desarrollo con Vue.js 3 usando Composition API (<script setup>), que cubre diseño de componentes, reactividad, gestión de estado, enrutamiento, pruebas y patrones SSR. Se incluyen pautas específicas de Nuxt cuando difieren de Vue estándar.
Cuándo Activarlo
Activa esta habilidad cuando:
- El proyecto usa Vue.js (cualquier versión), Nuxt, Vite + Vue o Pinia.
- El usuario pregunta sobre arquitectura de componentes de Vue, composables, reactividad o gestión de estado.
- Se están revisando Componentes de Archivo Único de Vue (archivos
.vue). - Se está configurando Vue Router, stores de Pinia o configuración de Vite/Vitest.
- Se discuten patrones de rendimiento, seguridad o SSR específicos de Vue.
1. Estructura del Proyecto
Diseño Recomendado (Prioridad de Características)
src/
├── api/ # Cliente API y definiciones de endpoints
├── assets/ # Recursos estáticos (imágenes, fuentes, iconos)
├── components/ # Componentes compartidos/reutilizables
│ ├── base/ # Primitivos de UI base (Button, Input, Modal)
│ └── features/ # Componentes compartidos específicos de funcionalidades
├── composables/ # Lógica reutilizable de Composition API
├── layouts/ # Diseños de página (opcional)
├── pages/ # Componentes de página a nivel de ruta
├── router/ # Configuración de Vue Router
├── stores/ # Stores de Pinia
├── types/ # Definiciones de tipos TypeScript
├── utils/ # Funciones de utilidad puras
└── App.vue # Componente raíz
Nomenclatura de Archivos
| Convención | Cuándo Usarla |
|---|---|
PascalCase.vue | Todos los componentes (exigido por vue/multi-word-component-names) |
useCamelCase.ts | Composables |
camelCase.ts | Utilidades, clientes API, tipos |
Directorios kebab-case | Segmentos de ruta, carpetas de funcionalidades |
2. Arquitectura de Componentes
Orden del Componente de Archivo Único
<script setup lang="ts">
// 1. Importaciones (vue → ecosistema → absolutas → relativas)
// 2. Props, Emits y Slots
// 3. Composables
// 4. Estado local (ref/reactive)
// 5. Propiedades computadas
// 6. Métodos
// 7. Watchers
// 8. Hooks del ciclo de vida
</script>
<template>
<!-- Contenido del template -->
</template>
<style scoped>
/* Estilos con ámbito */
</style>
Componentes de Presentación vs Contenedor
- Componentes contenedor: Gestionan la obtención de datos, estado y efectos secundarios. Renderizan componentes de presentación.
- Componentes de presentación: Reciben props, emiten eventos. Sin llamadas API, sin acceso al store. Renderizado puro.
Mejores Prácticas para Props
// Props basados en tipos con valores por defecto
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});
- Proporciona siempre
type, yrequired/defaultdonde corresponda. - Props booleanas:
isXxx,hasXxx,canXxx. - Nunca mutar props: emitir eventos en su lugar.
- Para enlace
v-model, usardefineModel()(Vue 3.4+) omodelValue+update:modelValue.
Eventos
const emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();
- Usar kebab-case en templates (
@update:model-value). - Usar camelCase en script (
emit("update:modelValue", val)).
3. Composables (Lógica Reutilizable)
Estructura
// composables/useDebounce.ts
export function useDebounce<T>(value: MaybeRef<T>, delay: number): Ref<T> {
const debounced = ref(toValue(value)) as Ref<T>;
let timer: ReturnType<typeof setTimeout>;
watch(
() => toValue(value),
(newVal) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = newVal; }, delay);
}
);
onUnmounted(() => clearTimeout(timer));
return readonly(debounced);
}
Reglas
- Debe comenzar con el prefijo
use. - Devolver valores reactivos (
ref,computed,reactive), nunca primitivos simples. - Aceptar entradas reactivas mediante
MaybeRef/toRef()/toValue(). - Limpiar efectos secundarios en
onUnmountedo cononCleanupdel watcher. - Sin efectos secundarios a nivel de módulo.
vs Mixins
Los composables reemplazan por completo los mixins de Vue 2:
- Mixins: Flujo de datos opaco, colisiones de fuente de verdad, conflictos de nombres.
- Composables: Importaciones explícitas, valores de retorno claros, componibles y tree-shakables.
4. Gestión de Estado
Cuándo Usar Qué
| Patrón | Caso de Uso |
|---|---|
ref() / reactive() | Estado local del componente |
| Props + Emits | Comunicación padre-hijo |
| Provide / Inject | Tema, configuración, API de plugin |
| Store de Pinia | Estado global, compartido, complejo |
| Composable de estado del servidor | Datos API con caché (envolver fetch/TanStack Query) |
Store de Pinia con Setup Store (Preferido)
// stores/useCartStore.ts
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const isLoading = ref(false);
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.quantity, 0)
);
async function addItem(productId: string) {
isLoading.value = true;
try {
const item = await fetchProduct(productId);
const existing = items.value.find(i => i.id === item.id);
if (existing) existing.quantity++;
else items.value.push({ ...item, quantity: 1 });
} finally {
isLoading.value = false;
}
}
return { items, isLoading, totalPrice, itemCount, addItem };
});
- Usar sintaxis de Setup Store (no Options Store).
- Preferir acciones para mutaciones a nivel de negocio y
$patch()para actualizaciones agrupadas. - Cada acción asíncrona: manejar carga + éxito + error.
5. Vue Router
Definiciones de Rutas
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // lazy
props: true, // pasar params como props
meta: { requiresAuth: true },
},
];
Guardias de Navegación
router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
Parámetros de Ruta Reactivos
Cuando un componente permanece montado pero los parámetros de ruta cambian:
const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));
6. Patrones de Template
Sintaxis de Template
<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">Cargando...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>{{ content }}</div>
<!-- v-show para alternancias frecuentes -->
<div v-show="isOpen">Contenido alternado</div>
<!-- v-for con claves estables -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- Lista filtrada computada (no v-if + v-for en el mismo elemento) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- Manejo de eventos -->
<form @submit.prevent="handleSubmit">
<button type="submit">Guardar</button>
</form>
<!-- v-model -->
<input v-model="name" />
<CustomInput v-model="value" v-model:title="title" />
7. Rendimiento
| Técnica | Cuándo Usarla |
|---|---|
v-memo | Elementos de lista que rara vez cambian |
v-once | Contenido renderizado una vez y estático para siempre |
shallowRef() | Grandes estructuras de datos reemplazadas por completo |
shallowReactive() | Solo las propiedades de nivel superior son reactivas |
v-show sobre v-if | Alternancias de visibilidad frecuentes |
<KeepAlive :max="10"> | Cachear vistas alternadas |
| Rutas lazy | () => import(...) para rutas no críticas |
Suspense | Carga de componentes asíncronos con fallback |
8. Pruebas
Stack
- Vitest para pruebas unitarias y de componentes
- Vue Test Utils para montaje e interacción
- @pinia/testing para simulación de stores
- Playwright para pruebas E2E
Patrón de Prueba de Componente
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import UserCard from "./UserCard.vue";
beforeEach(() => { setActivePinia(createPinia()); });
it("renderiza y emite", async () => {
const wrapper = mount(UserCard, {
props: { user: { id: "1", name: "Alice" } },
});
expect(wrapper.text()).toContain("Alice");
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("select")![0]).toEqual(["1"]);
});
9. Patrones Específicos de Nuxt
Auto-Importaciones
Nuxt auto-importa ref, computed, watch, useFetch, useAsyncData, etc. Úsalos directamente sin importar. Para proyectos no Nuxt, importa siempre explícitamente.
useAsyncData / useFetch
const { data: user, pending, error, refresh } = await useAsyncData(
"user", // clave única para caché
() => $fetch(`/api/users/${id}`),
);
const { data: posts } = await useFetch("/api/posts", {
query: { page: 1 },
key: "posts-page-1", // deduplica peticiones
});
Rutas del Servidor
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... obtener y devolver
});
Configuración en Tiempo de Ejecución
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// solo servidor
apiSecret: "",
// público (expuesto al cliente)
public: {
apiBase: "https://api.example.com",
},
},
});
10. Nuevas APIs de Vue 3.5+
Desestructuración Reactiva de Props
Vue 3.5 estabilizó la desestructuración reactiva de props: las variables desestructuradas de defineProps() son automáticamente reactivas:
// Vue 3.5+: props desestructurados son reactivos (no es necesario toRefs)
const { count = 0, msg = "Hola" } = defineProps<{
count?: number;
msg?: string;
}>();
// Limitación: no se puede observar un prop desestructurado directamente
watch(() => count, (newVal) => { ... }); // CORRECTO: se requiere un getter
useTemplateRef()
Reemplaza refs simples con nombre por useTemplateRef() para referencias a templates:
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" coincide con el atributo ref="input" en el template, no con el nombre de la variable
Soporta IDs de ref dinámicos: useTemplateRef(dynamicRefId).
onWatcherCleanup()
API de limpieza de watchers importable globalmente (Vue 3.5+). Debe llamarse sincrónicamente dentro del callback del watcher:
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... obtener con signal
});
useId()
Generación de ID única estable para SSR en elementos de formularios y accesibilidad:
import { useId } from "vue";
const id = useId();
defer en Teleport
<Teleport defer> permite teletransportar a destinos renderizados en el mismo ciclo:
<Teleport defer to="#container">Contenido</Teleport>
<div id="container"></div>
Hidratación Perezosa (SSR)
defineAsyncComponent() ahora soporta estrategia hydrate:
import { defineAsyncComponent, hydrateOnVisible } from "vue";
const AsyncComp = defineAsyncComponent({
loader: () => import("./Comp.vue"),
hydrate: hydrateOnVisible(),
});
Antipatrones
| Antipatrón | Por Qué Está Mal | La Solución |
|---|---|---|
Desestructurar defineProps() (Vue < 3.5) | Captura una instantánea, pierde reactividad | Acceder mediante props.xxx o usar toRefs() |
watch() sobre prop desestructurado (Vue 3.5+) | Error en tiempo de compilación: los props desestructurados no se pueden observar directamente | Usar envoltura con getter: watch(() => count, ...) |
v-if + v-for en el mismo elemento | Orden de ejecución ambiguo | Usar array filtrado computado |
Clave v-for = índice | Estado roto al reordenar | Usar IDs de base de datos estables |
| Mutar props | Viola el flujo de datos unidireccional | Emitir eventos o usar v-model |
v-html con contenido de usuario | Vulnerabilidad XSS | Sanitizar con DOMPurify |
| Mixins en Vue 3 | Opacos, propensos a colisiones | Reemplazar con composables |
| Efectos secundarios a nivel de módulo en composable | Compartidos entre instancias | Limitar a onMounted + onUnmounted |
reactive() para estado reemplazable | El reemplazo rompe la reactividad | Usar ref() en su lugar |
| Watcher sin limpieza | Fugas de memoria, condiciones de carrera | Usar onCleanup o onWatcherCleanup() (Vue 3.5+) |
| Options API en código Vue 3 nuevo | Migración del ecosistema a Composition API | Usar <script setup> |
| Ref simple para referencias a templates | Sin soporte de ref dinámico, coincidencia de nombres frágil | Usar useTemplateRef() (Vue 3.5+) |
Habilidades Relacionadas
accessibility— ARIA, HTML semántico, gestión de focofrontend-patterns— Arquitectura frontend multiplataformatypescript— Mejores prácticas de TypeScript aplicadas a proyectos Vuecoding-standards— Estándares generales de calidad de código