← Курс/VueUse: коллекция готовых composables#252 из 257+20 XP

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

Что такое VueUse

**VueUse** — это коллекция из более чем 200 готовых composable-функций для Vue 3. Авторы следят за браузерными API и оборачивают их в реактивные примитивы Vue. Вместо того чтобы писать одни и те же composables снова и снова в каждом проекте, вы берёте готовые, проверенные и типизированные реализации.

npm install @vueuse/core

useLocalStorage

Синхронизирует реактивное состояние с localStorage. При перезагрузке страницы значение восстанавливается автоматически:

import { useLocalStorage } from '@vueuse/core'

const theme = useLocalStorage('theme', 'light')  // значение по умолчанию — 'light'
const user = useLocalStorage('user', { name: '', logged: false })

// Работает как обычный ref
theme.value = 'dark'    // автоматически сохраняется в localStorage
console.log(theme.value) // 'dark' — даже после перезагрузки

useMouse

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

import { useMouse } from '@vueuse/core'

const { x, y, sourceType } = useMouse()
// x и y — реактивные ref с текущими координатами
// sourceType: 'mouse' | 'touch'
<template>
  <div>Позиция мыши: {{ x }}, {{ y }}</div>
</template>

useFetch

Реактивная обёртка над Fetch API с автоматическим управлением состоянием загрузки:

import { useFetch } from '@vueuse/core'

const { data, error, isFetching, execute } = useFetch(
  'https://api.example.com/users'
).json()

// data — реактивные данные ответа
// isFetching — true пока идёт запрос
// error — ошибка если запрос провалился
// execute() — повторить запрос

// С reactive URL (перезапускает запрос при изменении):
const userId = ref(1)
const { data: user } = useFetch(
  computed(() => `/api/users/${userId.value}`)
).json()

useEventListener

Безопасно добавляет обработчики событий и автоматически удаляет их при unmount компонента:

import { useEventListener } from '@vueuse/core'

// В Options API приходилось вручную removeEventListener в beforeUnmount
// VueUse делает это за вас:
useEventListener(window, 'resize', () => {
  console.log('Окно изменило размер:', window.innerWidth)
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') closeModal()
})

useDark

Управление тёмной темой с синхронизацией с системными настройками и localStorage:

import { useDark, useToggle } from '@vueuse/core'

const isDark = useDark()           // реактивный boolean
const toggleDark = useToggle(isDark)

// isDark автоматически добавляет/убирает класс 'dark' на <html>
// Следит за prefers-color-scheme медиа-запросом

useIntersectionObserver

Определяет когда элемент попадает в поле зрения (viewport):

import { useIntersectionObserver } from '@vueuse/core'
import { ref } from 'vue'

const target = ref(null)  // ref на DOM-элемент
const isVisible = ref(false)

useIntersectionObserver(target, ([{ isIntersecting }]) => {
  isVisible.value = isIntersecting
})

Создание собственного composable по образцу VueUse

Понять принцип VueUse проще, если написать аналог самому:

// useWindowSize — аналог @vueuse/core
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  function update() {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

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

  return { width, height }
}

Примеры

Реализация аналогов useLocalStorage, useMouse и useEventListener на чистом JavaScript — чтобы понять, что делает VueUse под капотом

// ============================================
// Аналог useLocalStorage — реактивное хранилище
// ============================================

// В Vue это был бы ref, здесь используем паттерн Observable
function useLocalStorage(key, defaultValue) {
  // Пытаемся загрузить сохранённое значение
  let stored
  try {
    const raw = localStorage.getItem(key)
    stored = raw !== null ? JSON.parse(raw) : defaultValue
  } catch {
    stored = defaultValue
  }

  let _value = stored
  const subscribers = []

  const storage = {
    get value() {
      return _value
    },
    set value(newVal) {
      _value = newVal
      // Синхронизируем с localStorage
      try {
        localStorage.setItem(key, JSON.stringify(newVal))
      } catch (e) {
        console.warn('localStorage недоступен:', e.message)
      }
      // Уведомляем подписчиков (аналог реактивности Vue)
      for (const fn of subscribers) fn(newVal)
    },
    onChange(fn) {
      subscribers.push(fn)
      return () => {
        const idx = subscribers.indexOf(fn)
        if (idx !== -1) subscribers.splice(idx, 1)
      }
    },
  }

  return storage
}

// Демонстрация useLocalStorage
console.log('=== useLocalStorage ===')
const theme = useLocalStorage('app-theme', 'light')
console.log('Начальное значение:', theme.value)  // 'light' (или из localStorage)

const unsubscribe = theme.onChange((val) => {
  console.log('Тема изменилась на:', val)
})

theme.value = 'dark'   // сохраняется в localStorage + уведомляет подписчиков
theme.value = 'light'

unsubscribe()  // отписываемся
theme.value = 'system'  // подписчик не вызовется

console.log('Итоговое значение:', theme.value)

// ============================================
// Аналог useEventListener — с auto-cleanup
// ============================================

console.log('\n=== useEventListener ===')

// В браузере это был бы настоящий addEventListener.
// Здесь симулируем EventEmitter.
class EventBus {
  constructor() { this._handlers = {} }

  addEventListener(event, handler) {
    if (!this._handlers[event]) this._handlers[event] = []
    this._handlers[event].push(handler)
    console.log(`  [addEventListener] "${event}" зарегистрирован`)
  }

  removeEventListener(event, handler) {
    if (!this._handlers[event]) return
    this._handlers[event] = this._handlers[event].filter(h => h !== handler)
    console.log(`  [removeEventListener] "${event}" удалён`)
  }

  emit(event, payload) {
    for (const h of (this._handlers[event] || [])) h(payload)
  }
}

const fakeWindow = new EventBus()

function useEventListener(target, event, handler) {
  target.addEventListener(event, handler)

  // Возвращаем функцию очистки (аналог onUnmounted в Vue)
  return function cleanup() {
    target.removeEventListener(event, handler)
  }
}

const onResize = useEventListener(fakeWindow, 'resize', (e) => {
  console.log('  Событие resize:', e)
})

const onKeydown = useEventListener(fakeWindow, 'keydown', (e) => {
  console.log('  Событие keydown:', e.key)
})

// Симулируем события
fakeWindow.emit('resize', { width: 1200, height: 800 })
fakeWindow.emit('keydown', { key: 'Escape' })

// Симулируем unmount — очищаем слушатели
console.log('\n  [component unmount] — очищаем слушатели...')
onResize()
onKeydown()

// После cleanup — события игнорируются
fakeWindow.emit('resize', { width: 800, height: 600 })
console.log('  (resize событие не обработано — слушатель удалён)')

// ============================================
// Аналог useDark — тёмная тема
// ============================================

console.log('\n=== useDark ===')

function useDark(storageKey = 'color-scheme') {
  // Определяем начальное значение
  // В браузере: window.matchMedia('(prefers-color-scheme: dark)').matches
  const systemPrefersDark = false  // симуляция
  const stored = (() => {
    try { return localStorage.getItem(storageKey) } catch { return null }
  })()

  let isDark = stored !== null ? stored === 'dark' : systemPrefersDark

  const state = {
    get value() { return isDark },
    set value(val) {
      isDark = val
      // В реальном VueUse: document.documentElement.classList.toggle('dark', val)
      console.log(`  document.documentElement.classList: ${val ? 'добавлен' : 'удалён'} класс "dark"`)
      try { localStorage.setItem(storageKey, val ? 'dark' : 'light') } catch {}
    },
    toggle() {
      this.value = !this.value
    }
  }

  return state
}

const isDark = useDark()
console.log('Текущая тема тёмная?', isDark.value)
isDark.value = true
isDark.toggle()
console.log('После двух переключений:', isDark.value)