12. Composables (хуки)
🎣 Composables в Vue 3
Заголовок раздела «🎣 Composables в Vue 3»Composables — это переиспользуемые функции с реактивным состоянием. Если React придумал хуки, Vue сделал то же самое, только назвал иначе. Composable — это функция, которая использует Composition API (ref, watch, onMounted и т.д.) внутри себя и возвращает реактивные данные и методы 🚀
Почему composables?
Заголовок раздела «Почему composables?»До Composition API Vue использовал mixins — и это было ужасно:
Проблемы с mixins:❌ Непонятно, откуда взялось this.someData❌ Конфликты имён между миксинами❌ Невозможно передать параметры❌ Плохая типизация в TypeScript
Composables решают всё это:✅ Явный импорт — видно, откуда данные✅ Нет конфликтов (у каждого вызова своё замыкание)✅ Параметры через аргументы функции✅ Отличная TypeScript поддержкаСоглашение об именовании: use*
Заголовок раздела «Соглашение об именовании: use*»Все composables начинаются с use. Это конвенция (не правило), но её все соблюдают:
✅ useCounter✅ useFetch✅ useLocalStorage✅ useDebounce✅ useMousePosition
❌ getCounter // не composable — нет реактивности❌ mouseTracker // нарушение конвенцииuseMouse — отслеживание мыши
Заголовок раздела «useMouse — отслеживание мыши»Классический первый composable. Инкапсулирует логику отслеживания позиции мыши:
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() { const x = ref(0) const y = ref(0)
function update(event: MouseEvent) { x.value = event.pageX y.value = event.pageY }
onMounted(() => { window.addEventListener('mousemove', update) })
onUnmounted(() => { window.removeEventListener('mousemove', update) })
return { x, y }}<!-- Использование — КРАСОТА! --><script setup lang="ts">import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()</script>
<template> <p>Мышь: {{ x }}, {{ y }}</p></template>Обрати внимание: onMounted и onUnmounted внутри composable привязываются к компоненту, который его вызывает. Магия! ✨
useLocalStorage — персистентное состояние
Заголовок раздела «useLocalStorage — персистентное состояние»import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, initialValue: T) { // Пытаемся получить значение из localStorage const stored = localStorage.getItem(key) const parsed = stored ? (JSON.parse(stored) as T) : initialValue
const state = ref<T>(parsed)
// Синхронизируем с localStorage при изменении watch( state, (newValue) => { if (newValue === null || newValue === undefined) { localStorage.removeItem(key) } else { localStorage.setItem(key, JSON.stringify(newValue)) } }, { deep: true } // Для объектов! )
return state}<script setup lang="ts">import { useLocalStorage } from '@/composables/useLocalStorage'
// Значение автоматически сохраняется в localStorage!const username = useLocalStorage('username', '')const theme = useLocalStorage<'light' | 'dark'>('theme', 'dark')const settings = useLocalStorage('settings', { notifications: true, sound: false,})</script>
<template> <input v-model="username" placeholder="Твоё имя" /> <!-- username сохранится даже после перезагрузки страницы --></template>useFetch — загрузка данных
Заголовок раздела «useFetch — загрузка данных»import { ref, watch, toValue, type MaybeRefOrGetter } from 'vue'
interface FetchState<T> { data: T | null loading: boolean error: Error | null}
export function useFetch<T>(url: MaybeRefOrGetter<string>) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) let abortController: AbortController | null = null
async function fetchData() { // Отменяем предыдущий запрос abortController?.abort() abortController = new AbortController()
loading.value = true error.value = null
try { const response = await fetch(toValue(url), { signal: abortController.signal })
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) }
data.value = await response.json() } catch (e) { if (e instanceof Error && e.name !== 'AbortError') { error.value = e } } finally { loading.value = false } }
// Перезапускаем при изменении URL watch(() => toValue(url), fetchData, { immediate: true })
return { data, loading, error, refresh: fetchData }}<script setup lang="ts">import { ref, computed } from 'vue'import { useFetch } from '@/composables/useFetch'
interface Post { id: number title: string body: string}
const postId = ref(1)
// URL реактивен — при изменении postId автоматически перезагружается!const { data: post, loading, error, refresh } = useFetch<Post>( computed(() => `https://jsonplaceholder.typicode.com/posts/${postId.value}`))</script>
<template> <div> <button @click="postId--" :disabled="postId <= 1">← Пред</button> <button @click="postId++">След →</button> <button @click="refresh">🔄 Обновить</button>
<div v-if="loading">⏳ Загрузка поста #{{ postId }}...</div> <div v-else-if="error">❌ {{ error.message }}</div> <article v-else-if="post"> <h2>{{ post.title }}</h2> <p>{{ post.body }}</p> </article> </div></template>useDebounce — дебаунс ввода
Заголовок раздела «useDebounce — дебаунс ввода»import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(value: Ref<T>, delay = 300) { const debouncedValue = ref<T>(value.value) as Ref<T> let timeout: ReturnType<typeof setTimeout>
watch(value, (newValue) => { clearTimeout(timeout) timeout = setTimeout(() => { debouncedValue.value = newValue }, delay) })
return debouncedValue}<script setup lang="ts">import { ref } from 'vue'import { useDebounce } from '@/composables/useDebounce'import { useFetch } from '@/composables/useFetch'
const searchQuery = ref('')const debouncedQuery = useDebounce(searchQuery, 500)
// Запрос запустится только через 500мс после остановки вводаconst { data: results, loading } = useFetch( computed(() => debouncedQuery.value ? `https://api.example.com/search?q=${debouncedQuery.value}` : '' ))</script>Composables с аргументами
Заголовок раздела «Composables с аргументами»Composables могут принимать как обычные значения, так и ref’ы:
import { ref, computed } from 'vue'
interface CounterOptions { min?: number max?: number step?: number}
export function useCounter(initial = 0, options: CounterOptions = {}) { const { min = -Infinity, max = Infinity, step = 1 } = options
const count = ref(initial)
const increment = () => { count.value = Math.min(count.value + step, max) }
const decrement = () => { count.value = Math.max(count.value - step, min) }
const reset = () => { count.value = initial }
const isMin = computed(() => count.value <= min) const isMax = computed(() => count.value >= max)
return { count, increment, decrement, reset, isMin, isMax, }}<script setup lang="ts">import { useCounter } from '@/composables/useCounter'
const cart = useCounter(0, { min: 0, max: 99 })const steps = useCounter(1, { min: 1, max: 10, step: 1 })</script>
<template> <div> <button @click="cart.decrement" :disabled="cart.isMin.value">−</button> {{ cart.count.value }} <button @click="cart.increment" :disabled="cart.isMax.value">+</button> </div></template>VueUse — библиотека готовых composables
Заголовок раздела «VueUse — библиотека готовых composables»VueUse — это коллекция из 200+ готовых composables. Не изобретай велосипед! 🚲
npm install @vueuse/coreСамые полезные composables из VueUse:
Заголовок раздела «Самые полезные composables из VueUse:»import { // Браузер useLocalStorage, // Персистентное состояние useSessionStorage, // Сессионное хранилище useCookies, // Работа с куками useClipboard, // Буфер обмена useGeolocation, // Геолокация usePermission, // Разрешения браузера useFullscreen, // Полноэкранный режим useWakeLock, // Блокировка сна экрана
// Сенсоры useMouse, // Позиция мыши useMouseInElement, // Мышь в элементе useScroll, // Скролл useIntersectionObserver, // Видимость элемента useResizeObserver, // Изменение размера useKeyModifier, // Клавиши-модификаторы useKeyboard, // Клавиатура useGamepad, // Геймпад!
// Состояние useRefHistory, // История изменений ref useUndoRedo, // Undo/Redo useDebounceFn, // Дебаунс функции useThrottleFn, // Throttle функции useToggle, // Переключатель useCounter, // Счётчик
// Сеть useFetch, // HTTP запросы useWebSocket, // WebSocket useEventSource, // Server-Sent Events useOnline, // Онлайн статус
// Анимация useTransition, // Анимация чисел useAnimate, // Web Animations API
// Утилиты useTimeAgo, // "5 минут назад" useDateFormat, // Форматирование дат useCloned, // Глубокое клонирование} from '@vueuse/core'Пример использования VueUse:
Заголовок раздела «Пример использования VueUse:»<script setup lang="ts">import { ref } from 'vue'import { useClipboard, useOnline, useTimeAgo, useIntersectionObserver} from '@vueuse/core'
// Буфер обменаconst { copy, copied, text } = useClipboard()
// Статус сетиconst isOnline = useOnline()
// Время "5 минут назад"const date = ref(new Date('2024-01-15'))const timeAgo = useTimeAgo(date)
// Lazy loadingconst target = ref<HTMLElement | null>(null)const isVisible = ref(false)
useIntersectionObserver(target, ([entry]) => { isVisible.value = entry.isIntersecting})</script>
<template> <div> <p>{{ isOnline ? '🟢 Online' : '🔴 Offline' }}</p> <p>{{ timeAgo }}</p>
<button @click="copy('Hello Vue!')"> {{ copied ? '✅ Скопировано!' : '📋 Копировать' }} </button>
<div ref="target"> <img v-if="isVisible" src="/heavy-image.jpg" /> <div v-else>Загрузится когда станет видимым</div> </div> </div></template>Composable vs Mixin vs Service
Заголовок раздела «Composable vs Mixin vs Service»┌─────────────┬──────────────┬────────────────┬──────────────────┐│ │ Mixin │ Composable │ Service │├─────────────┼──────────────┼────────────────┼──────────────────┤│ Синтаксис │ Options API │ Composition API│ Обычный класс ││ Реактивность│ ✅ через this │ ✅ ref/reactive │ ❌ нет ││ Lifecycle │ ✅ да │ ✅ да │ ❌ нет ││ Параметры │ ❌ нет │ ✅ аргументы │ ✅ constructor ││ TypeScript │ 😢 плохо │ ✅ отлично │ ✅ отлично ││ Конфликты │ ❌ возможны │ ✅ нет │ ✅ нет ││ Тестировать │ 😢 сложно │ ✅ легко │ ✅ легко │└─────────────┴──────────────┴────────────────┴──────────────────┘
Вывод: Composable — лучший выбор для Vue 3!Service (через provide/inject) — для глобальных singleton'ов без реактивности.