Привет, Хабр!
Сегодня рассмотрим, что такое композиционные хуки во Vue 3, зачем они нужны и как их использовать.
Что вообще за хуки во Vue 3?
Хук (composable) во Vue 3 — это обычная функция, которая ��ивёт внутри setup() или другого хука и использует возможности Composition API: ref, reactive, computed, watch, жизненные циклы, provide/inject.
Но не обманывайтесь простотой. Это целый способ инкапсуляции реактивного поведения, привязанного к жизненному циклу компонента, но изолированного от его UI-структуры.
Определение, которое стоит запомнить:
Композиционный хук — это чистая функция, создающая реактивное поведение и управляющая побочными эффектами, синхронно с жизненным циклом компонента.
Основные свойства хуков:
Работают только в
setup(): иначе потеряете реактивность.Живут в реактивной области компонента — значит, все
refиreactiveвнутри них участвуют в трекинге зависимостей.Могут использовать жизненные циклы (
onMounted,onUnmountedи др.).Могут вызываться несколько раз: каждый вызов — изолированное состояние.
Обязаны убирать за собой, если создают сайд-эффекты (например, подписки или таймеры).
Анатомия композиционного хука
Вот шаблон, который лежит в основе любого хорошего хука:
import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { // 1. Приватное реактивное состояние const x = ref(0) const y = ref(0) // 2. Бизнес-логика, отделённая от UI const update = (e: MouseEvent) => { x.value = e.pageX y.value = e.pageY } // 3. Сайд-эффекты, завязанные на жизненный цикл onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) // 4. Публичный контракт (API наружу) return { x, y } }
Каждый блок здесь имеет свою задачу:
Слой | Зачем он нужен | |
|---|---|---|
1 |
| Создаём локальный state — строго внутри функции, не снаружи |
2 | Отдельные методы ( | Логика вынесена, можно тестировать и подменять |
3 |
| Вешаем и снимаем слушатели. |
4 |
| Только нужное наружу. |
Если коротко: хук = функция, которая подключается к реактивной системе Vue в момент вызова из setup(). Вся логика хука работает так, будто ты написал её прямо в setup(). Но — чище, модульнее и повторно используемо.
И это весь смысл композиционного API: разделить поведение, оставить компоненту только структуру.
Производственный useFetch: отменяем, кешируем, типизируем
На практике, самый частый use case для хуков — это вытаскивание данных. Всё, что приходит по сети — API-запросы, загрузка списков, профилей, карточек — должно быть вынесено в отдельный слой.
Создадим useFetch(), который умеет:
безопасно отменять висящие запросы при
unmounted,оборачивать запрос в
try/catch/finally,кастомизироваться под тесты и заглушки через
fetcher,типизироваться через
T,таймаутить медленные ответы,
опционально кешировать данные локально.
Скелет useFetch()
// useFetch.ts import { ref, shallowRef, onUnmounted } from 'vue' interface Options<T> { cacheKey?: string fetcher?: (url: string) => Promise<T> // можно подменить на тестах immediate?: boolean timeout?: number } export function useFetch<T = unknown>(url: string, opts: Options<T> = {}) { const data = shallowRef<T | null>(null) const error = ref<Error | null>(null) const loading = ref(false) const controller = new AbortController() let timer: number | undefined
ref для loading и error, как обычно. shallowRef для data — чтобы Vue не превращал вложенные объекты в реактивные (например, если это массивы с 10K элементов). AbortController — чтобы можно было отменить запрос, если компонент анмаунтится или timeout наступает. Options — удобный API: можно передать fetcher, можно не делать immediate, можно задать timeout.
Дальше — реализация запроса:
const fetcher = opts.fetcher ?? (u => fetch(u, { signal: controller.signal }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`) return r.json() as Promise<T> }))
Если fetcher не передан — используем стандартный fetch, но уже с сигналом для отмены.
Основная логика запроса:
const exec = async () => { loading.value = true error.value = null try { if (opts.timeout) timer = window.setTimeout(() => controller.abort(), opts.timeout) data.value = await fetcher(url) } catch (e) { if ((e as DOMException).name !== 'AbortError') error.value = e as Error } finally { loading.value = false clearTimeout(timer) } }
Перед началом — сбрасываем error, включаем loading, если задан timeout, вешаем setTimeout, который через N миллисекунд вызовет abort() (всё это прерывает fetch).
Если ошибка — проверяем, не AbortError ли это, чтобы не засорять error лишним, а в finally — выключаем loading, убираем таймер.
Это костяк для 99% асинхронных операций в UI: fetch + abort + timeout + loading/error guard.
Подключение хуку к жизненному циклу:
if (opts.immediate !== false) exec() onUnmounted(() => controller.abort())
Если immediate !== false, то запрос начнётся сразу. Если нет — компонент сам вызовет refetch() позже.
onUnmounted(() => controller.abort()) — это страховка: уходит компонент — уходит запрос. Иначе можно словить ошибку обновления state на уничтоженном компоненте, или вообще race condition.
Экспортируем API:
return { data, error, loading, refetch: exec, abort: () => controller.abort(), canAbort: () => !controller.signal.aborted } }
refetch() — можно повторно загрузить вручную; abort() — вручную прервать (например, пользователь нажал "Отмена"); canAbort() — полезно в UI (например, серый vs активный "Отмена").
Подключаем кеш
Кеширование по cacheKey — элементарный, но рабочий паттерн.
const cached = new Map<string, unknown>() if (opts.cacheKey && cached.has(opts.cacheKey)) { data.value = cached.get(opts.cacheKey) as T } else { // после успешного запроса: if (opts.cacheKey) cached.set(opts.cacheKey, data.value) }
Зачем это? 80% фронтов живёт на временном кешировании:
чтобы не дёргать API лишний раз;
чтобы при повторных переходах не было "моргания" загрузки;
чтобы давать мгновенный отклик при возвращении на предыдущий экран.
Можно улучшить:
TTL через
Map<string, { data, timestamp }>;LRUкеширование;проброс
cachePolicy: 'cache-first' | 'network-only'.
Но даже простейший Map покрывает 90% задач.
Масштабируем хуки, управляем скоупом и выходим за пределы setup()
Когда вы уже наловчились писать свои useFetch, useCounter и useMouse, приходит пора следующего уровня. Хуки становятся сложнее: они работают с WebSocket'ами, обмениваются состоянием между компонентами, шарят глобальный auth, или лезут вглубь Vue-инстанса.
Управляем областью реактивности через effectScope
По дефолту все ref, computed, watchEffect и даже onUnmounted внутри хука регистрируются во внешнем скоупе компонента. И это нормально, пока вы не создаёте несколько реактивных зависимостей, которые нужно убить одной командой.
В таких случаях нужен effectScope — встроанный в Vue механизм, позволяющий запускать реактивный контекст в изолированной области, которую вы можете вручную останавливать. Это хороший способ собрать все сайд-эффекты в песочницу и уничтожить её вызовом scope.stop().
Реализация useWebSocket с effectScope и авто-reconnect
Пример хука для WebSocket. Он:
создаёт соединение,
ловит
messageи пушит в реактивныйmessages,восстанавливает соединение при обрыве,
использует
effectScope, чтобы не протекли подписки.
// useWebSocket.ts import { ref, effectScope, onUnmounted } from 'vue' export function useWebSocket(url: string) { const scope = effectScope() const messages = ref<string[]>([]) const status = ref<'OPEN' | 'CLOSED' | 'ERROR'>('CLOSED') let ws: WebSocket | null = null let retry = 0 const MAX_RETRY = 5 const init = () => { ws = new WebSocket(url) status.value = 'OPEN' ws.addEventListener('message', e => messages.value.push(e.data)) ws.addEventListener('close', handleClose) ws.addEventListener('error', handleError) } const handleClose = () => reconnect() const handleError = () => reconnect() const reconnect = () => { status.value = 'ERROR' if (retry++ < MAX_RETRY) { setTimeout(init, 1000 * retry) // exponential back-off } else { console.warn('Max WS retries reached') } } scope.run(() => init()) // все сайд-эффекты пойдут внутрь scope const send = (msg: string) => ws?.readyState === WebSocket.OPEN && ws.send(msg) const stop = () => { ws?.close() scope.stop() // мгновенно отключает все эффекты, listeners и reactivity } onUnmounted(stop) return { messages, status, send, stop } }
effectScope собирает:
все
ref, чтобы они больше не обновлялись,все
watchEffect, если бы они были внутри,все сайд-эффекты, подписки — и убирает их одним вызовом
scope.stop().
Без этого, даже при onUnmounted, могли бы остаться живые WebSocket-обработчики.
Делимся состоянием глобально: provide / inject
Иногда компоненту нужно не просто вызвать хук, а разделить его состояние с другими компонентами. Например, логин, текущий пользователь, глобальные настройки.
Vue имеет чистый механизм — provide() и inject().
Вот как выглядит глобальный useAuth():
// authProvider.ts import { provide, inject } from 'vue' import { currentUser } from '@/stores/user' // глобальный ref const key = Symbol('auth') export function provideAuth() { provide(key, { user: currentUser, login: () => { /* ... */ }, logout: () => { /* ... */ } }) } export function useAuth() { const ctx = inject<ReturnType<typeof provideAuth>>(key) if (!ctx) throw new Error('Auth not provided') return ctx }
Принцип:
в корневом компоненте (например,
App.vueилиLayout) вызываемprovideAuth()один раз;внутри любого дочернего —
useAuth(), без пропсов и глобальных сторей.
Это работает без потери реактивности. То, что ты provide'нул, остаётся реактивным — Vue просто прокидывает ссылку через внутренний context tree.
getCurrentInstance()
Иногда нужен доступ к emit, proxy, appContext, attrs. Например, если вы пишете хук, который:
триггерит
emit()изнутри;работает с
attrsилиslots;лезет в
appContext.config.
Для этого есть getCurrentInstance():
import { getCurrentInstance } from 'vue' export function useEmitter() { const inst = getCurrentInstance() if (!inst) throw new Error('useEmitter must be called in setup') const emit = inst.emit return { emit } }
Вызываем только внутри setup() или хуков, иначе инстанса не будет. Также не стоит вызывать внутри computed или watch, потому что на момент повторного запуска — инстанс уже не доступен (частый баг, обсуждаемый на GitHub).
Тестируем composable
Если вы пишете хуки с логикой, таймерами, сетевыми запросами или shared state — вам придётся тестировать их.
Vue 3 делает это проще, чем кажется. Главное — помнить: хук сам по себе просто функция, которую можно замаунтить через setup() в мок-компонент и потестить.
Vitest + @vue/test-utils: базовая схема
import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import { defineComponent } from 'vue' import { useFetch } from '@/composables/useFetch' describe('useFetch', () => { it('fetches data and exposes it', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: 1 }) }) )) const Comp = defineComponent({ setup () { return useFetch<{ ok: number }>('/api', { immediate: false }) }, template: '<div></div>' }) const wrapper = mount(Comp) await wrapper.vm.refetch() expect(wrapper.vm.data.ok).toBe(1) }) })
Заглушаем fetch глобально через vi.stubGlobal. Далее создаём мок-компонент, который просто вызывает useFetch в setup() — в этом вся фича: хук становится частью компонента, и мы можем получить к нему доступ через wrapper.vm.
Вызываем refetch(), ждём промис, и проверяем результат.
flushPromises(): не забываем про microtasks
Асинхронные хуки почти всегда требуют flushPromises():
import flushPromises from 'flush-promises' await wrapper.vm.refetch() await flushPromises() expect(wrapper.vm.data).toEqual(...)
await только дождётся промиса — но Vue запланирует обновление реактивных данных в следующем тике. Без flushPromises() можно проверять ref, который ещё не обновился.
Вывод
Если вы уже пишете свои хуки — расскажите в комментариях, какие подходы у вас прижились. Что оказалось удобным, что — нет. Используете ли effectScope, делаете ли глобальные provide-хуки, тестируете ли логику? Делитесь своим опытом.
Погружаетесь в Vue 3 и хотите освоить современные подходы к разработке?
Разберитесь с композиционными хуками — они позволяют писать чистые, модульные функции с полной поддержкой реактивности и жизненного цикла. А чтобы не просто читать, а практиковаться под руководством экспертов — приходите на открытые уроки:
Как быстро освоить Vue, если уже знаешь JavaScript — 8 июля в 20:00
Vue умеет проще: пишем игру, пока React грузит стейт — 16 июля в 20:00
Создаем чат на Vue с WebSocket: интерактив в реальном времени — 21 июля в 20:00
А ещё приглашаем пройти вступительное тестирование — это отличный способ включиться в процесс и сделать первый шаг к обучению.
