Skip to content
← К сниппетам
Vue

Composables для Vue 3, которые реально переиспользуются

Девять composables для Vue 3, закрывающих реактивную обвязку, которую переписывает каждое приложение: брейкпоинты, media-запросы, тема, буфер обмена, drag and drop, IntersectionObserver, debounce-ref, определение платформы и режим отображения.

Composable стоит выносить тогда, когда он владеет чем-то, чем не должен владеть компонент: слушателем, который надо снять, matchMedia-запросом, который надо перевычислить, таймером, который надо очистить. Все девять ниже существуют именно поэтому — переиспользование здесь побочный эффект, а суть в управлении жизненным циклом.

Вся работа — в очистке

Баг, который они предотвращают, всегда один и тот же. Компонент вешает слушатель resize в onMounted, компонент размонтируется, слушатель остаётся, и колбэк продолжает дёргать реактивную ссылку, которая больше не принадлежит смонтированному дереву. В разработке это выглядит как ничего; в долгоживущем SPA накапливается, пока скролл не начнёт тормозить.

Каждый composable здесь регистрирует своё снятие через onScopeDispose или onUnmounted, то есть использующий его компонент физически не может забыть про очистку. Это ценнее сэкономленных строк.

Media-запросы лучше слушателя resize

useBreakpoints и useMediaQuery оба оборачивают window.matchMedia, а не слушают resize. Разница существенная: matchMedia срабатывает только когда результат запроса реально изменился, а resize стреляет непрерывно при перетаскивании окна и вынуждает делать debounce и перезамеры. Заодно он оперирует теми же брейкпоинтами, что и CSS, поэтому JavaScript и стили не могут разойтись.

useMediaQuery покрывает и не-ширинные запросы — prefers-color-scheme, prefers-reduced-motion, pointer: coarse, — благодаря чему useTheme следует системной настройке, а интерфейс может уважать просьбу пользователя об уменьшении анимации.

SSR — место, где composables ломаются

Nuxt рендерит компоненты на сервере, где нет ни window, ни document, ни navigator, ни localStorage. Composable, обращающийся к любому из них на этапе setup, роняет не браузер, а сборку. Используемый здесь приём — инициализироваться безопасным значением и читать настоящее в onMounted; это заодно снимает рассинхрон гидратации, когда сервер предположил одно, а клиент обнаружил другое.

useTheme и useViewMode сохраняются в localStorage, поэтому оба требуют такой обработки: сервер не знает, что пользователь выбрал в прошлый раз, и попытка сделать вид, что знает, даёт заметное мигание при загрузке.

Буфер обмена и drag-and-drop устроены как разрешения

navigator.clipboard.writeText возвращает промис, который отклоняется — когда страница не в фокусе, когда документ отдан не по HTTPS или когда пользователь отказал в разрешении. Считать запись гарантированной значит получить кнопку, которая молча ничего не делает. Composable поднимает ошибку наверх, чтобы интерфейс мог о ней сказать.

VueVue

Хук useBreakpoints

Vue-композабл, который реактивно отслеживает ширину экрана и отдаёт флаги isMobile/isTablet/isDesktop.

Как это работает: один обработчик resize обновляет реактивный windowWidth, а три computed-флага вычисляются от него — поэтому любой компонент, использующий этот хук, автоматически перерисовывается при пересечении брейкпоинта.

import { ref, onMounted, onUnmounted, computed } from 'vue'

export function useBreakpoints() {
  const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 0)

  const onResize = () => {
    windowWidth.value = window.innerWidth
  }

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

  const isMobile = computed(() => windowWidth.value < 768)
  const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024)
  const isDesktop = computed(() => windowWidth.value >= 1024)

  return { windowWidth, isMobile, isTablet, isDesktop }
}

// Example usage
const { isMobile } = useBreakpoints()
VueVue

Хук useClipboardCopy

Vue-композабл, который копирует текст в буфер обмена с фолбэком для старых браузеров и коротким состоянием "скопировано".

Как это работает: сначала используется современный API navigator.clipboard.writeText, а при его отсутствии — фолбэк через скрытый <textarea> и document.execCommand('copy'); флаг copied автоматически сбрасывается через 1.5 секунды для UI-обратной связи.

import { ref } from 'vue'

export function useClipboardCopy() {
  const isCopying = ref(false)
  const copied = ref(false)

  async function copy(text) {
    if (!text) return false

    isCopying.value = true
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text)
      } else {
        const el = document.createElement('textarea')
        el.value = text
        el.style.position = 'fixed'
        el.style.opacity = '0'
        document.body.appendChild(el)
        el.select()
        document.execCommand('copy')
        document.body.removeChild(el)
      }
      copied.value = true
      setTimeout(() => { copied.value = false }, 1500)
      return true
    } catch {
      return false
    } finally {
      isCopying.value = false
    }
  }

  return { copy, isCopying, copied }
}
VueVue

useDebouncedRef — отложенное реактивное значение

Vue-композабл, возвращающий пару ref: «живое» значение и debounced-версию, которая обновляется только после паузы в вводе. Идеально для поисковых запросов и фильтров.

Как это работает: value обновляется немедленно (для UI), debounced — только спустя delay мс после последнего изменения. Watch на value сбрасывает и перезапускает таймер при каждом новом вводе.

import { ref, watch } from 'vue'

export function useDebouncedRef(initialValue, delay = 300) {
  const value = ref(initialValue)
  const debounced = ref(initialValue)
  let timer = null

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

  return { value, debounced }
}

// Example usage — поиск с debounce
const { value: query, debounced: debouncedQuery } = useDebouncedRef('', 400)

// Запрос к API только когда пользователь перестал печатать
watch(debouncedQuery, (q) => {
  if (q.length >= 2) fetchResults(q)
})
<template>
  <input v-model="query" placeholder="Поиск..." />
  <span v-if="query !== debouncedQuery">Печатает…</span>
</template>
VueVue

Хук useDragAndDrop

Vue-композабл для drag-and-drop загрузки файлов — отслеживает состояние перетаскивания и передаёт брошенные файлы в ваш колбэк.

Как это работает: счётчик перетаскивания (а не одиночный булев флаг) корректно отслеживает вложенные события dragenter/dragleave, потому что браузер вызывает их для каждого дочернего элемента, над которым проходит курсор.

import { ref } from 'vue'

export function useDragAndDrop(options) {
  const isDragging = ref(false)
  let dragCounter = 0

  function onDragEnter(e) {
    e.preventDefault()
    if (!e.dataTransfer) return

    dragCounter++
    if (options.validateDrag) {
      isDragging.value = options.validateDrag(e.dataTransfer)
    } else if (e.dataTransfer.types.includes('Files')) {
      isDragging.value = true
    }
  }

  function onDragLeave(e) {
    e.preventDefault()
    dragCounter = Math.max(0, dragCounter - 1)
    if (dragCounter === 0) isDragging.value = false
  }

  function onDragOver(e) {
    e.preventDefault()
  }

  function onDrop(e) {
    e.preventDefault()
    dragCounter = 0
    isDragging.value = false

    const files = e.dataTransfer?.files
    if (files?.length) options.onDrop(files)
  }

  return {
    isDragging,
    dragHandlers: { dragenter: onDragEnter, dragleave: onDragLeave, dragover: onDragOver, drop: onDrop }
  }
}

// Example usage — spread dragHandlers onto a drop-zone element in your template
const { isDragging, dragHandlers } = useDragAndDrop({
  onDrop: (files) => console.log('Dropped', files.length, 'file(s)')
})
VueVue

useIntersectionObserver — Vue composable

Vue-композабл для отслеживания видимости элемента в viewport — подходит для lazy-load изображений, анимаций при скролле и infinite scroll.

Как это работает: создаёт IntersectionObserver после монтирования компонента и подключает его к переданному target ref. isIntersecting обновляется реактивно. hasIntersected остаётся true после первого появления — удобно для одноразовых анимаций.

import { ref, onMounted, onUnmounted } from 'vue'

export function useIntersectionObserver(options = {}) {
  const target = ref(null)
  const isIntersecting = ref(false)
  const hasIntersected = ref(false)

  let observer = null

  onMounted(() => {
    if (!target.value) return

    observer = new IntersectionObserver(([entry]) => {
      isIntersecting.value = entry.isIntersecting
      if (entry.isIntersecting) hasIntersected.value = true
    }, { threshold: 0.1, ...options })

    observer.observe(target.value)
  })

  onUnmounted(() => observer?.disconnect())

  return { target, isIntersecting, hasIntersected }
}
<!-- Example usage — fade-in при появлении в viewport -->
<script setup>
import { useIntersectionObserver } from '~/composables/useIntersectionObserver'

const { target, hasIntersected } = useIntersectionObserver({ threshold: 0.2 })
</script>

<template>
  <div
    ref="target"
    :class="['card', hasIntersected ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4']"
    class="transition-all duration-500"
  >
    Контент появится при скролле
  </div>
</template>
VueVue

useMediaQuery — реактивный media query

Vue-композабл, который возвращает реактивный ref, обновляемый при изменении результата CSS media query. SSR-безопасный — не падает на сервере.

Как это работает: использует window.matchMedia для создания медиа-запроса и подписывается на событие change. Проверка typeof window !== 'undefined' делает хук безопасным для SSR/Nuxt.

import { ref, onMounted, onUnmounted } from 'vue'

export function useMediaQuery(query) {
  const matches = ref(false)
  let mql = null

  function onchange(e) {
    matches.value = e.matches
  }

  onMounted(() => {
    mql = window.matchMedia(query)
    matches.value = mql.matches
    mql.addEventListener('change', onchange)
  })

  onUnmounted(() => {
    mql?.removeEventListener('change', onchange)
  })

  return matches
}

// Example usage
const isDesktop  = useMediaQuery('(min-width: 1024px)')
const isMobile   = useMediaQuery('(max-width: 767px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
<template>
  <nav v-if="isDesktop">Десктопная навигация</nav>
  <MobileMenu v-else />
</template>
VueVue

Хук usePlatform

Определяет macOS среди других платформ, чтобы показывать правильный символ горячей клавиши (⌘ или Ctrl).

Как это работает: navigator.platform сообщает ОС, под которой запущен браузер; сравнение с Mac/iPod/iPhone/iPad позволяет выбрать правильный символ модификатора для подсказок горячих клавиш в интерфейсе.

import { computed } from 'vue'

export function usePlatform() {
  const isMac = /MAC|IPOD|IPHONE|IPAD/.test(navigator.platform?.toUpperCase() || '')
  const primaryKeySymbol = computed(() => (isMac ? '⌘' : 'Ctrl'))

  return { isMac, primaryKeySymbol }
}

// Example usage — show the right shortcut hint per OS
const { primaryKeySymbol } = usePlatform()
console.log(`Save: ${primaryKeySymbol.value}+S`)
VueVue

Хук useTheme

Vue-композабл для определения и переключения светлой/тёмной/системной темы с сохранением в localStorage.

Как это работает: текущий выбор ('light' / 'dark' / 'system') читается из localStorage, а слушатель matchMedia держит systemIsDark в актуальном состоянии, чтобы isDark оставался верным, даже если выбрано "system" и системная тема меняется.

import { ref, computed, onMounted, onUnmounted } from 'vue'

export function useTheme() {
  const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null
  const theme = ref(stored || 'system')
  const systemIsDark = ref(false)

  const mediaQuery = () =>
    typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null

  const isDark = computed(() =>
    theme.value === 'system' ? systemIsDark.value : theme.value === 'dark'
  )

  function setTheme(value) {
    theme.value = value
    localStorage.setItem('theme', value)
  }

  const onSystemChange = (e) => { systemIsDark.value = e.matches }

  onMounted(() => {
    const mq = mediaQuery()
    systemIsDark.value = mq?.matches ?? false
    mq?.addEventListener('change', onSystemChange)
  })

  onUnmounted(() => mediaQuery()?.removeEventListener('change', onSystemChange))

  return { theme, isDark, setTheme }
}

// Example usage
const { isDark, setTheme } = useTheme()
setTheme('dark') // 'light' | 'dark' | 'system'
VueVue

Хук useViewMode

Переключение между сеткой и списком с сохранением выбора в localStorage между визитами.

Как это работает: режим читается из localStorage при монтировании и записывается обратно при каждом изменении через watch, поэтому выбор пользователя (сетка/список) сохраняется между перезагрузками страницы.

import { ref, onMounted, watch } from 'vue'

export function useViewMode({ storageKey = 'view_mode', defaultMode = 'grid' } = {}) {
  const mode = ref(defaultMode)

  onMounted(() => {
    const saved = localStorage.getItem(storageKey)
    if (saved === 'grid' || saved === 'list') mode.value = saved
  })

  watch(mode, (value) => localStorage.setItem(storageKey, value))

  const toggle = () => { mode.value = mode.value === 'grid' ? 'list' : 'grid' }

  return { mode, setMode: (v) => (mode.value = v), toggle }
}

// Example usage
const { mode, toggle } = useViewMode()
// CONNECT

Контактная информация

Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.

Укажите email или Telegram, чтобы я мог вам ответить.