Tutorial avanzado: Desarrollando componentes modernos con Vue 3 y script setup

En el vibrante y siempre evolucionando universo del desarrollo front-end, Vue.js ha logrado consolidarse como una de las herramientas predilectas para construir interfaces de usuario dinámicas e interactivas. Desde su creación, ha sido elogiado por su accesibilidad, rendimiento y la elegante simplicidad de su API, cualidades que se han potenciado aún más con el lanzamiento de Vue 3. La última versión no solo trajo consigo mejoras significativas en el rendimiento y la flexibilidad, sino que también introdujo una serie de características que han transformado radicalmente la forma en que pensamos y construimos componentes. Entre estas innovaciones, script setup destaca como un verdadero punto de inflexión, simplificando drásticamente la creación de componentes y optimizando la experiencia del desarrollador.

¿Alguna vez te has encontrado con componentes de Vue.js que, a medida que crecen en complejidad, se vuelven difíciles de leer y mantener? ¿O has deseado una forma más concisa y directa de declarar la lógica de tus componentes sin la verbosidad de las Options API tradicionales? Si tu respuesta es sí, entonces este tutorial está diseñado para ti. Nos sumergiremos de lleno en el corazón de la modernidad de Vue 3, explorando script setup y sus compañeros esenciales (defineProps, defineEmits, ref, reactive, computed, watch, watchEffect) para que no solo entiendas cómo usarlos, sino que también aprecies el porqué de su importancia en la construcción de aplicaciones Vue de alto rendimiento y fácil mantenimiento. Prepárate para descubrir un paradigma de desarrollo más limpio, rápido y, en mi opinión, mucho más intuitivo.

La revolución de `script setup`

a laptop computer sitting on top of a table

El lanzamiento de Vue 3 y, en particular, la introducción de script setup, ha marcado un antes y un después en la forma en que los desarrolladores de Vue escriben sus componentes. Anteriormente, la Options API requería organizar la lógica del componente en varias secciones bien definidas: data, methods, computed, props, emits, setup, etc. Si bien esto ofrecía una estructura clara, podía llevar a una fragmentación de la lógica relacionada con una característica específica a lo largo de todo el componente.

script setup aborda este problema de frente. Se trata de un azúcar sintáctico que simplifica enormemente el uso de la Composition API dentro de los Single File Components (SFC). La idea principal es que la mayoría del código que escribimos en la sección <script setup> es compilado directamente en la función setup() de Vue, lo que significa que no necesitamos devolver explícitamente las propiedades y métodos para que estén disponibles en la plantilla. Todo lo que se declara en la raíz de <script setup> (variables, funciones, importaciones) se expone automáticamente a la plantilla.

Las ventajas son múltiples y significativas:

  • Concisión: Mucho menos código repetitivo. No hay necesidad de definir la función setup ni de devolver explícitamente nada.
  • Rendimiento: El código dentro de script setup se compila de una manera más eficiente, lo que puede resultar en un mejor rendimiento en tiempo de ejecución.
  • Mejor inferencia de tipos con TypeScript: La estructura plana de script setup facilita enormemente la inferencia de tipos, lo que lo convierte en un aliado formidable para proyectos con TypeScript.
  • Colocación de la lógica: Permite agrupar la lógica relacionada en un solo lugar, mejorando la legibilidad y mantenibilidad del código, especialmente en componentes complejos.

Veamos un ejemplo básico de cómo un componente simple se vería con script setup:

<template>
  <div class="saludo-componente">
    <h2>{{ mensaje }}</h2>
    <button @click="cambiarMensaje">Cambiar saludo</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

// Definimos una variable reactiva con ref
const mensaje = ref('¡Hola desde Vue 3!');

// Definimos una función
const cambiarMensaje = () => {
  mensaje.value = '¡El mensaje ha cambiado!';
};
</script>

<style scoped>
.saludo-componente {
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
  text-align: center;
  margin-bottom: 20px;
}
h2 {
  color: #42b983;
}
button {
  background-color: #42b983;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 5px;
  cursor: pointer;
  font-size: 1em;
}
button:hover {
  background-color: #36a477;
}
</style>

