6. Composables
🪝 Composables в Nuxt 3
Заголовок раздела «🪝 Composables в Nuxt 3»Composables — это переиспользуемые куски логики на основе Vue 3 Composition API. В Nuxt они живут в папке composables/ и импортируются автоматически. Никаких import useMyThing from '~/composables/useMyThing' — просто вызываешь useMyThing().
Что такое Composable? 🧩
Заголовок раздела «Что такое Composable? 🧩»export const useCounter = (initialValue = 0) => { const count = ref(initialValue) const doubled = computed(() => count.value * 2)
const increment = () => count.value++ const decrement = () => count.value-- const reset = () => { count.value = initialValue }
return { count, doubled, increment, decrement, reset }}<!-- pages/index.vue — просто используем, без импорта! --><script setup>const { count, doubled, increment, decrement } = useCounter(10)</script>
<template> <div> <p>Счёт: {{ count }}</p> <p>Удвоен: {{ doubled }}</p> <button @click="increment">+</button> <button @click="decrement">-</button> </div></template>Правила Composables 📋
Заголовок раздела «Правила Composables 📋»- Имя начинается с
use— это соглашение Vue - Файл в
composables/— Nuxt авто-импортирует - Возвращает реактивные данные —
ref,computed, функции - Может быть асинхронным —
async/awaitвнутри
composables/├── useAuth.ts → useAuth()├── useCart.ts → useCart()├── useTheme.ts → useTheme()├── useDarkMode.ts → useDarkMode()└── utils/ ├── useFormat.ts → useFormat() └── useDebounce.ts → useDebounce()useState — SSR-совместимое состояние 🌐
Заголовок раздела «useState — SSR-совместимое состояние 🌐»Обычный ref не работает для глобального состояния в SSR — данные не передаются с сервера на клиент. Используй useState:
export const useTheme = () => { // useState — ключ уникален, данные сериализуются const theme = useState<'light' | 'dark'>('theme', () => 'light')
const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }
return { theme, toggle }}<script setup>const { theme, toggle } = useTheme()// theme.value === 'light' — одинаково на сервере и клиенте</script>
<!-- ComponentB.vue --><script setup>const { theme } = useTheme()// То же самое состояние! Не новый ref!</script>Почему useState, а не ref?
// ❌ Проблема с ref в SSR:const count = ref(0) // Создаётся новый ref при каждом вызове // Нет общего состояния между компонентами на сервере // Данные не гидрируются на клиенте
// ✅ useState решает это:const count = useState('counter', () => 0)// - Один экземпляр на ключ 'counter'// - Данные передаются с сервера на клиент// - Гидрация работает корректноuseNuxtApp — доступ к Nuxt инстансу 🎛️
Заголовок раздела «useNuxtApp — доступ к Nuxt инстансу 🎛️»// В composable или компонентеconst nuxtApp = useNuxtApp()
// Доступ к Vue appnuxtApp.vueApp
// Хуки жизненного цикла NuxtnuxtApp.hook('page:start', () => { console.log('Начало загрузки страницы')})
// Глобальные плагиныnuxtApp.$toast.success('Привет!') // если есть плагин $toast
// Пина сторnuxtApp.$pinia
// РоутерnuxtApp.$router
// Вызов хукаnuxtApp.callHook('app:mounted')Создание реальных composables 🔨
Заголовок раздела «Создание реальных composables 🔨»useLocalStorage
Заголовок раздела «useLocalStorage»export const useLocalStorage = <T>(key: string, defaultValue: T) => { // На сервере localStorage недоступен — используем useState const value = useState<T>(key, () => { if (import.meta.client) { const stored = localStorage.getItem(key) return stored ? JSON.parse(stored) : defaultValue } return defaultValue })
// Следим за изменениями и сохраняем watch(value, (newValue) => { if (import.meta.client) { localStorage.setItem(key, JSON.stringify(newValue)) } }, { deep: true })
return value}useDebounce
Заголовок раздела «useDebounce»export const useDebounce = <T>(value: Ref<T>, delay = 300) => { const debouncedValue = ref<T>(value.value) as Ref<T> let timeout: ReturnType<typeof setTimeout>
watch(value, (newValue) => { clearTimeout(timeout) timeout = setTimeout(() => { debouncedValue.value = newValue }, delay) })
onUnmounted(() => clearTimeout(timeout))
return debouncedValue}useWindowSize
Заголовок раздела «useWindowSize»export const useWindowSize = () => { const width = ref(0) const height = ref(0)
const update = () => { width.value = window.innerWidth height.value = window.innerHeight }
onMounted(() => { update() window.addEventListener('resize', update) })
onUnmounted(() => { window.removeEventListener('resize', update) })
return { width, height }}useAsyncState — асинхронные composables 🔄
Заголовок раздела «useAsyncState — асинхронные composables 🔄»export const useUserProfile = (userId: string) => { const profile = useState(\`user-\${userId}\`, () => null) const loading = ref(false) const error = ref<Error | null>(null)
const fetchProfile = async () => { loading.value = true error.value = null try { const data = await $fetch(\`/api/users/\${userId}\`) profile.value = data } catch (e) { error.value = e as Error } finally { loading.value = false } }
// Загружаем при инициализации if (!profile.value) { fetchProfile() }
return { profile, loading, error, refresh: fetchProfile }}Composable для авторизации 🔐
Заголовок раздела «Composable для авторизации 🔐»export const useAuth = () => { const user = useState<User | null>('auth-user', () => null) const isLoggedIn = computed(() => !!user.value) const isAdmin = computed(() => user.value?.role === 'admin')
const login = async (credentials: { email: string; password: string }) => { const data = await $fetch('/api/auth/login', { method: 'POST', body: credentials, }) user.value = data.user await navigateTo('/dashboard') }
const logout = async () => { await $fetch('/api/auth/logout', { method: 'POST' }) user.value = null await navigateTo('/login') }
const fetchUser = async () => { try { user.value = await $fetch('/api/auth/me') } catch { user.value = null } }
return { user, isLoggedIn, isAdmin, login, logout, fetchUser }}Composable для корзины покупок 🛒
Заголовок раздела «Composable для корзины покупок 🛒»interface CartItem { id: number name: string price: number quantity: number}
export const useCart = () => { const items = useState<CartItem[]>('cart', () => [])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) )
const count = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0) )
const addItem = (item: Omit<CartItem, 'quantity'>) => { const existing = items.value.find(i => i.id === item.id) if (existing) { existing.quantity++ } else { items.value.push({ ...item, quantity: 1 }) } }
const removeItem = (id: number) => { const idx = items.value.findIndex(i => i.id === id) if (idx !== -1) items.value.splice(idx, 1) }
const updateQuantity = (id: number, quantity: number) => { const item = items.value.find(i => i.id === id) if (item) { if (quantity <= 0) removeItem(id) else item.quantity = quantity } }
const clear = () => { items.value = [] }
return { items, total, count, addItem, removeItem, updateQuantity, clear }}Composables и Server/Client 🖥️
Заголовок раздела «Composables и Server/Client 🖥️»export const usePlatform = () => { // import.meta.server — true во время SSR // import.meta.client — true в браузере const isServer = import.meta.server const isClient = import.meta.client
const isMobile = computed(() => { if (isServer) return false return /Android|iPhone/i.test(navigator.userAgent) })
return { isServer, isClient, isMobile }}Тестирование composables 🧪
Заголовок раздела «Тестирование composables 🧪»import { describe, it, expect } from 'vitest'import { useCounter } from '../useCounter'
describe('useCounter', () => { it('starts with initial value', () => { const { count } = useCounter(5) expect(count.value).toBe(5) })
it('increments correctly', () => { const { count, increment } = useCounter() increment() expect(count.value).toBe(1) })
it('resets to initial value', () => { const { count, increment, reset } = useCounter(10) increment() increment() reset() expect(count.value).toBe(10) })})