Перейти к содержимому

12. Composables (хуки)

Composables — это переиспользуемые функции с реактивным состоянием. Если React придумал хуки, Vue сделал то же самое, только назвал иначе. Composable — это функция, которая использует Composition API (ref, watch, onMounted и т.д.) внутри себя и возвращает реактивные данные и методы 🚀


До Composition API Vue использовал mixins — и это было ужасно:

Проблемы с mixins:
❌ Непонятно, откуда взялось this.someData
❌ Конфликты имён между миксинами
❌ Невозможно передать параметры
❌ Плохая типизация в TypeScript
Composables решают всё это:
✅ Явный импорт — видно, откуда данные
✅ Нет конфликтов (у каждого вызова своё замыкание)
✅ Параметры через аргументы функции
✅ Отличная TypeScript поддержка

Все composables начинаются с use. Это конвенция (не правило), но её все соблюдают:

✅ useCounter
✅ useFetch
✅ useLocalStorage
✅ useDebounce
✅ useMousePosition
❌ getCounter // не composable — нет реактивности
❌ mouseTracker // нарушение конвенции

Классический первый composable. Инкапсулирует логику отслеживания позиции мыши:

composables/useMouse.ts
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 привязываются к компоненту, который его вызывает. Магия! ✨


composables/useLocalStorage.ts
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>

composables/useFetch.ts
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>

composables/useDebounce.ts
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 могут принимать как обычные значения, так и ref’ы:

composables/useCounter.ts
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 — это коллекция из 200+ готовых composables. Не изобретай велосипед! 🚲

Окно терминала
npm install @vueuse/core
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'
<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 loading
const 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>

┌─────────────┬──────────────┬────────────────┬──────────────────┐
│ │ Mixin │ Composable │ Service │
├─────────────┼──────────────┼────────────────┼──────────────────┤
│ Синтаксис │ Options API │ Composition API│ Обычный класс │
│ Реактивность│ ✅ через this │ ✅ ref/reactive │ ❌ нет │
│ Lifecycle │ ✅ да │ ✅ да │ ❌ нет │
│ Параметры │ ❌ нет │ ✅ аргументы │ ✅ constructor │
│ TypeScript │ 😢 плохо │ ✅ отлично │ ✅ отлично │
│ Конфликты │ ❌ возможны │ ✅ нет │ ✅ нет │
│ Тестировать │ 😢 сложно │ ✅ легко │ ✅ легко │
└─────────────┴──────────────┴────────────────┴──────────────────┘
Вывод: Composable — лучший выбор для Vue 3!
Service (через provide/inject) — для глобальных singleton'ов без реактивности.