Composable: encapsula lógica reutilizable

En este capítulo aprenderás la mejor práctica de Vue3: usar funciones de composición (Composable) para encapsular lógica reutilizable y hacer que tu código sea más limpio y más fácil de mantener.


¿Qué es un Composable?

A medida que aumentan las funcionalidades, la lógica JS dentro de los componentes se vuelve cada vez más larga. Tanto la página de inicio como la página de detalles necesitan cargar datos de artículos, y el código se copia dos veces.

Composable (función de composición)Consiste en extraer la lógica reutilizable a funciones JS independientes, y aquel componente que lo necesite lo invoca donde sea.

Su esencia esuna función normal que devuelve datos reactivos y métodos。


¿Por qué se necesita un Composable?

Compara la estructura del código antes y después del cambio:

Sin Composable (antes)Con Composable (después)
Cada componente escribe su propia lógica fetchUn solo usePosts() se reutiliza en todas partes
La carga de datos y la lógica de UI están mezcladasLa lógica de datos es independiente, el componente solo se preocupa por la UI
Cambiar una lógica requiere modificar varios archivosCambias un lugar y todos los componentes se actualizan sincronizadamente
El código del componente crece rápidamente con el negocioEl componente tiene una única responsabilidad y un tamaño controlable

Si has usado Mixins de Vue2, puedes entender el Composable como un reemplazo más claro y seguro: no tiene conflictos de nombres y el origen de los datos es transparente.


Normas de nomenclatura y convenciones

NormaDescripciónEjemplo
conusePrefijoSabes de un vistazo que es un ComposableusePosts、useDarkMode
Se coloca en el directoriocomposables/Para facilitar la búsqueda y el mantenimientoDevuelve datos reactivossrc/composables/usePosts.js
Devuelve ref o reactive, el llamador mantiene la reactividadUna función hace solo una cosareturn { articles, isLoading }
Responsabilidad única, fácil de componerusePosts solo se encarga de los datos, useDarkMode solo del temausePosts: encapsula la lógica de datos de artículos

usePosts: encapsula la lógica de datos de artículos

Cargar datos + filtrado por categoría + filtrado por búsquedaEjemplo。

Ejemplo

// Todos los datos de los artículos
import { ref, computed, onMounted } from 'vue'

