Привет, Хабр!
Сегодня рассмотрим, что такое композиционные хуки во 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
А ещё приглашаем пройти вступительное тестирование — это отличный способ включиться в процесс и сделать первый шаг к обучению.
devprodest
Тот случай когда реактер добрался до вью...