← Курс/Паттерны composables: лучшие практики#242 из 257+30 XP

Паттерны composables: лучшие практики

Соглашение именования useXxx

Все composables называются с префиксом use. Это конвенция (не требование), которая:

  • Сигнализирует, что функция содержит реактивное состояние
  • Явно указывает, что её нужно вызывать в setup()
  • Делает код узнаваемым в любой Vue-кодовой базе
  • // ✅ Правильно
    export function useCounter() { ... }
    export function useUserProfile() { ... }
    export function useWindowSize() { ... }
    
    // ❌ Неправильно (функция, не composable)
    export function getCounter() { ... }
    export function createUser() { ... }

    Возвращаемые значения: refs vs reactive

    Рекомендуется возвращать объект с ref-свойствами (не reactive-объект):

    // ✅ Рекомендуется: деструктурирование работает корректно
    export function useCounter(initial = 0) {
      const count = ref(initial)
      const increment = () => count.value++
      return { count, increment }
    }
    
    // Деструктурирование сохраняет реактивность:
    const { count, increment } = useCounter()
    
    // ❌ Проблема с reactive: деструктурирование ломает реактивность
    export function useCounter(initial = 0) {
      return reactive({ count: initial })  // не делайте так
    }
    const { count } = useCounter()  // count — примитив, не реактивный!

    Composable composition (компоновка)

    Composables можно использовать внутри других composables:

    // useUserSettings.js — использует useStorage внутри
    export function useUserSettings() {
      const { data, save } = useStorage('user-settings')
      const { locale, setLocale } = useI18n()
    
      const settings = computed(() => ({
        ...data.value,
        locale: locale.value,
      }))
    
      async function updateSettings(patch) {
        await save({ ...data.value, ...patch })
      }
    
      return { settings, updateSettings, setLocale }
    }

    Async composables

    export function useAsyncData(fetcher) {
      const data = ref(null)
      const loading = ref(false)
      const error = ref(null)
    
      async function execute(...args) {
        loading.value = true
        error.value = null
        try {
          data.value = await fetcher(...args)
        } catch (e) {
          error.value = e
        } finally {
          loading.value = false
        }
      }
    
      // Немедленный вызов при создании
      execute()
    
      return { data, loading, error, execute }
    }

    Очистка в onUnmounted

    Важнейший паттерн — всегда очищать подписки, таймеры, слушатели:

    export function useEventListener(target, event, handler) {
      onMounted(() => {
        target.addEventListener(event, handler)
      })
    
      // Автоматическая очистка при уничтожении компонента
      onUnmounted(() => {
        target.removeEventListener(event, handler)
      })
    }
    
    export function useInterval(callback, ms) {
      let timer = null
    
      onMounted(() => {
        timer = setInterval(callback, ms)
      })
    
      onUnmounted(() => {
        if (timer) clearInterval(timer)  // утечка памяти предотвращена
      })
    }

    Паттерн: acceptRef (принятие ref или значения)

    import { toRef, isRef } from 'vue'
    
    // Composable принимает и ref, и обычное значение
    export function useDouble(value) {
      const valueRef = isRef(value) ? value : ref(value)
      return computed(() => valueRef.value * 2)
    }
    
    // Оба варианта работают:
    const double1 = useDouble(5)
    const count = ref(10)
    const double2 = useDouble(count)  // реактивно к count

    Организация файлов

    src/
      composables/
        useAuth.js        — авторизация
        useForm.js        — формы
        useFetch.js       — HTTP-запросы
        useDebounce.js    — дебаунс
        index.js          — реэкспорт всего

    Примеры

    Набор composable-паттернов: async data, cleanup, composition — всё на чистом JS

    // Реализуем ключевые паттерны composables без Vue-рантайма,
    // используя замыкания как аналог реактивного состояния.
    
    // --- Паттерн: useAsyncData ---
    function useAsyncData(fetcher) {
      let data = null
      let loading = false
      let error = null
      const listeners = new Set()
    
      function notify() {
        listeners.forEach(fn => fn({ data, loading, error }))
      }
    
      const composable = {
        subscribe(fn) {
          listeners.add(fn)
          fn({ data, loading, error })  // немедленный вызов с текущим состоянием
          return () => listeners.delete(fn)  // отписка
        },
    
        async execute(...args) {
          loading = true
          error = null
          notify()
    
          try {
            data = await fetcher(...args)
          } catch (e) {
            error = e.message
          } finally {
            loading = false
            notify()
          }
        },
    
        getState() { return { data, loading, error } }
      }
    
      return composable
    }
    
    // --- Паттерн: useCompose (composable из composables) ---
    function useFilteredData(allItems) {
      let filter = ''
      let sortKey = null
      const listeners = new Set()
    
      function getResult() {
        let result = allItems.filter(item =>
          !filter || JSON.stringify(item).toLowerCase().includes(filter.toLowerCase())
        )
        if (sortKey) {
          result = [...result].sort((a, b) => a[sortKey] < b[sortKey] ? -1 : 1)
        }
        return result
      }
    
      return {
        setFilter(f) { filter = f; listeners.forEach(fn => fn(getResult())) },
        setSortKey(k) { sortKey = k; listeners.forEach(fn => fn(getResult())) },
        subscribe(fn) {
          listeners.add(fn)
          fn(getResult())
          return () => listeners.delete(fn)
        },
      }
    }
    
    // --- Паттерн: cleanup (имитация onUnmounted) ---
    function createComponent(setup) {
      const cleanups = []
      const onUnmounted = (fn) => cleanups.push(fn)
      const instance = setup({ onUnmounted })
    
      return {
        ...instance,
        destroy() {
          console.log('[Component] destroying — вызываем cleanup...')
          cleanups.forEach(fn => fn())
          cleanups.length = 0
        }
      }
    }
    
    function useInterval(callback, ms, { onUnmounted }) {
      const id = setInterval(callback, ms)
      console.log(`[useInterval] запущен (id=${id})`)
    
      onUnmounted(() => {
        clearInterval(id)
        console.log(`[useInterval] очищен (id=${id})`)
      })
    
      return { stop: () => clearInterval(id) }
    }
    
    // === Тест: useAsyncData ===
    async function runAsyncTest() {
      console.log('=== useAsyncData ===')
    
      const userData = useAsyncData(async (id) => {
        await new Promise(r => setTimeout(r, 30))
        if (id === 0) throw new Error('Пользователь не найден')
        return { id, name: `User #${id}`, email: `user${id}@example.com` }
      })
    
      const unsub = userData.subscribe(state => {
        if (state.loading) console.log('  loading...')
        if (state.data)    console.log('  data:', state.data.name)
        if (state.error)   console.log('  error:', state.error)
      })
    
      await userData.execute(42)
      await userData.execute(0)   // ошибка
    
      unsub()
      await userData.execute(7)   // не отобразится (отписались)
      console.log('После отписки:', userData.getState().data?.name)
    }
    
    // === Тест: useFilteredData ===
    function runFilterTest() {
      console.log('\n=== useFilteredData ===')
    
      const items = [
        { name: 'Apple',  price: 100 },
        { name: 'Banana', price: 50  },
        { name: 'Cherry', price: 200 },
        { name: 'Date',   price: 150 },
      ]
    
      const filteredData = useFilteredData(items)
      const unsub = filteredData.subscribe(result => {
        console.log('Результат:', result.map(i => i.name))
      })
    
      filteredData.setFilter('a')
      filteredData.setSortKey('price')
      filteredData.setFilter('')
    
      unsub()
    }
    
    // === Тест: cleanup ===
    function runCleanupTest() {
      console.log('\n=== Cleanup (onUnmounted) ===')
    
      let ticks = 0
      const comp = createComponent(({ onUnmounted }) => {
        const { stop } = useInterval(() => ticks++, 10, { onUnmounted })
        return { getTicks: () => ticks }
      })
    
      setTimeout(() => {
        console.log('Тиков до destroy:', comp.getTicks())
        comp.destroy()
        // После destroy интервал остановлен
        setTimeout(() => {
          console.log('Тиков после destroy (не изменилось):', comp.getTicks())
        }, 50)
      }, 50)
    }
    
    runAsyncTest().then(runFilterTest).then(runCleanupTest)