En este ejemplo, notamos inmediatamente la ausencia de la exportación por defecto, del objeto de configuración tradicional y de la función setup. Simplemente importamos ref, declaramos mensaje y cambiarMensaje, y ¡listo! Ambos están disponibles directamente en la plantilla. Esto es, en mi opinión, un avance gigantesco hacia una forma de programar más directa y menos ceremonial. Para una comprensión más profunda de la Composition API y script setup, recomiendo encarecidamente revisar la documentación oficial de Vue.js sobre la Composition API.

Propiedades y eventos en la era `script setup`

La comunicación entre componentes es una piedra angular de cualquier aplicación moderna. En Vue.js, esto se logra principalmente a través de propiedades (props) que fluyen hacia abajo y eventos (emits) que fluyen hacia arriba. Con script setup, la forma de declarar y utilizar estos mecanismos también ha sido simplificada y mejorada.

Definición de propiedades con `defineProps`

defineProps es un macro de compilación que se utiliza para declarar las propiedades que un componente puede recibir. No necesita ser importado; está disponible globalmente dentro de <script setup>. Su uso no solo hace que el código sea más legible, sino que también ofrece una excelente integración con TypeScript, permitiendo definir los tipos de las props de manera muy natural.

Aquí un ejemplo:

<template>
  <div class="tarjeta-usuario">
    <h3>{{ nombreUsuario }}</h3>
    <p>Edad: {{ edadUsuario }}</p>
    <p>Email: {{ emailUsuario }}</p>
    <button @click="mostrarDetalles">Ver más</button>
  </div>
</template>

<script setup>
import { defineProps } from 'vue'; // Nota: defineProps no necesita ser importado explícitamente, pero es buena práctica para la claridad.

// Declaración de props utilizando defineProps
const props = defineProps({
  nombreUsuario: {
    type: String,
    required: true
  },
  edadUsuario: {
    type: Number,
    default: 0
  },
  emailUsuario: {
    type: String,
    required: false
  }
});

// También se puede hacer con una interfaz de TypeScript para una mayor robustez
/*
interface Props {
  nombreUsuario: string;
  edadUsuario?: number; // Opcional
  emailUsuario?: string;
}
const props = defineProps<Props>();
*/

const mostrarDetalles = () => {
  alert(`Detalles de ${props.nombreUsuario}: Edad ${props.edadUsuario}, Email ${props.emailUsuario || 'No proporcionado'}`);
};
</script>

<style scoped>
.tarjeta-usuario {
  border: 1px solid #ddd;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 10px;
  background-color: #f9f9f9;
}
h3 {
  color: #333;
}
p {
  color: #555;
}
button {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 8px 15px;
  border-radius: 5px;
  cursor: pointer;
}
</style>

Como puedes ver, defineProps se invoca directamente y el objeto de configuración es similar al que usaríamos en la Options API. Las propiedades declaradas se acceden a través del objeto props retornado. La sintaxis con interfaces de TypeScript es especialmente potente para proyectos grandes, ya que ofrece autocompletado y validación en tiempo de desarrollo. La documentación de Vue sobre propiedades es un recurso excelente para explorar todas las opciones.

Emisión de eventos con `defineEmits`

De manera similar a defineProps, defineEmits es otro macro de compilación que se utiliza para declarar los eventos que un componente puede emitir a su padre. También está disponible globalmente en <script setup> sin necesidad de importación.

<template>
  <div class="contador-interactivo">
    <h3>Contador: {{ contador }}</h3>
    <button @click="incrementar">Incrementar</button>
    <button @click="resetear">Resetear</button>
  </div>
</template>

<script setup>
import { ref, defineEmits } from 'vue'; // defineEmits tampoco necesita ser importado.

const contador = ref(0);

// Declaración de eventos que el componente puede emitir
const emit = defineEmits(['incremento', 'reseteo']);

const incrementar = () => {
  contador.value++;
  emit('incremento', contador.value); // Emitimos el evento 'incremento' con el valor actual
};

const resetear = () => {
  contador.value = 0;
  emit('reseteo'); // Emitimos el evento 'reseteo'
};
</script>

