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 fetch | Un solo usePosts() se reutiliza en todas partes |
| La carga de datos y la lógica de UI están mezcladas | La lógica de datos es independiente, el componente solo se preocupa por la UI |
| Cambiar una lógica requiere modificar varios archivos | Cambias un lugar y todos los componentes se actualizan sincronizadamente |
| El código del componente crece rápidamente con el negocio | El 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
| Norma | Descripción | Ejemplo |
|---|---|---|
conusePrefijo | Sabes de un vistazo que es un Composable | usePosts、useDarkMode |
Se coloca en el directoriocomposables/Para facilitar la búsqueda y el mantenimiento | Devuelve datos reactivos | src/composables/usePosts.js |
| Devuelve ref o reactive, el llamador mantiene la reactividad | Una función hace solo una cosa | return { articles, isLoading } |
| Responsabilidad única, fácil de componer | usePosts solo se encarga de los datos, useDarkMode solo del tema | usePosts: 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
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
<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
<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
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
<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 ? '☀Combinado con variables CSS globales para cambiar el tema
</button>
</nav>
</header>
</template>
Combinado con variables CSS globales para cambiar el tema
Ejemplo
/* 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 datos | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| No transparente, no sabes de dónde vienen las propiedades | Se obtiene explícitamente por desestructuración del return, el origen es claro de un vistazo | Conflictos de nombres |
| Las propiedades con el mismo nombre de varios Mixins se sobrescriben entre sí | Tú decides los nombres de las variables, no existen conflictos | Inferencia de tipos |
| Soporte deficiente de TypeScript | Soporte nativo de TypeScript, inferencia automática de tipos | Organización de la lógica |
| Clasificada por opciones (data/methods/computed) | Clasificada por funcionalidad, un composable por archivo | Reutilización de la lógica |
| Los Mixins son difíciles de combinar entre sí y pueden generar dependencias implícitas | Los Composable pueden llamarse entre sí y tienen una composición flexible | Otras extensiones |