← Курс/Composables: переиспользуемая логика#234 из 257+30 XP

Composables: переиспользуемая логика

Что такое composable

Composable — это функция с именем use*, которая инкапсулирует **реактивную логику** и позволяет переиспользовать её в нескольких компонентах. Composables — главный способ переиспользования кода в Vue 3.

// useMousePosition.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

// В компоненте:
const { x, y } = useMousePosition()

useDebounce

import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timer = null

  watch(value, (newVal) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })

  return debouncedValue
}

// Применение — поиск с задержкой
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

watch(debouncedQuery, (query) => {
  if (query) fetchResults(query) // запрос только когда пользователь перестал печатать
})

useFetch

import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  fetch(url)
    .then(r => r.json())
    .then(json => { data.value = json })
    .catch(err => { error.value = err.message })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

// В компоненте:
const { data: users, loading, error } = useFetch('/api/users')

Composables vs Mixins

Mixins — старый способ переиспользования в Vue 2. Они имеют серьёзные недостатки:

| Проблема | Mixins | Composables |

|-------------------------|---------------------------------|--------------------------|

| Источник свойств | Непонятно — откуда пришло? | Явно из const { x } = useX() |

| Конфликты имён | Перезапись без предупреждения | Переименование через деструктуризацию |

| Переиспользование | Нельзя передавать параметры | Полноценные аргументы |

| Отладка | Сложно трассировать | Обычная функция |

// MIXINS — проблема: откуда взялось searchQuery?
export default {
  mixins: [SearchMixin, UserMixin, PaginationMixin],
  mounted() {
    // searchQuery из SearchMixin или UserMixin? Непонятно!
    this.searchQuery = ''
  }
}

// COMPOSABLES — явно и прозрачно
export default {
  setup() {
    const { query: searchQuery, results } = useSearch()
    const { currentUser } = useUser()
    const { page, totalPages } = usePagination(results)
    // всё читаемо и явно
    return { searchQuery, results, currentUser, page, totalPages }
  }
}

Паттерны composables

// Composable с опциями
function useInterval(callback, { delay = 1000, immediate = false } = {}) {
  let id = null

  onMounted(() => {
    if (immediate) callback()
    id = setInterval(callback, delay)
  })

  onUnmounted(() => clearInterval(id))

  return {
    pause: () => clearInterval(id),
    resume: () => { id = setInterval(callback, delay) }
  }
}

// Composable возвращающий composable
function useAsyncState(asyncFn, defaultValue) {
  const state = ref(defaultValue)
  const loading = ref(false)
  const error = ref(null)

  async function execute(...args) {
    loading.value = true
    error.value = null
    try {
      state.value = await asyncFn(...args)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  return { state, loading, error, execute }
}

Примеры

useDebounce и useFetch как чистые JS функции без Vue

// useDebounce — задержка выполнения функции
function useDebounce(fn, delay = 300) {
  let timer = null

  const debounced = function(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }

  // Дополнительные методы
  debounced.cancel = () => clearTimeout(timer)
  debounced.flush = function(...args) {
    clearTimeout(timer)
    fn.apply(this, args)
  }

  return debounced
}

// Использование useDebounce
const handleSearch = useDebounce((query) => {
  console.log(`Поиск: "${query}"`)
}, 300)

// Имитируем быстрый ввод пользователя
handleSearch('J')
handleSearch('Ja')
handleSearch('Jav')
handleSearch('Java')
handleSearch('JavaS')
// Только последний вызов выполнится через 300мс (в реальном браузере)
// В Node.js нет таймаута между вызовами — демонстрируем концепцию
console.log('(запросы задебаунсированы, выполнится только последний)')

// useFetch — загрузка данных с состоянием
function useFetch(fetchFn) {
  const state = {
    data: null,
    loading: false,
    error: null,
  }

  let abortController = null

  async function execute(...args) {
    // Отменяем предыдущий запрос если он ещё идёт
    if (abortController) abortController.abort()
    abortController = { aborted: false, abort() { this.aborted = true } }

    const currentController = abortController
    state.loading = true
    state.error = null

    try {
      const result = await fetchFn(...args)
      // Если запрос был отменён — игнорируем результат
      if (!currentController.aborted) {
        state.data = result
      }
    } catch (err) {
      if (!currentController.aborted) {
        state.error = err.message
      }
    } finally {
      if (!currentController.aborted) {
        state.loading = false
      }
    }

    return state
  }

  return { state, execute }
}

// Имитируем async fetchFn
async function fakeApiCall(userId) {
  // В реальности: return fetch(`/api/users/${userId}`).then(r => r.json())
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id: userId, name: `User ${userId}` }), 10)
  })
}

const { state, execute } = useFetch(fakeApiCall)

async function demo() {
  console.log('loading:', state.loading)  // false
  const result = await execute(1)
  console.log('data:', JSON.stringify(state.data))   // { id: 1, name: "User 1" }
  console.log('loading:', state.loading) // false

  await execute(42)
  console.log('data:', JSON.stringify(state.data))   // { id: 42, name: "User 42" }
}

demo()