Композиционные хуки во Vue 3: коротко

от автора

Привет, Хабр!

Сегодня рассмотрим, что такое композиционные хуки во 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

ref/reactive

Создаём локальный state — строго внутри функции, не снаружи

2

Отдельные методы (update)

Логика вынесена, можно тестировать и подменять

3

onMounted/onUnmounted

Вешаем и снимаем слушатели.

4

return {}

Только нужное наружу.

Если коротко: хук = функция, которая подключается к реактивной системе 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 и хотите освоить современные подходы к разработке?

Разберитесь с композиционными хуками — они позволяют писать чистые, модульные функции с полной поддержкой реактивности и жизненного цикла. А чтобы не просто читать, а практиковаться под руководством экспертов — приходите на открытые уроки:

А ещё приглашаем пройти вступительное тестирование — это отличный способ включиться в процесс и сделать первый шаг к обучению.


ссылка на оригинал статьи https://habr.com/ru/articles/922582/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *