Skip to main content

Компоновані у 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 рядках:

javascript
// 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 } }
vue
<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:

javascript
const { count } = useCounter() count = 5 // неправильно - перезаписує змінну, не ref count.value = 5 // правильно

Refs потребують .value для запису. Пряме присвоєння ламає Proxy-зв'язок і Vue перестає відстежувати зміни.

Забуте очищення:

javascript
// неправильно - таймер витікає при розмонтуванні export function useTimer() { const id = setInterval(() => doSomething(), 1000) // clearInterval відсутній } // правильно export function useTimer() { let id onMounted(() => { id = setInterval(() => doSomething(), 1000) }) onUnmounted(() => clearInterval(id)) }

Спільний змінний стан поза функцією:

javascript
const count = ref(0) // визначений один раз на рівні модуля export function useCounter() { return { count } // всі виклики отримують один і той самий ref }

Кожен компонент що викликає useCounter() мутує один count. Це виправдано тільки для синглтону. Для ізольованого стану - визначай refs всередині тіла функції.

Виклик composable поза setup():

javascript
export default { methods: { doSomething() { const { x } = useMouse() // неправильно - немає активного екземпляра компонента } } }

Без активного effect scope хуки всередині composable ігноруються, а реактивність поводиться непередбачувано. Викликай composables тільки на верхньому рівні setup() або <script setup>.

Умовний виклик composable:

javascript
// неправильно 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.

javascript
// 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 } }
vue
<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. Кнопка залишається заблокованою поки обидва поля не пройдуть перевірку.

javascript
// 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 } }
vue
<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 кожного разу коли користувач повертався на вкладку.

javascript
// 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 } }
vue
<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 немає.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?