<style scoped>
.contador-interactivo {
  border: 1px solid #ccc;
  padding: 20px;
  border-radius: 10px;
  text-align: center;
  background-color: #f0f0f0;
}
h3 {
  color: #2c3e50;
  margin-bottom: 15px;
}
button {
  background-color: #28a745;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 5px;
  cursor: pointer;
  font-size: 1em;
  margin: 0 5px;
}
button:hover {
  background-color: #218838;
}
</style>

Al igual que con defineProps, el valor de retorno de defineEmits es una función emit que se utiliza para disparar los eventos. Esto proporciona una manera limpia y explícita de definir la interfaz de eventos de un componente, lo cual es muy útil para la comunicación entre componentes y para mantener la claridad del código, especialmente cuando se trabaja en equipos o en proyectos de gran envergadura.

Gestión del estado reactivo: `ref` y `reactive`

En el corazón de Vue.js reside su sistema de reactividad, que permite que la interfaz de usuario se actualice automáticamente cuando los datos cambian. Con la Composition API, tenemos dos funciones principales para crear estado reactivo: ref y reactive. Comprender cuándo y cómo usar cada una es fundamental para construir aplicaciones Vue eficientes.

  • ref: Se utiliza para crear una referencia reactiva a cualquier tipo de valor. Es especialmente útil para valores primitivos (strings, numbers, booleans) y para cualquier tipo de dato cuando quieres que el seguimiento de reactividad se aplique a la referencia en sí, no solo a sus propiedades. Cuando accedes o modificas el valor de un ref en JavaScript, debes hacerlo a través de su propiedad .value. Sin embargo, en las plantillas de Vue, los ref se unwrappean automáticamente, por lo que no necesitas .value.

  • reactive: Se utiliza para crear un objeto reactivo. Solo funciona con objetos (incluyendo arrays y Map/Set si se usa reactive sobre ellos, aunque para arrays y colecciones primitivas, ref suele ser más sencillo). A diferencia de ref, reactive no requiere el .value para acceder a sus propiedades, pero si reemplazas todo el objeto reactivo por uno nuevo, perderás la reactividad.

Aquí un ejemplo que muestra ambos:

<template>
  <div class="estado-reactivo">
    <h3>Información del usuario (con reactive):</h3>
    <p>Nombre: {{ usuario.nombre }}</p>
    <p>Edad: {{ usuario.edad }}</p>
    <button @click="actualizarUsuario">Actualizar usuario</button>

    <h3>Contador (con ref):</h3>
    <p>Valor: {{ contadorRef }}</p>
    <button @click="incrementarRef">Incrementar contador</button>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue';

// Uso de reactive para un objeto
const usuario = reactive({
  nombre: 'Juan Pérez',
  edad: 30
});

const actualizarUsuario = () => {
  usuario.nombre = 'María García';
  usuario.edad = 25;
};

// Uso de ref para un valor primitivo
const contadorRef = ref(0);

const incrementarRef = () => {
  contadorRef.value++; // Acceso con .value en script
};
</script>

<style scoped>
.estado-reactivo {
  border: 1px solid #e0e0e0;
  padding: 20px;
  border-radius: 10px;
  margin-top: 20px;
  background-color: #fcfcfc;
}
.estado-reactivo h3 {
  color: #333;
}
.estado-reactivo p {
  color: #666;
  margin: 5px 0;
}
.estado-reactivo button {
  background-color: #6c757d;
  color: white;
  border: none;
  padding: 8px 15px;
  border-radius: 5px;
  cursor: pointer;
  margin-right: 10px;
}
.estado-reactivo button:hover {
  background-color: #5a6268;
}
</style>

Mi opinión personal es que ref tiende a ser más versátil y fácil de usar en la mayoría de los escenarios, especialmente porque su comportamiento de unwrapping automático en la plantilla simplifica mucho las cosas. Sin embargo, reactive brilla cuando tienes un objeto complejo y quieres mantener todas sus propiedades reactivas sin necesidad de envolver cada una en un ref individual. Lo importante es ser consistente dentro de un mismo contexto. Para una explicación más detallada sobre las diferencias, consulta la documentación oficial de Vue sobre ref vs reactive.

