Компоновані у Vue.js
Composables - це функції у Vue 3, які збирають реактивний стан і логіку через Composition API. Як утилітарні функції, але з ref, computed і хуками життєвого циклу всередині - запаковуєш один раз і підключаєш де завгодно.
Теорія
TL;DR
- Аналогія: composable - це утилітарний пояс для компонента. Пакуєш реактивний стан і функції один раз, використовуєш будь-де без переписування.
- Головна різниця від Options API: він розкидає логіку по
data,methodsіmounted; composable тримає ту ж логіку в одній портативній функції. - Головне правило: composable повертає refs. Завжди. Повернення
reactive-об'єкта ламає деструктуризацію. - Правило вибору: та сама логіка в 2+ компонентах - виноси в composable. В одному компоненті - inline
setup()підійде. - Завжди викликай на верхньому рівні
setup()або<script setup>, ніколи всерединіif-блоків або циклів.
Швидкий приклад
Класичний useMouse з документації Vue показує повний патерн менш ніж у 15 рядках:
// useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX // Vue відстежує зміни через Proxy
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update)) // обов'язкове очищення
return { x, y }
}<template><p>X: {{ x }}, Y: {{ y }}</p></template>
<script setup>
import { useMouse } from './useMouse'
const { x, y } = useMouse()
</script>x і y залишаються реактивними після деструктуризації, бо це об'єкти ref, а не прості числа. Шаблон оновлюється в реальному часі при кожному русі миші.
Головна різниця від Options API
Options API прив'язує логіку до одного компонента, розкидаючи її по data, methods, mounted і computed. Щоб перевикористати - або копіюєш, або берешся за mixins, які автоматично зливаються в прототип компонента і створюють конфлікти імен.
Composables виносять логіку в прості функції. Джерело кожного значення явне. Два composables можуть повертати ref з іменем count без жодного конфлікту - ти просто деструктуруєш їх з різними іменами. Тестувати також простіше: викликаєш функцію, перевіряєш refs, DOM не потрібен.
Коли використовувати
- Та сама логіка в 2+ компонентах (відстеження миші, валідація форм, лічильники) - composable краще за копіювання.
- Декільком компонентам потрібен однаковий стан, ізольований для кожного екземпляра - composable замість глобального store.
- Хочеш тестувати реактивну логіку окремо - composable замість повного тесту компонента.
- Потрібне tree-shaking невикористаної логіки - composables видаляються бандлером якщо не використовуються.
- Логіка тільки в одному місці - залишай inline у
setup().
Як Vue відстежує реактивність у composables
Коли composable виконується всередині setup(), він працює в межах активного effect scope компонента. Будь-який ref або computed, створений там, прив'язується до цього scope. Vue використовує Proxy для перехоплення читань і записів - так він відстежує, які ефекти залежать від яких даних.
Хуки onMounted і onUnmounted, зареєстровані всередині composable, автоматично прив'язуються до компонента що викликає. Коли компонент розмонтується, Vue зупиняє effect scope і запускає всі колбеки очищення. Саме тому забутий onUnmounted у composable викликає витік пам'яті - слухач або таймер живе довше за компонент.
Типові помилки
Перезапис ref замість оновлення .value:
const { count } = useCounter()
count = 5 // неправильно - перезаписує змінну, не ref
count.value = 5 // правильноRefs потребують .value для запису. Пряме присвоєння ламає Proxy-зв'язок і Vue перестає відстежувати зміни.
Забуте очищення:
// неправильно - таймер витікає при розмонтуванні
export function useTimer() {
const id = setInterval(() => doSomething(), 1000)
// clearInterval відсутній
}
// правильно
export function useTimer() {
let id
onMounted(() => { id = setInterval(() => doSomething(), 1000) })
onUnmounted(() => clearInterval(id))
}Спільний змінний стан поза функцією:
const count = ref(0) // визначений один раз на рівні модуля
export function useCounter() {
return { count } // всі виклики отримують один і той самий ref
}Кожен компонент що викликає useCounter() мутує один count. Це виправдано тільки для синглтону. Для ізольованого стану - визначай refs всередині тіла функції.
Виклик composable поза setup():
export default {
methods: {
doSomething() {
const { x } = useMouse() // неправильно - немає активного екземпляра компонента
}
}
}Без активного effect scope хуки всередині composable ігноруються, а реактивність поводиться непередбачувано. Викликай composables тільки на верхньому рівні setup() або <script setup>.
Умовний виклик composable:
// неправильно
if (featureFlag) {
const { data } = useFetch('/api/data')
}
// правильно
const { data } = useFetch('/api/data')
if (featureFlag) { /* використовуй data */ }Виклики composables мають бути безумовними. Відстеження ефектів у Vue залежить від стабільного порядку викликів.
Де зустрічається в реальних проектах
- VueUse (50k+ зірок на GitHub) містить 200+ composables для продакшену:
useMouse,useStorage,useIntersectionObserver,useDebounceFn. useFetchіuseAsyncDataу Nuxt 3 - composables для отримання даних з підтримкою SSR.- Логіка Pinia store часто обгортається в composable, щоб компоненти не зверталися до store напряму.
- Vitest тест-сьюти викликають composables напряму для тестування реактивної логіки без монтування компонента.
- Конвенція іменування: завжди префікс
use.useMouse,useFetch,useAuth. Без префікса - це звичайна функція, і інші розробники не здогадаються що вона несе реактивний стан.
Питання на співбесіді
Q: Яка різниця між composable і mixin?
A: Mixins автоматично зливають властивості в прототип компонента. Неможливо зрозуміти звідки прийшла властивість, і два mixins можуть перезаписати методи один одного. Composables повертають звичайні об'єкти. Джерело кожного значення явне у місці виклику, конфлікти імен неможливі.
Q: Як composables працюють з SSR?
A: На сервері немає window або DOM, тому уникай браузерних API на верхньому рівні composable. Використовуй onMounted для захисту - він не виконується на сервері. Для отримання даних використовуй onServerPrefetch або useAsyncData у Nuxt.
Q: Чи можна викликати composable всередині іншого composable?
A: Так. Система реактивності Vue відстежує залежності між вкладеними викликами. Внутрішні refs прив'язуються до scope зовнішнього composable, який прив'язується до scope компонента. Саме так VueUse будує складні composables зі простих.
Q: (Senior) Чому composable може викликати витік пам'яті у списку з 1000 елементів?
A: Кожен елемент виконує composable незалежно, створюючи власні слухачі або таймери. Без очищення в onUnmounted розмонтування елементів залишає 1000 осиротілих слухачів. Рішення - завжди очищення в onUnmounted всередині composable, або передача спільного екземпляра через пропс.
Q: Як типізувати composable у TypeScript?
A: Достатньо анотації типу повернення: function useCounter(): { count: Ref<number>; increment: () => void }. Для аргументів що можуть бути звичайним значенням, ref або computed - використовуй тип MaybeRef<T> з Vue і toValue() для нормалізації.
Приклади
Базовий: useCounter
Лічильник з increment, decrement і reset. Демонструє базовий патерн: створюєш refs всередині функції, повертаєш через return.
// 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>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</template>
<script setup>
import { useCounter } from './useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>Два компоненти що використовують useCounter() отримують ізольований count кожен. Спільного стану немає, якщо не виносити ref за межі функції.
Середній: useValidator для форм авторизації
Валідація email і пароля з computed isValid і списком errors. Кнопка залишається заблокованою поки обидва поля не пройдуть перевірку.
// useValidator.js
import { ref, computed } from 'vue'
export function useValidator(initialEmail = '', initialPass = '') {
const email = ref(initialEmail)
const password = ref(initialPass)
const errors = ref([])
const isValid = computed(() => {
errors.value = []
if (!email.value.includes('@')) errors.value.push('Невірний email')
if (password.value.length < 8) errors.value.push('Пароль занадто короткий')
return errors.value.length === 0
})
return { email, password, errors, isValid }
}<template>
<input v-model="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Пароль" />
<ul v-if="errors.length">
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
<button :disabled="!isValid">Відправити</button>
</template>
<script setup>
import { useValidator } from './useValidator'
const { email, password, errors, isValid } = useValidator()
</script>isValid перераховується кожного разу при зміні email або password. Кнопка блокується автоматично. Додаткові watcher-и не потрібні.
Просунутий: useFetchOnFocus з abort controller
Перезапитує дані коли вкладка браузера отримує фокус. Складнощі: якщо користувач швидко перемикає вкладки, попередній запит має бути скасований до початку нового. Я зіштовхнувся з цим race condition у продакшені при побудові дашборду що опитував endpoint кожного разу коли користувач повертався на вкладку.
// useFetchOnFocus.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useFetchOnFocus(url) {
const data = ref(null)
const loading = ref(false)
let abortController = null
async function fetchData() {
if (abortController) abortController.abort() // скасовуємо поточний запит
abortController = new AbortController()
loading.value = true
try {
const res = await fetch(url, { signal: abortController.signal })
data.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') console.error(e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchData()
window.addEventListener('focus', fetchData)
})
onUnmounted(() => {
window.removeEventListener('focus', fetchData)
if (abortController) abortController.abort() // очищення незавершеного запиту
})
return { data, loading, refetch: fetchData }
}<template>
<p v-if="loading">Завантаження...</p>
<pre v-else>{{ data }}</pre>
<button @click="refetch">Оновити</button>
</template>
<script setup>
import { useFetchOnFocus } from './useFetchOnFocus'
const { data, loading, refetch } = useFetchOnFocus('/api/dashboard')
</script>Швидкі перемикання вкладок скасовують старий запит до початку нового. Ref data оновлюється тільки з останнього завершеного запиту. Застарілих даних немає, race conditions немає.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.