En esta página

Componentes SFC y script setup

15 min lectura TextoCap. 1 — Fundamentos de Vue

¿Qué es un Single File Component?

Un Single File Component (SFC) es el formato nativo de Vue para escribir componentes. Un archivo .vue encapsula tres bloques en un solo archivo:

  • <script setup> — La lógica del componente (TypeScript)
  • <template> — La estructura HTML del componente
  • <style scoped> — Los estilos CSS del componente

Esta colocación tiene enormes ventajas: todo lo relacionado con un componente vive junto, los estilos son automáticamente aislados, y los editores como VS Code (con la extensión Volar/Vue Official) ofrecen soporte completo de sintaxis, autocompletado y refactoring.

Anatomía de un SFC

El bloque script setup

<script setup lang="ts">
import { ref } from 'vue'

const saludo = ref('¡Hola, Vue!')
</script>

lang="ts" activa TypeScript. La directiva setup hace que todo lo declarado en este bloque esté automáticamente disponible en el template, sin necesidad de return {} como en la Options API.

El bloque template

<template>
  <div>
    <p>{{ saludo }}</p>
  </div>
</template>

El template puede tener múltiples elementos raíz (fragments), a diferencia de Vue 2 que requería un solo elemento raíz. Usa la sintaxis de dobles llaves {{ }} para la interpolación de texto.

El bloque style scoped

<style scoped>
p {
  color: #6366f1;
  font-size: 1.25rem;
}
</style>

La palabra clave scoped es crucial: hace que estos estilos solo afecten a este componente. Sin scoped, los estilos son globales y pueden afectar toda la aplicación.

Crear un proyecto con create-vue

create-vue es el andamio oficial basado en Vite:

npm create vue@latest mi-proyecto
cd mi-proyecto
npm install
npm run dev

Durante el proceso interactivo, selecciona:

  • ✅ TypeScript
  • ✅ Vue Router (para navegación)
  • ✅ Pinia (para estado global)
  • ✅ ESLint (para calidad de código)
  • ✅ Vitest (para testing)

El punto de entrada: main.ts

Toda aplicación Vue comienza con main.ts. Aquí se crea la instancia de la aplicación, se registran plugins y se monta en el DOM:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'

const app = createApp(App)

app.use(createPinia()) // Plugin de estado global
app.use(router)        // Plugin de enrutamiento

app.mount('#app')      // Monta en el elemento con id="app"

El orden importa: los plugins deben usarse antes de mount().

Componentes hijos e importación

En Vue SFC, importar un componente lo hace disponible directamente en el template gracias a script setup:

<script setup lang="ts">
// Solo importar es suficiente — no hay que registrar en components: {}
import MiBoton from './components/MiBoton.vue'
import TarjetaUsuario from './components/TarjetaUsuario.vue'
</script>

<template>
  <!-- Uso en PascalCase (recomendado) o kebab-case -->
  <TarjetaUsuario nombre="Ana" />
  <mi-boton>Clic aquí</mi-boton>
</template>

La convención es usar PascalCase para los componentes en el template. Vue acepta ambos (PascalCase y kebab-case), pero PascalCase distingue visualmente los componentes del HTML nativo.

Props — comunicación padre a hijo

Las props son la forma de pasar datos de un componente padre a un componente hijo. Con script setup y TypeScript, se declaran con defineProps<T>():

<script setup lang="ts">
// Sintaxis de genérico — la más recomendada
const props = defineProps<{
  titulo: string
  subtitulo?: string       // Opcional
  nivel: 1 | 2 | 3         // Union type
}>()

// Acceso: props.titulo, props.subtitulo
console.log(props.titulo)
</script>

Para valores por defecto, usamos withDefaults():

<script setup lang="ts">
const { titulo, nivel = 1 } = withDefaults(
  defineProps<{
    titulo: string
    nivel?: 1 | 2 | 3
  }>(),
  {
    nivel: 1
  }
)
// Con Vue 3.5 también puedes usar desestructuración directa con valores por defecto
</script>

El sistema de slots

Los slots permiten que el componente padre inyecte contenido dentro del componente hijo. Son el equivalente Vue de children en React:

<!-- Componente Panel.vue -->
<template>
  <div class="panel">
    <header>
      <slot name="cabecera">Título por defecto</slot>
    </header>
    <main>
      <slot /> <!-- slot por defecto -->
    </main>
    <footer>
      <slot name="pie" />
    </footer>
  </div>
</template>
<!-- Uso del componente Panel -->
<template>
  <Panel>
    <template #cabecera>
      <h2>Mi panel</h2>
    </template>

    <p>Contenido principal del panel.</p>

    <template #pie>
      <button type="button">Guardar</button>
    </template>
  </Panel>
</template>

defineComponent — el modo alternativo

