Перейти к содержимому

6. Composables

Composables — это переиспользуемые куски логики на основе Vue 3 Composition API. В Nuxt они живут в папке composables/ и импортируются автоматически. Никаких import useMyThing from '~/composables/useMyThing' — просто вызываешь useMyThing().


composables/useCounter.ts
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>

  1. Имя начинается с use — это соглашение Vue
  2. Файл в composables/ — Nuxt авто-импортирует
  3. Возвращает реактивные данныеref, computed, функции
  4. Может быть асинхроннымasync/await внутри
composables/
├── useAuth.ts → useAuth()
├── useCart.ts → useCart()
├── useTheme.ts → useTheme()
├── useDarkMode.ts → useDarkMode()
└── utils/
├── useFormat.ts → useFormat()
└── useDebounce.ts → useDebounce()

Обычный ref не работает для глобального состояния в SSR — данные не передаются с сервера на клиент. Используй useState:

composables/useTheme.ts
export const useTheme = () => {
// useState — ключ уникален, данные сериализуются
const theme = useState<'light' | 'dark'>('theme', () => 'light')
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
return { theme, toggle }
}
ComponentA.vue
<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'
// - Данные передаются с сервера на клиент
// - Гидрация работает корректно

// В composable или компоненте
const nuxtApp = useNuxtApp()
// Доступ к Vue app
nuxtApp.vueApp
// Хуки жизненного цикла Nuxt
nuxtApp.hook('page:start', () => {
console.log('Начало загрузки страницы')
})
// Глобальные плагины
nuxtApp.$toast.success('Привет!') // если есть плагин $toast
// Пина стор
nuxtApp.$pinia
// Роутер
nuxtApp.$router
// Вызов хука
nuxtApp.callHook('app:mounted')

composables/useLocalStorage.ts
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
}
composables/useDebounce.ts
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
}
composables/useWindowSize.ts
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 }
}

composables/useUserProfile.ts
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 }
}

composables/useAuth.ts
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 }
}

composables/useCart.ts
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/usePlatform.ts
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/__tests__/useCounter.test.ts
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)
})
})