Composable — инкапсуляция переиспользуемой логики

В этой главе вы изучите лучшую практику Vue3 — использование composable-функций для инкапсуляции переиспользуемой логики, что делает код чище и проще в поддержке.


Что такое композабл?

По мере роста функциональности JS-логика внутри компонентов становится длиннее. И главной странице, и странице сведений нужно загружать данные статьи, поэтому код дублируется дважды.

Композабл (composable-функция)Он выносит переиспользуемую логику в независимые JS-функции, и любой компонент, которому это нужно, может вызвать её у себя.

По сути этообычная функция, которая возвращает реактивные данные и методы.。


Зачем нужен композабл?

Сравните структуру кода до и после изменения:

Без композабла (до)С композаблом (после)
Каждый компонент пишет свою собственную логику получения данныхОдин usePosts() переиспользуется везде
Загрузка данных и логика UI смешаны вместеЛогика данных независима, компоненты заботятся только об UI
Изменение одной части логики требует изменения нескольких файловИзмените в одном месте — все компоненты обновляются синхронно
Код компонента быстро разрастается по мере роста бизнес-логикиКод компонента имеет единственную ответственность, а его размер контролируем

Если вы использовали Mixins из Vue2, Composable можно понять как более понятную и безопасную альтернативу — в ней нет конфликтов имен, а источник данных прозрачен.


Соглашения об именовании и правила

ПравилоОписаниеПример
withuseПрефиксСразу видно, что это ComposableusePosts、useDarkMode
Поместите вcomposables/каталогаЛегко находить и поддерживатьsrc/composables/usePosts.js
Возвращайте реактивные данныеВозвращайте ref или reactive, чтобы вызывающая сторона оставалась реактивнойreturn { articles, isLoading }
Одна функция делает только одноЕдинственная ответственность, легко компоноватьusePosts отвечает только за данные, useDarkMode — только за темы

usePosts — инкапсуляция логики данных статей

Этот композабл отвечает за:Загрузка данных + фильтрация по категориям + фильтрация по поиску。

Пример

// Путь к файлу: src/composables/usePosts.js
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:

Пример

<!-- Путь к файлу: src/views/HomeView.vue -->
<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:

Пример

<!-- Путь к файлу: src/views/PostView.vue -->
<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 и автоматически восстанавливается при следующем посещении.

Пример

// Путь к файлу: src/composables/useDarkMode.js
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, нужны всего две строки:

Пример

<!-- Путь к файлу: src/components/NavBar.vue -->
<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 ? '&#x2600;`Светлая' : '☾ Темная' }}`
      </button>
    </nav>
  </header>
</template>

Объедините с глобальными CSS-переменными для реализации переключения темы

Пример

/* Путь к файлу: src/assets/main.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)Категоризация по функциональности, один композабл на файл
Повторное использование логикиМиксины сложно комбинировать, и они склонны создавать неявные зависимостиКомпозаблы могут вызывать друг друга, гибкая композиция
Другие расширения