Operaciones asíncronas y efectos secundarios con `watch` y `watchEffect`

En el desarrollo de aplicaciones web, es común necesitar realizar acciones o "efectos secundarios" en respuesta a cambios en el estado reactivo, o después de que un componente se monte en el DOM. Vue 3 nos proporciona watch y watchEffect para manejar estos escenarios, junto con ganchos del ciclo de vida como onMounted.

Consumo de una API con `fetch` y `onMounted`

Uno de los casos de uso más frecuentes para los efectos secundarios es la carga de datos de una API cuando un componente se inicializa. onMounted es el gancho perfecto para esto.

<template>
  <div class="lista-posts">
    <h3>Posts (API externa)</h3>
    <p v-if="cargando">Cargando posts...</p>
    <p v-else-if="error">Error al cargar: {{ error.message }}</p>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        <strong>{{ post.title }}</strong> - {{ post.body.substring(0, 50) }}...
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const posts = ref([]);
const cargando = ref(true);
const error = ref(null);

onMounted(async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    posts.value = await response.json();
  } catch (err) {
    error.value = err;
  } finally {
    cargando.value = false;
  }
});
</script>

<style scoped>
.lista-posts {
  border: 1px solid #dcdcdc;
  padding: 20px;
  border-radius: 10px;
  margin-top: 20px;
  background-color: #fff;
}
.lista-posts h3 {
  color: #3f51b5;
}
.lista-posts ul {
  list-style: none;
  padding: 0;
}
.lista-posts li {
  background-color: #e8eaf6;
  margin-bottom: 8px;
  padding: 10px;
  border-radius: 5px;
}
.lista-posts p {
  color: #777;
}
</style>

Aquí, onMounted asegura que la llamada a la API solo se realice una vez que el componente ha sido montado en el DOM, evitando posibles problemas si la lógica intentara interactuar con el DOM antes de que este esté listo.

Reacción a cambios con `watch`

watch nos permite reaccionar a cambios específicos en una o más fuentes de datos reactivas. Es útil cuando necesitas realizar una acción asíncrona o costosa solo cuando ciertos datos cambian. Puedes especificar qué fuentes de datos observar y qué función ejecutar cuando cambian.

<template>
  <div class="observador-reactivo">
    <h3>Observador de valor</h3>
    <p>Valor de entrada: <input type="text" v-model="busqueda" /></p>
    <p v-if="procesandoBusqueda">Buscando "{{ busquedaActual }}"...</p>
    <p v-else-if="resultadoBusqueda">Resultados para "{{ busquedaActual }}": {{ resultadoBusqueda }}</p>
    <p v-else>Escribe algo para buscar...</p>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue';

const busqueda = ref('');
const busquedaActual = ref('');
const procesandoBusqueda = ref(false);
const resultadoBusqueda = ref(null);

watch(busqueda, (nuevoValor, viejoValor) => {
  if (nuevoValor.length > 2) {
    procesandoBusqueda.value = true;
    busquedaActual.value = nuevoValor;
    console.log(`Buscando: ${nuevoValor} (anterior: ${viejoValor})`);
    // Simulamos una llamada a API
    setTimeout(() => {
      resultadoBusqueda.value = `Encontrados 5 elementos para "${nuevoValor}"`;
      procesandoBusqueda.value = false;
    }, 1000);
  } else {
    resultadoBusqueda.value = null;
    busquedaActual.value = '';
  }
});
</script>

<style scoped>
.observador-reactivo {
  border: 1px solid #d3e0f0;
  padding: 20px;
  border-radius: 10px;
  margin-top: 20px;
  background-color: #f7fafd;
}
.observador-reactivo h3 {
  color: #1e3a5f;
}
.observador-reactivo input[type="text"] {
  padding: 8px;
  border: 1px solid #a7c7ed;
  border-radius: 4px;
  width: 250px;
}
.observador-reactivo p {
  color: #4a6c8e;
}
</style>

Este ejemplo muestra cómo watch puede reaccionar a los cambios en la variable busqueda para simular una búsqueda, útil para funcionalidades como el autocompletado o filtros en tiempo real.

`watchEffect` para efectos automáticos