Как писать собственные composables
Composable — это просто функция, которая использует Composition API, чтобы вынести и переиспользовать кусок логики с состоянием.
Composable — функция с именем по конвенции
use*, которая инкапсулирует реактивное состояние и связанную с ним логику, чтобы её можно было применять в любом компоненте.
Раньше мы выносили общую логику в миксины и хелперы. У миксинов был хронический недуг: непрозрачный источник свойств (откуда взялась эта переменная в шаблоне?) и конфликты имён — два миксина легко затирали поля друг друга. Composables решают обе проблемы — это обычные функции, вы видите ровно то, что они вернули, и сами даёте имена. Нет скрытой инъекции свойств, нет «магии» — есть явный вызов и явный результат.
Ещё одно преимущество — тестируемость. Composable можно протестировать почти как чистую функцию: вызвать, проверить возвращённые ref, дёрнуть метод, убедиться в новом значении. Не нужно монтировать целый компонент. Это резко удешевляет юнит-тесты бизнес-логики.
Зачем это на практике
Представьте, что в трёх разных компонентах вам нужно отслеживать положение курсора мыши. Копировать ref, обработчик и подписку в каждый компонент — путь к расхождениям. Вместо этого пишем одну функцию useMouse() и зовём её там, где нужно. Каждый вызов создаёт свой экземпляр состояния, изолированный от других.
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
const { x, y } = useMouse()
</script>
<template>
<p>Курсор: {{ x }}, {{ y }}</p>
</template>
Конвенция именования
Имя composable всегда начинается с use и пишется в camelCase: useMouse, useFetch, useDark. Это не прихоть — по этому префиксу читатель кода мгновенно понимает, что перед ним composable, который может содержать реактивное состояние и эффекты жизненного цикла. Линтеры и инструменты тоже опираются на эту конвенцию.
Сравните с обычной утилитой вроде formatDate(date): она не держит состояния и не привязана к жизненному циклу, поэтому префикс use ей не нужен. Само наличие use сигналит: «эту функцию зови внутри setup, она может подписываться на события и хранить реактивные данные». Так конвенция работает как лёгкая документация прямо в имени.
Что возвращать
Здесь есть важная развилка. Возвращайте объект из ref-ов, а не реактивный объект через reactive. Причина — деструктуризация. Если вы вернёте reactive({ x, y }) и деструктурируете его на стороне вызова, реактивность потеряется. А отдельные ref можно спокойно разложить: каждый сохраняет связь с источником.
function useCounter() {
let count = 0
const state = { count }
state.inc = () => { state.count += 1; return state.count }
return state
}
const c = useCounter()
console.log(c.inc())
console.log(c.inc())
console.log('итог:', c.count)
Вывод:
1 2 итог: 2
Это упрощённая иллюстрация формы «состояние + методы в одном объекте» на чистом JS. В настоящем Vue вместо обычных полей будут ref, но принцип тот же: composable отдаёт и данные, и функции для их изменения.
Реактивные аргументы и toValue
Хороший composable принимает аргументы, которые могут быть как обычным значением, так и ref или геттером. Тогда его можно «подвязать» к реактивному источнику. Ключ к этому — функция toValue() из Vue: она нормализует вход — разворачивает ref, вызывает геттер, а простое значение отдаёт как есть.
<script setup>
import { ref, watchEffect, toValue } from 'vue'
function useDoubled(source) {
const result = ref(0)
watchEffect(() => {
result.value = toValue(source) * 2
})
return result
}
const n = ref(5)
const doubled = useDoubled(n) // реактивно: меняем n — пересчитается
const fixed = useDoubled(10) // статично: число 10
</script>
Оборачивание в watchEffect важно: оно автоматически перечитывает toValue(source) при изменении источника, поэтому useDoubled(n) реагирует на правки n.
Очистка эффектов
Любой composable, который что-то «подписывает» (слушатели событий, таймеры, веб-сокеты), обязан это убирать. Иначе при размонтировании компонента подписка останется висеть — это утечка памяти и фантомные срабатывания. Используйте onUnmounted или функцию очистки внутри watchEffect.
<script setup>
import { ref, onUnmounted } from 'vue'
function useInterval(callback, delay) {
const id = setInterval(callback, delay)
onUnmounted(() => clearInterval(id))
return { stop: () => clearInterval(id) }
}
</script>
Очистка внутри watchEffect
Когда эффект пересоздаётся (например, поменялся URL для подписки), нужно убрать предыдущую подписку перед созданием новой. Для этого watchEffect даёт функцию onCleanup:
<script setup>
import { watchEffect, toValue } from 'vue'
function useSocket(urlSource) {
watchEffect((onCleanup) => {
const ws = new WebSocket(toValue(urlSource))
onCleanup(() => ws.close())
})
}
</script>
Как это работает под капотом
Никакой магии: composable вызывается синхронно внутри setup (или <script setup>). В этот момент активен «текущий экземпляр компонента», и хуки вроде onMounted, onUnmounted регистрируются именно на него. Поэтому composable нельзя вызывать асинхронно (после await) или внутри обработчика события — там контекст компонента уже потерян, и хуки молча не сработают. Реактивные ref внутри composable — те же обычные ref; каждый вызов функции создаёт новый набор, отсюда и изоляция между компонентами.
Частые ошибки
- Возвращать
reactive(...)и деструктурировать на вызове — реактивность теряется. Возвращайте объект изref. - Забыть очистку подписок/таймеров — утечки памяти и срабатывания после размонтирования.
- Читать аргумент-
refкакsource.valueвместоtoValue(source)— тогда composable не примет геттер и не будет реагировать на смену источника. - Вызывать composable внутри обработчика или после
await— хуки жизненного цикла не привяжутся. - Класть в один composable слишком много несвязанного — лучше несколько маленьких, которые комбинируются.
Итоги
- Composable — функция
use*, инкапсулирующая реактивное состояние и логику для переиспользования. - Возвращайте объект из
ref, чтобы его можно было безопасно деструктурировать. - Принимайте аргументы через
toValue, чтобы поддержать и значения, иref, и геттеры. - Всегда чистите эффекты в
onUnmountedили черезonCleanupвwatchEffect. - Вызывайте composables синхронно в
setup— только там доступен контекст компонента.