Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Компоновані у Vue.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Composables** - функції у Vue 3, які збирають реактивний стан і логіку через Composition API. ```javascript export function useMouse() { const x = ref(0), y = ref(0) const update = e => { x.value = e.pageX; y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } } ``` **Головне:** composables замінюють mixins - логіка явна і тестована. Префікс `use`, повертають refs, завжди з очищенням у `onUnmounted`.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 немає.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.