Si no usas script setup (por ejemplo, al escribir componentes como objetos de configuración puro para TypeScript), puedes usar defineComponent():

import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'MiComponente',
  props: {
    titulo: {
      type: String,
      required: true
    }
  },
  setup(props) {
    const activo = ref(false)
    return { activo }
  }
})

Sin embargo, para proyectos nuevos, script setup es siempre preferible. Es más conciso, tiene mejor soporte de tipos y el compilador de Vue lo optimiza más agresivamente.

Estilos — las opciones disponibles

Vue SFC soporta varias estrategias de estilo:

<!-- CSS puro con scope -->
<style scoped>
.boton { background: blue; }
</style>

<!-- SCSS/SASS -->
<style scoped lang="scss">
.tarjeta {
  &:hover { transform: translateY(-2px); }
}
</style>

<!-- Módulos CSS (alternative a scoped) -->
<style module>
.boton { background: blue; }
</style>

<script setup lang="ts">
import { useCssModule } from 'vue'
const css = useCssModule()
// uso: :class="css.boton"
</script>

El scoped es suficiente para la mayoría de proyectos. Los CSS Modules son útiles cuando necesitas acceder al nombre de clase generado dinámicamente desde JavaScript.

Buenas prácticas con SFC

  1. Nombres en PascalCase: TarjetaUsuario.vue, no tarjeta-usuario.vue
  2. Un componente por archivo: Nunca exportes múltiples componentes desde un SFC
  3. Componentes pequeños: Si un componente supera las 150 líneas de template, considera dividirlo
  4. Props en la interfaz: Siempre tipa las props con TypeScript genérico
  5. Estilos siempre scoped: Evita estilos globales salvo en src/assets/ o src/styles/
  6. Lógica compleja en composables: Si el script setup crece demasiado, extrae la lógica

Práctica

  1. Crea tu primer SFC: Crea un componente src/components/Bienvenida.vue con un prop nombre: string y un estado reactivo contadorVisitas. Muéstralos en el template.
  2. Usa slots: Crea un componente Contenedor.vue con un slot por defecto y un slot nombrado #encabezado. Úsalo desde App.vue con contenido personalizado.
  3. Estilos scoped: Añade estilos al componente Bienvenida.vue y verifica que no afectan a otros elementos de la página. Prueba también ::v-deep() para afectar elementos hijos.

En la siguiente lección aprenderemos toda la sintaxis de templates y las directivas de Vue.

Estilos scoped — cómo funcionan
Cuando usas style scoped, Vue añade un atributo único (por ejemplo data-v-a1b2c3) a cada elemento del componente y prefija los selectores CSS con ese atributo. Esto garantiza que los estilos no se filtren hacia componentes hijos o padres. Si necesitas afectar a un componente hijo, usa el combinador :deep().
defineComponent — ¿cuándo usarlo?
Con script setup, defineComponent() es innecesario; Vue infiere todo automáticamente. Solo necesitas defineComponent() si escribes componentes con la Options API o si usas la sintaxis de función de render. Para el 99% de los casos modernos, script setup es suficiente.
Un componente, un archivo
Aunque técnicamente puedes tener múltiples export en un SFC (con defineComponent y exports adicionales), la convención es un componente por archivo. Esto facilita el tree-shaking, la legibilidad y las herramientas de análisis estático.
vue
<script setup lang="ts">
import { ref, computed } from 'vue'

// Props tipadas con TypeScript genérico
const { nombre, email, rol = 'usuario' } = defineProps<{
  nombre: string
  email: string
  rol?: 'admin' | 'usuario' | 'editor'
}>()

// Estado local reactivo
const expandido = ref(false)

// Estado derivado
const iniciales = computed(() =>
  nombre
    .split(' ')
    .map(p => p[0])
    .join('')
    .toUpperCase()
)

function toggleExpandir(): void {
  expandido.value = !expandido.value
}
</script>

<template>
  <article class="tarjeta" :class="{ expandida: expandido }">
    <div class="avatar">{{ iniciales }}</div>
    <div class="info">
      <h3>{{ nombre }}</h3>
      <span class="rol">{{ rol }}</span>
      <p v-if="expandido">{{ email }}</p>
    </div>
    <button type="button" @click="toggleExpandir">
      {{ expandido ? 'Menos' : 'Más' }}
    </button>
  </article>
</template>

<style scoped>
.tarjeta {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid #e2e8f0;
  border-radius: 0.5rem;
}

.tarjeta.expandida {
  background: #f8fafc;
}

.avatar {
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: #6366f1;
  color: white;
  display: grid;
  place-items: center;
  font-weight: bold;
}
</style>
import { createApp } from 'vue'
import App from './App.vue'

// Crear la aplicación Vue
const app = createApp(App)

// Los plugins se montan antes de mount()
// app.use(router)
// app.use(pinia)

app.mount('#app')