Patrones y Mejores Prácticas de Vue.js
Guía completa para el desarrollo con Vue.js 3 usando la Composition API (<script setup>), que cubre diseño de componentes, reactividad, gestión de estado, enrutamiento, pruebas y patrones SSR. Se incluye orientación específica de Nuxt cuando difiere de Vue vanilla.
Cuándo activarlo
Activa este skill cuando:
- El proyecto usa Vue.js (cualquier versión), Nuxt, Vite + Vue o Pinia.
- El usuario pregunta sobre arquitectura de componentes Vue, composables, reactividad o gestión de estado.
- Revisas componentes de un solo archivo de Vue (archivos
.vue). - Configuras Vue Router, tiendas Pinia o Vite/Vitest.
- Se discuten patrones de rendimiento, seguridad o SSR específicos de Vue.
1. Estructura del Proyecto
Diseño Recomendado (Primero las Características)
src/
├── api/ # Cliente API y definiciones de endpoints
├── assets/ # Recursos estáticos (imágenes, fuentes, iconos)
├── components/ # Componentes compartidos/reutilizables
│ ├── base/ # Primitivas de UI base (Button, Input, Modal)
│ └── features/ # Componentes compartidos específicos de funcionalidades
├── composables/ # Lógica reutilizable de la 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/ # Tiendas Pinia
├── types/ # Definiciones de tipos TypeScript
├── utils/ # Funciones de utilidad puras
└── App.vue # Componente raíz
Nomenclatura de Archivos
| Convención | Cuándo Usar |
|---|---|
PascalCase.vue | Todos los componentes (aplicado 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 un Solo Archivo
<script setup lang="ts">
// 1. Importaciones (vue → ecosistema → absolutas → relativas)
// 2. Props & Emits & Slots
// 3. Composables
// 4. Estado local (ref/reactive)
// 5. Propiedades computadas
// 6. Métodos
// 7. Observadores
// 8. Hooks del ciclo de vida
</script>
<template>
<!-- Contenido del template -->
</template>
<style scoped>
/* Estilos con ámbito */
</style>
Presentacional vs. Contenedor
- Componentes contenedor: Poseen la obtención de datos, el estado y los efectos secundarios. Renderizan componentes presentacionales.
- Componentes presentacionales: Reciben props, emiten eventos. Sin llamadas API, sin acceso a la tienda. Solo 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,
});
- Siempre proporciona
type, yrequired/defaultcuando sea apropiado. - 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 las plantillas (
@update:model-value). - Usar camelCase en el 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 primitivas simples. - Aceptar entradas reactivas mediante
MaybeRef/toRef()/toValue(). - Limpiar efectos secundarios en
onUnmountedo en elonCleanupdel observador. - Sin efectos secundarios a nivel de módulo.
vs Mixins
Los composables reemplazan completamente 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-shakeables.
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 plugins |
| Tienda Pinia | Estado global, compartido y complejo |
| Composable de estado del servidor | Datos de API con caché (envolver fetch/TanStack Query) |
Tienda Pinia Setup (Preferida)
// 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 la 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 Ruta
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // perezosa
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 Plantilla
Sintaxis de Plantilla
<!-- 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 Usar |
|---|---|
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 perezosas | () => 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 simular tiendas
- Playwright para 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: "Alicia" } },
});
expect(wrapper.text()).toContain("Alicia");
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("select")![0]).toEqual(["1"]);
});
9. Patrones Específicos de Nuxt
Autoimportaciones
Nuxt autoimporta ref, computed, watch, useFetch, useAsyncData, etc. Úsalos directamente sin importar. Para proyectos que no son Nuxt, siempre importa 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 solicitudes
});
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+
Destructuring Reactivo de Props
Vue 3.5 estabilizó el destructuring reactivo de props — las variables destructureadas de defineProps() son automáticamente reactivas:
// Vue 3.5+: props destructureadas son reactivas (no se necesita toRefs)
const { count = 0, msg = "hola" } = defineProps<{
count?: number;
msg?: string;
}>();
// Limitación: no se puede observar una prop destructureada directamente
watch(() => count, (newVal) => { ... }); // SE REQUIERE un getter
useTemplateRef()
Reemplaza las refs simples con coincidencia de nombre por useTemplateRef() para referencias a plantillas:
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" coincide con el atributo ref="input" en la plantilla, no con el nombre de la variable
Soporta IDs de ref dinámicos: useTemplateRef(dynamicRefId).
onWatcherCleanup()
API de limpieza de observador importable globalmente (Vue 3.5+). Debe llamarse sincrónicamente dentro del callback del observador:
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... obtener con señal
});
useId()
Generación de ID único estable para SSR para elementos de formulario y accesibilidad:
import { useId } from "vue";
const id = useId();
Teletransporte defer
<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 estrategias 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 |
|---|---|---|
Destructuring defineProps() (Vue < 3.5) | Captura una instantánea, pierde reactividad | Acceder mediante props.xxx o usar toRefs() |
watch() sobre prop destructureada (Vue 3.5+) | Error en tiempo de compilación — las props destructureadas no se pueden observar directamente | Usar envoltorio getter: watch(() => count, ...) |
v-if + v-for en el mismo elemento | Orden de ejecución ambiguo | Usar matriz filtrada computada |
v-for con clave = í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 composables | Compartidos entre instancias | Limitar en onMounted + onUnmounted |
reactive() para estado reemplazable | El reemplazo rompe la reactividad | Usar ref() en su lugar |
| Observador sin limpieza | Fugas de memoria, condiciones de carrera | Usar onCleanup o onWatcherCleanup() (Vue 3.5+) |
| Options API en código Vue 3 nuevo | El ecosistema se mueve a la Composition API | Usar <script setup> |
| Ref simple para referencias de plantilla | Sin soporte de ref dinámico, coincidencia de nombre frágil | Usar useTemplateRef() (Vue 3.5+) |
Skills Relacionados
accessibility— ARIA, HTML semántico, gestión de enfoquefrontend-patterns— Arquitectura frontend entre frameworkstypescript— Mejores prácticas de TypeScript aplicadas a proyectos Vuecoding-standards— Estándares generales de calidad de código