Hack Frontend Community

Composables в Vue.js

Что такое Composables?

Composables — это функции, которые используют Composition API для инкапсуляции и переиспользования состояния и логики между компонентами.

Composables в Vue похожи на custom hooks в React.


Базовый пример

// useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    increment,
    decrement,
    reset
  }
}

Использование:

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">Reset</button>
  </div>
</template>

<script setup>
import { useCounter } from './useCounter'

const { count, increment, decrement, reset } = useCounter(10)
</script>

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

Composables обычно называются с префиксом use:

// Хорошо
useCounter()
useMouse()
useFetch()
useLocalStorage()

// Плохо
counter()
mouse()
fetch()

Примеры популярных Composables

useMouse

Отслеживание позиции мыши:

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

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }
  
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  
  return { x, y }
}

Использование:

<template>
  <p>Позиция мыши: {{ x }}, {{ y }}</p>
</template>

<script setup>
import { useMouse } from './useMouse'

const { x, y } = useMouse()
</script>

useFetch

Получение данных с API:

// useFetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)
  
  async function fetch() {
    loading.value = true
    error.value = null
    
    try {
      const response = await window.fetch(url)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  fetch()
  
  return { data, error, loading, refetch: fetch }
}

Использование:

<template>
  <div v-if="loading">Загрузка...</div>
  <div v-else-if="error">Ошибка: {{ error.message }}</div>
  <div v-else>
    <pre>{{ data }}</pre>
    <button @click="refetch">Обновить</button>
  </div>
</template>

<script setup>
import { useFetch } from './useFetch'

const { data, error, loading, refetch } = useFetch('/api/users')
</script>

useLocalStorage

Синхронизация с localStorage:

// useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)
  
  // Читаем из localStorage при инициализации
  const stored = localStorage.getItem(key)
  if (stored) {
    try {
      value.value = JSON.parse(stored)
    } catch (e) {
      console.error('Error parsing localStorage value:', e)
    }
  }
  
  // Сохраняем при изменении
  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })
  
  return value
}

Использование:

<template>
  <input v-model="username" placeholder="Имя пользователя" />
  <p>Сохранено: {{ username }}</p>
</template>

<script setup>
import { useLocalStorage } from './useLocalStorage'

const username = useLocalStorage('username', '')
</script>

useDebounce

Debounce для значения:

// useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  
  let timeoutId
  
  watch(value, (newValue) => {
    clearTimeout(timeoutId)
    
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })
  
  return debouncedValue
}

Использование:

<template>
  <input v-model="searchQuery" placeholder="Поиск..." />
  <p>Поиск: {{ debouncedQuery }}</p>
</template>

<script setup>
import { ref } from 'vue'
import { useDebounce } from './useDebounce'

const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

// Используйте debouncedQuery для API запросов
</script>

Composables с параметрами

Статические параметры

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  // ...
  return { count, increment, decrement }
}

// Использование
const { count } = useCounter(10)

Реактивные параметры

// useFetch.js
import { ref, watch, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(false)
  
  async function fetch() {
    loading.value = true
    try {
      const response = await window.fetch(toValue(url))
      data.value = await response.json()
    } finally {
      loading.value = false
    }
  }
  
  // Следим за изменениями URL
  watch(() => toValue(url), fetch, { immediate: true })
  
  return { data, loading }
}

Использование:

<script setup>
import { ref } from 'vue'
import { useFetch } from './useFetch'

const userId = ref(1)
const url = computed(() => `/api/users/${userId.value}`)

const { data, loading } = useFetch(url)
// Автоматически перезагрузится при изменении userId
</script>

Композиция Composables

Composables можно комбинировать:

// useUser.js
import { useFetch } from './useFetch'
import { useLocalStorage } from './useLocalStorage'

