Composable — инкапсуляция переиспользуемой логики
В этой главе вы изучите лучшую практику Vue3 — использование composable-функций для инкапсуляции переиспользуемой логики, что делает код чище и проще в поддержке.
Что такое композабл?
По мере роста функциональности JS-логика внутри компонентов становится длиннее. И главной странице, и странице сведений нужно загружать данные статьи, поэтому код дублируется дважды.
Композабл (composable-функция)Он выносит переиспользуемую логику в независимые JS-функции, и любой компонент, которому это нужно, может вызвать её у себя.
По сути этообычная функция, которая возвращает реактивные данные и методы.。
Зачем нужен композабл?
Сравните структуру кода до и после изменения:
| Без композабла (до) | С композаблом (после) |
|---|---|
| Каждый компонент пишет свою собственную логику получения данных | Один usePosts() переиспользуется везде |
| Загрузка данных и логика UI смешаны вместе | Логика данных независима, компоненты заботятся только об UI |
| Изменение одной части логики требует изменения нескольких файлов | Измените в одном месте — все компоненты обновляются синхронно |
| Код компонента быстро разрастается по мере роста бизнес-логики | Код компонента имеет единственную ответственность, а его размер контролируем |
Если вы использовали Mixins из Vue2, Composable можно понять как более понятную и безопасную альтернативу — в ней нет конфликтов имен, а источник данных прозрачен.
Соглашения об именовании и правила
| Правило | Описание | Пример |
|---|---|---|
withuseПрефикс | Сразу видно, что это Composable | usePosts、useDarkMode |
Поместите вcomposables/каталога | Легко находить и поддерживать | src/composables/usePosts.js |
| Возвращайте реактивные данные | Возвращайте ref или reactive, чтобы вызывающая сторона оставалась реактивной | return { articles, isLoading } |
| Одна функция делает только одно | Единственная ответственность, легко компоновать | usePosts отвечает только за данные, useDarkMode — только за темы |
usePosts — инкапсуляция логики данных статей
Этот композабл отвечает за:Загрузка данных + фильтрация по категориям + фильтрация по поиску。
Пример
import { ref, computed, onMounted } from 'vue'
export function usePosts() {
const articles = ref([]) // Все данные статей
const isLoading = ref(true) // Состояние загрузки
const error = ref(null) // Сообщение об ошибке
const activeCategory = ref('All') // Текущая выбранная категория
const keyword = ref('') // Ключевое слово для поиска
// Извлечь все категории (удалить дубликаты)
const categories = computed(() => {
const cats = articles.value.map(a => a.category)
return ['All', ...new Set(cats)]
})
// Отфильтрованные статьи: сначала по категории, затем по ключевому слову
const filteredArticles = computed(() => {
let result = articles.value
// Фильтр по категории
if (activeCategory.value !== 'All') {
result = result.filter(a => a.category === activeCategory.value)
}
// Фильтр по ключевому слову (совпадение с заголовком или кратким описанием)
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
})
// Найти одну статью по ID
function getArticleById(id) {
return articles.value.find(a => a.id === id)
}
// Переключить категорию
function setCategory(cat) {
activeCategory.value = cat
}
// Загрузить данные
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
}
}
// Автоматически загружать при монтировании компонента
onMounted(() => {
fetchPosts()
})
// Возвращать данные и методы для использования компонентами
return {
articles,
isLoading,
error,
activeCategory,
keyword,
categories,
filteredArticles,
getArticleById,
setCategory,
fetchPosts
}
}
Теперь компонент главной страницы становится очень лаконичным: достаточно вызвать usePosts:
Пример
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'
// Получить все данные и методы, связанные со статьями, одной строкой кода
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="Поиск по заголовку или краткому описанию статьи..."
/>
</div>
<CategoryFilter
:categories="categories"
:active-category="activeCategory"
@update-category="setCategory"
/>
<p v-if="isLoading">Загрузка...</p>
<p v-else-if="error">Не удалось загрузить: {{ error }}
<button @click="fetchPosts">Повторить</button>
</p>
<p v-else-if="filteredArticles.length === 0">Нет подходящих статей</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>
Страница подробностей также может повторно использовать usePosts без повторного написания fetch. Обратите внимание, что его нужно импортировать отдельно из Vuecomputed:
Пример
<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()
// articles — реактивные данные, computed автоматически пересчитается после завершения fetch
const article = computed(() => getArticleById(id))
</script>
<template>
<div>
<p v-if="isLoading">Загрузка...</p>
<p v-else-if="error">Не удалось загрузить: {{ error }}</p>
<div v-else-if="!article">Статья не существует</div>
<article v-else>
<h1>{{ article.title }}</h1>
<div v-html="article.content"></div>
</article>
</div>
</template>
Примечание: если пользователь напрямую открывает URL страницы подробной информации (например, обновляет страницу), usePosts снова получит данные. Поскольку главная страница и страница подробной информации — это разные экземпляры компонентов, каждый вызов usePosts выполняет запрос в onMounted, и данные не являются общими. Если вам нужно совместно использовать одни и те же данные на разных страницах, вы можете поднять состояние в хранилище Pinia или вызвать его один раз в компоненте верхнего уровня и передать вниз через provide / inject.
useDarkMode — Переключение тёмного режима одной строкой кода
Основная идея тёмного режима: наhtmlтег, добавьте класс, затем используйте CSS-переменные для управления цветами.
Выбор пользователя сохраняется в localStorage и автоматически восстанавливается при следующем посещении.
Пример
import { ref, watchEffect } from 'vue'
export function useDarkMode() {
// Прочитать предыдущую настройку пользователя из localStorage (по умолчанию — светлая, если её нет)
const saved = localStorage.getItem('blog-theme')
const isDark = ref(saved === 'dark')
// Применить тему к DOM
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')
}
}
// watchEffect выполняется один раз сразу, чтобы применить начальную тему
// После этого, каждый раз, когда isDark изменяется, автоматически синхронизировать с DOM
watchEffect(() => {
applyTheme(isDark.value)
})
// Переключить темный режим
function toggleDark() {
isDark.value = !isDark.value
}
return { isDark, toggleDark }
}
Чтобы использовать его в NavBar, нужны всего две строки:
Пример
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'
const { isDark, toggleDark } = useDarkMode()
</script>
<template>
<header class="navbar">
<a href="/" class="logo">ПРИМЕР Блог</a>
<nav>
<a href="/">Главная</a>
<button class="theme-btn" @click="toggleDark">
{{ isDark ? '☀`Светлая' : '☾ Темная' }}`
</button>
</nav>
</header>
</template>
Объедините с глобальными CSS-переменными для реализации переключения темы
Пример
/* Светлая тема (по умолчанию) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary:#666666;
--border-color: #eeeeee;
}
/* Темный режим (применяется, когда тег html имеет класс .dark) */
html.dark {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary:#a0a0a0;
--border-color: #2a2a4a;
}
/* Ссылайтесь на эти переменные в компонентах, не нужно изменять код компонентов при переключении тем */
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
}
На этом этапе нажатие кнопки переключения мгновенно изменит цвета всего сайта, а ваш выбор сохранится после обновления.
Сравнение с Vue2 Mixin
| Характеристика | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| Источник данных | Непрозрачно, вы не знаете, откуда берутся свойства | Явное получение через деструктуризацию из return, источник виден с первого взгляда |
| Конфликт имен | Одноименные свойства из нескольких Mixin будут перезаписывать друг друга | Вы сами задаёте имена переменных, конфликтов не возникает |
| Вывод типов | Слабая поддержка TypeScript | Встроенная поддержка TypeScript, автоматический вывод типов |
| Организация логики | Категоризация по опциям (data/methods/computed) | Категоризация по функциональности, один композабл на файл |
| Повторное использование логики | Миксины сложно комбинировать, и они склонны создавать неявные зависимости | Композаблы могут вызывать друг друга, гибкая композиция |