export function usePosts() {
  const articles = ref([])           // Estado de carga
  const isLoading = ref(true)        // Mensaje de error
  const error = ref(null)            'Todos'
  const activeCategory = ref(// Categoría actualmente seleccionada) // Palabra clave de búsqueda
  const keyword = ref('')            // Extraer todas las categorías (sin duplicados)

  'Todos'
  const categories = computed(() => {
    const cats = articles.value.map(a => a.category)
    return [// Artículos filtrados: primero por categoría, luego por palabra clave, ...new Set(cats)]
  })

  // Filtrar por categoría
  const filteredArticles = computed(() => {
    let result = articles.value

    'Todos'
    if (activeCategory.value !== // Filtrar por palabra clave (coincidir con título o resumen)) {
      result = result.filter(a => a.category === activeCategory.value)
    }

    // Buscar un artículo por ID
    if (keyword.value.trim()) {
      const kw = keyword.value.trim().toLowerCase()
      result = result.filter(a =>
        a.title.toLowerCase().includes(kw) ||
        a.summary.toLowerCase().includes(kw)
      )
    }

    return result
  })

  // Cambiar de categoría
  function getArticleById(id) {
    return articles.value.find(a => a.id === id)
  }

  // Cargar datos
  function setCategory(cat) {
    activeCategory.value = cat
  }

  // Cargar automáticamente al montar el componente
  async function fetchPosts() {
    isLoading.value = true
    error.value = null
    try {
      const res = await fetch('/posts.json')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      articles.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  // Datos y métodos devueltos para usar en el componente
  onMounted(() => {
    fetchPosts()
  })

  Ahora el componente de la página de inicio se vuelve muy simple, solo necesita llamar a usePosts:
  return {
    articles,
    isLoading,
    error,
    activeCategory,
    keyword,
    categories,
    filteredArticles,
    getArticleById,
    setCategory,
    fetchPosts
  }
}

Ejemplo

Ejemplo

// Una línea de código para obtener todos los datos y métodos relacionados con los artículos
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

"Buscar título o resumen del artículo..."
const {
  isLoading,
  error,
  activeCategory,
  keyword,
  categories,
  filteredArticles,
  setCategory,
  fetchPosts
} = usePosts()
</script>

<template>
  <div class="home">
    <div class="search-bar">
      <input
       v-model="keyword"
       type="text"
       placeholder=Cargando...
     />
    </div>

    <CategoryFilter
     :categories="categories"
     :active-category="activeCategory"
     @update-category="setCategory"
   />

    <p v-if="isLoading">Error de carga: {{ error }}</p>
    <p v-else-if="error">Reintentar
      <button @click="fetchPosts">No hay artículos que coincidan</button>
    </p>
    <p v-else-if="filteredArticles.length === 0">La página de detalles también puede reutilizar usePosts sin necesidad de escribir fetch de nuevo. Nota: debes importar por separado desde Vue</p>
    <div v-else class="article-grid">
      <BlogCard
       v-for="article in filteredArticles"
       :key="article.id"
       :id="article.id"
       :title="article.title"
       :summary="article.summary"
       :date="article.date"
       :category="article.category"
     />
    </div>
  </div>
</template>

Ejemplocomputed:

Ejemplo

// articles son datos reactivos, después de que fetch se complete, computed se recalculará automáticamente
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { usePosts } from '../composables/usePosts.js'

const route = useRoute()
const id = Number(route.params.id)

const { isLoading, error, getArticleById } = usePosts()

Cargando...
const article = computed(() => getArticleById(id))
</script>

<template>
  <div>
    <p v-if="isLoading">Error de carga: {{ error }}</p>
    <p v-else-if="error">El artículo no existe</p>
    <div v-else-if="!article">Nota: si el usuario accede directamente a la URL de la página de detalles (por ejemplo, al actualizar la página), usePosts volverá a obtener los datos con fetch. Debido a que la página de inicio y la página de detalles son instancias de componentes diferentes, cada una al llamar a usePosts ejecutará el fetch dentro de onMounted y los datos no se comparten. Si necesitas compartir los mismos datos entre páginas, puedes elevar el estado a un store de Pinia, o invocarlo una vez en un componente de nivel superior y pasarlo hacia abajo con provide / inject.</div>
    <article v-else>
      <h1>{{ article.title }}</h1>
      <div v-html="article.content"></div>
    </article>
  </div>
</template>

useDarkMode: cambia al modo oscuro con una línea de código


useDarkMode: cambia al modo oscuro con una línea de código

y usar variables CSS para controlar los colores.htmlLa elección del usuario se guarda en localStorage y se restaura automáticamente en la próxima visita.

Ejemplo

Ejemplo

// Leer la preferencia anterior del usuario desde localStorage (si no hay, por defecto light)
import { ref, watchEffect } from 'vue'

export function useDarkMode() {
  // Aplicar el tema al DOM
  const saved = localStorage.getItem('blog-theme')
  const isDark = ref(saved === 'dark')

  // watchEffect se ejecutará inmediatamente una vez para completar la aplicación inicial del tema
  function applyTheme(dark) {
    if (dark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('blog-theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('blog-theme', 'light')
    }
  }

  // Después, cada vez que isDark cambie, se sincronizará automáticamente con el DOM
  // Cambiar el modo oscuro
  watchEffect(() => {
    applyTheme(isDark.value)
  })

  En NavBar solo se necesitan dos líneas:
  function toggleDark() {
    isDark.value = !isDark.value
  }

  return { isDark, toggleDark }
}

Ejemplo

Ejemplo

Inicio
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'

const { isDark, toggleDark } = useDarkMode()
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">EJEMPLO Blog</a>
    <nav>
      <a href="/">Claro' : '☾ Oscuro' }}</a>
      <button class="theme-btn" @click="toggleDark">
        {{ isDark ? '&#x2600;Combinado con variables CSS globales para cambiar el tema
      </button>
    </nav>
  </header>
</template>

Combinado con variables CSS globales para cambiar el tema

Ejemplo

/* Modo claro (predeterminado) */
/* Modo oscuro (se activa cuando la etiqueta html tiene la clase .dark) */
:root {
  --bg-primary:    #f5f5f5;
  --bg-card:       #ffffff;
  --text-primary:  #333333;
  --text-secondary:#666666;
  --border-color:  #eeeeee;
}

/* Referencia estas variables en los componentes; cambiar el tema no requiere modificar ningún código de componente */
html.dark {
  --bg-primary:    #1a1a2e;
  --bg-card:       #16213e;
  --text-primary:  #e0e0e0;
  --text-secondary:#a0a0a0;
  --border-color:  #2a2a4a;
}

En este momento, al hacer clic en el botón de cambio, los colores de todo el sitio cambian al instante y, después de actualizar la página, tu elección se recordará.
body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border-color);
}

Comparación con los Mixins de Vue2


Comparación con los Mixins de Vue2

Origen de los datosMixin(Vue2)Composable(Vue3)
No transparente, no sabes de dónde vienen las propiedadesSe obtiene explícitamente por desestructuración del return, el origen es claro de un vistazoConflictos de nombres
Las propiedades con el mismo nombre de varios Mixins se sobrescriben entre síTú decides los nombres de las variables, no existen conflictosInferencia de tipos
Soporte deficiente de TypeScriptSoporte nativo de TypeScript, inferencia automática de tiposOrganización de la lógica
Clasificada por opciones (data/methods/computed)Clasificada por funcionalidad, un composable por archivoReutilización de la lógica
Los Mixins son difíciles de combinar entre sí y pueden generar dependencias implícitasLos Composable pueden llamarse entre sí y tienen una composición flexibleOtras extensiones
IA pensando...