export function useUser() {
  const userId = useLocalStorage('userId', null)
  
  const url = computed(() => 
    userId.value ? `/api/users/${userId.value}` : null
  )
  
  const { data: user, loading, error } = useFetch(url)
  
  function login(id) {
    userId.value = id
  }
  
  function logout() {
    userId.value = null
  }
  
  return {
    user,
    loading,
    error,
    login,
    logout
  }
}

Асинхронные Composables

// useAsyncData.js
import { ref } from 'vue'

export function useAsyncData(asyncFunction) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)
  
  asyncFunction()
    .then(result => {
      data.value = result
    })
    .catch(err => {
      error.value = err
    })
    .finally(() => {
      loading.value = false
    })
  
  return { data, error, loading }
}

Использование:

<script setup>
import { useAsyncData } from './useAsyncData'

const { data, error, loading } = useAsyncData(async () => {
  const response = await fetch('/api/config')
  return response.json()
})
</script>

Best Practices

Возвращайте refs, а не reactive

// Хорошо
export function useCounter() {
  const count = ref(0)
  return { count }
}

// Плохо
export function useCounter() {
  const state = reactive({ count: 0 })
  return state
}

Причина: refs сохраняют реактивность при деструктуризации.

Именование возвращаемых значений

// Хорошо - понятные имена
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  return { x, y }
}

// Плохо - неясные имена
export function useMouse() {
  const a = ref(0)
  const b = ref(0)
  return { a, b }
}

Cleanup в onUnmounted

export function useEventListener(target, event, callback) {
  onMounted(() => {
    target.addEventListener(event, callback)
  })
  
  onUnmounted(() => {
    target.removeEventListener(event, callback)
  })
}

Используйте toValue для гибкости

import { toValue } from 'vue'

export function useFetch(url) {
  // url может быть ref, computed или обычным значением
  const actualUrl = toValue(url)
  // ...
}

Composables vs Mixins

Mixins (Vue 2)

// Плохо - mixins
const counterMixin = {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}

Проблемы mixins:

  • Неясный источник свойств
  • Конфликты имён
  • Неявные зависимости

Composables (Vue 3)

// Хорошо - composables
export function useCounter() {
  const count = ref(0)
  
  function increment() {
    count.value++
  }
  
  return { count, increment }
}

Преимущества composables:

  • Явный источник
  • Нет конфликтов имён
  • Лучшая типизация
  • Проще тестировать

Библиотеки Composables

VueUse

Коллекция готовых composables:

npm install @vueuse/core
<script setup>
import { useMouse, useLocalStorage, useFetch } from '@vueuse/core'

const { x, y } = useMouse()
const theme = useLocalStorage('theme', 'light')
const { data } = useFetch('/api/data').json()
</script>

Частые ошибки

Вызов composable вне setup

// Неправильно
export default {
  data() {
    const { x, y } = useMouse() // Ошибка!
    return { x, y }
  }
}

// Правильно
export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}

Условный вызов composable

// Неправильно
if (condition) {
  const { count } = useCounter() // Ошибка!
}

// Правильно
const { count } = useCounter()
if (condition) {
  // используйте count
}

Забыть cleanup

// Неправильно - утечка памяти
export function useMouse() {
  const x = ref(0)
  
  function update(event) {
    x.value = event.pageX
  }
  
  window.addEventListener('mousemove', update)
  // Забыли removeEventListener!
  
  return { x }
}

// Правильно
export function useMouse() {
  const x = ref(0)
  
  function update(event) {
    x.value = event.pageX
  }
  
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  
  return { x }
}

Вывод

Composables:

  • Переиспользуемая логика с Composition API
  • Лучше, чем mixins
  • Именуются с префиксом use
  • Возвращают refs
  • Требуют cleanup в onUnmounted
  • Можно комбинировать
  • Поддерживают TypeScript

На собеседовании:

Важно уметь:

  • Объяснить, что такое composables и зачем они нужны
  • Показать примеры создания composables
  • Рассказать о преимуществах перед mixins
  • Объяснить best practices (именование, cleanup, возврат refs)
  • Привести примеры популярных composables