После выхода Composition API разработчики приложений на Vue получили в свое распоряжение композиционные функции — еще один способ разбить код на небольшие модули для лучшей читаемости и повторного использования. Так были решены проблемы, которые мы имели применяя иные подходы: Mixins (Миксины) и Renderless Components (Компоненты без представления). Но спустя время многие из нас не достаточно хорошо владеют тонкостями написания Composables. В этой статье вы найдете описание важных моментов, благодаря которым ваш код будет становится более поддерживаемым.
Содержание
- Что такое Composable
- Composable с параметрами
- Использование опций
- Динамический return
- Рекомендации по написанию Composable
- Польза от Composable
Что такое Composable
Composable (Композиционная функция) — это функция, которая использует Composition API для инкапсуляции и повторного использования логики хранящей состояние.
Рассмотрим следующий простой пример:
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isOnline = ref(navigator.onLine)
function updateStatus() {
isOnline.value = navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateStatus)
window.addEventListener('offline', updateStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateStatus)
window.removeEventListener('offline', updateStatus)
})
</script>
<template>
<div v-if="!isOnline" class="offline-banner">
Нет подключения к интернету. Проверьте сеть.
</div>
</template>
<style scoped>
.offline-banner {
padding: 12px 16px;
background: #fef3c7;
color: #92400e;
text-align: center;
}
</style>
Логика понятна, но видно, что <script setup> занят не UI, а инфраструктурой.
Компонент работает. Однако, если завтра понадобится показывать статус сети ещё где-то — в шапке, рядом с кнопкой «Сохранить», в настройках — придётся копировать те же ref, onMounted и onUnmounted.
Перенесём всё, что относится к отслеживанию сети, в отдельную функцию. По имеющемуся соглашению название composable начинают с префикса use:
import { ref, onMounted, onUnmounted } from 'vue'
export function useOnlineStatus() {
const isOnline = ref(navigator.onLine)
function updateStatus() {
isOnline.value = navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateStatus)
window.addEventListener('offline', updateStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateStatus)
window.removeEventListener('offline', updateStatus)
})
return { isOnline }
}
Теперь компонент будет отвечать только за отображение. Читать его проще: одна строка в <script setup>, шаблон без лишнего шума:
<script setup>
import { useOnlineStatus } from '@/composables/useOnlineStatus'
const { isOnline } = useOnlineStatus()
</script>
<template>
<div v-if="!isOnline" class="offline-banner">
Нет подключения к интернету. Проверьте сеть.
</div>
</template>
<style scoped>
.offline-banner {
padding: 12px 16px;
background: #fef3c7;
color: #92400e;
text-align: center;
}
</style>
Компонент делает своё дело, а composable — своё.
Composable с параметрами
Используем toValue
Рассмотрим компонент с обращением к backend:
<script setup>
import { ref, watch } from 'vue'
const postId = ref(1)
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watch(postId, async (id) => {
loading.value = true
error.value = null
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
data.value = await res.json()
} catch (e) {
error.value = e
data.value = null
} finally {
loading.value = false
}
}, { immediate: true })
</script>
<template>
<div>
<button v-for="n in 3" :key="n" @click="postId = n">Пост {{ n }}</button>
<p v-if="loading">Загрузка...</p>
<p v-else-if="error">{{ error.message }}</p>
<article v-else-if="data">
<h2>{{ data.title }}</h2>
<p>{{ data.body }}</p>
</article>
</div>
</template>
Мы могли бы вынести обращение к fetch и первичную обработку результата в composable. Начнем с использования фиксированного URL:
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
loading.value = true
fetch(url)
.then((res) => res.json())
.then((json) => { data.value = json })
.catch((err) => { error.value = err })
.finally(() => { loading.value = false })
return { data, error, loading }
}
При подключении useFetch задаем нужный URL:
import { useFetch } from '@/composables/useFetch'
const { data, error, loading } = useFetch(
'https://jsonplaceholder.typicode.com/posts/1'
)
Компонент стал короче, но postId с кнопками пока не работает — URL зашит. То есть не используется реактивность на полную мощь.
Нужно иметь возможность принимать как строку, так и ref или getter, чтобы перезапускать запрос при изменении значения. Для этих целей прекрасно послужит toValue (доступен во Vue с версии 3.3) — он приводит любой из этих вариантов к обычному значению и даёт watchEffect отследить зависимости:
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const fetchData = () => {
loading.value = true
data.value = null
error.value = null
fetch(toValue(url)) // ref, getter или строка
.then((res) => res.json())
.then((json) => { data.value = json })
.catch((err) => { error.value = err })
.finally(() => { loading.value = false })
}
watchEffect(() => {
fetchData()
})
return { data, error, loading }
}
Код компонента — простой:
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const postId = ref(1)
const { data, error, loading } = useFetch(
() => `https://jsonplaceholder.typicode.com/posts/${postId.value}`
)
</script>
<template>
<div>
<button v-for="n in 3" :key="n" @click="postId = n">Пост {{ n }}</button>
<p v-if="loading">Загрузка...</p>
<p v-else-if="error">{{ error.message }}</p>
<article v-else-if="data">
<h2>{{ data.title }}</h2>
<p>{{ data.body }}</p>
</article>
</div>
</template>
Теперь наш Composable более универсален. Мы передаем в него getter (() => /posts/${postId.value}). Можем передавать и ref и простую строку. Все благодаря toValue.
Используем ref и unref
В одной из своих статей Майкл Тиссен (Michael Thiessen) показывает еще один прием обработки параметра composable на лету. Такой, чтобы можно было работать как с примитивом, так и с реактивной переменной (ref).
Мы могли бы написать composable, так, чтобы он работал следующим образом:
const count = ref(2)
const { increment } = useCount(count)
increment() // count.value === 3
const { count, increment } = useCount(2)
increment() // count.value === 3
Код самого useCount может выглядеть так:
import { ref } from 'vue'
export function useCount(input) {
const count = ref(input) // 2 → новый ref; ref → тот же ref
function increment() {
count.value++
}
return { count, increment }
}
Это работает благодаря тому, что если мы передадим в ref реактивную переменную, то получем на выходе ссылку на нее же:
// Создадим новый ref
const myRef = ref(0);
// Получим тот же ref
assert(myRef === ref(myRef));
Когда же нам нужен ref, а не toValue? Он нужен, если composable **владеет состоянием** и должен работать с реактивным значением внутри себя. Если же composable только **читает** реактивное состояние (разово или внутри watchEffect), то здесь подойдет toValue.
В своей статье Майкл Тиссен также говорит по unref.
// Когда нам нужно использовать простое значение в composable
export default useMyComposable(input) {
const rawValue = unref(input);
}
После выхода Vue 3.3 unref не так полезен, так как делает то же, что и toValue, но только для ref и примитива — getter не вызовет. Для чтения старого кода и cтатей полезно знать, что это предшественник toValue.
Использование опций
У composable могут быть обязательные аргументы и еще несколько необязательных настроек. Передавать их длинным списком неудобно — непонятен порядок и смысл каждого значения. Лучше использовать паттерн из первой статьи серии Vue Mastery, когда опциональные параметры собирают в объект options.
// Длинный список аргументов. Что значит каждое число?
useCount(0, 5, 100)
// Объект — настройки читаются сами
useCount(0, { step: 5, max: 100 })
Выгоды такого подхода:
1. Не нужно помнить порядок — в объекте он не важен.
2. Код лучше читается — «step: 5» понятнее, чем голая пятёрка третьим аргументом.
3. Легко расширять — новая опция не ломает существующие вызовы.
Обязательные аргументы идут отдельно, а options — последним параметром с дефолтом {}.
Расширим useCount из примера выше:
export function useCount(input, options = {}) {
const { step = 1, max = Infinity } = options
const count = ref(input)
function increment() {
count.value = Math.min(count.value + step, max)
}
return { count, increment }
}
Деструктуризация сразу задаёт значения по-умолчанию — вызывающий код передаёт только то, что нужно изменить.
const { count, increment } = useCount(0, { step: 5, max: 100 })
increment()
increment()
// count.value === 10, так как шаг 5
Без options наш composable ведёт себя как раньше — step равен 1, а ограничения по значению нет.
Тот же приём используется в библиотеке в VueUse:
useTitle('Заголовок', { titleTemplate: '%s | Мой сайт' })
useRefHistory(count, { deep: true, capacity: 15 })
Динамический return
Иногда composable по-умолчанию отдаёт одно значение, а по запросу — объект с методами. Паттерн из третьей статьи Vue Mastery можно озвучить так: возвращаем простое, когда хватает простого; отдаем подробности, когда нужен контроль.
Например useInterval:
// Достаточно счётчика
const counter = useInterval(1000)
// Нужны pause и resume
const { counter, pause, resume } = useInterval(1000, { controls: true })
Здесь меньше шума в простых случаях. Нет лишней деструктуризации.
Реализация выглядит так:
import { ref, onMounted, onUnmounted } from 'vue'
export function useInterval(ms, options = {}) {
const { controls = false } = options
const counter = ref(0)
let id
const pause = () => clearInterval(id)
const resume = () => {
pause()
id = setInterval(() => counter.value++, ms)
}
onMounted(resume)
onUnmounted(pause)
return controls ? { counter, pause, resume } : counter
}
Опция controls меняет только форму return — логика внутри одна.
Использование:
<script setup>
import { useInterval } from '@/composables/useInterval'
const ticks = useInterval(1000)
const { counter, pause, resume } = useInterval(500, { controls: true })
</script>
<template>
<p>Тиков: {{ ticks }}</p>
<p>Счёт: {{ counter }}</p>
<button @click="pause">Пауза</button>
<button @click="resume">Продолжить</button>
</template>
Во VueUse так устроены useInterval, useNow, useTimeout. Ищите опцию controls.
Однако, стоит заметить, что это не дает какой-то большой выгоды в читаемости кода. Простой случай выглядел бы так:
const { ticks } = useInterval(1000)
вместо
const ticks = useInterval(1000)
Не слишком большая разница, не так ли?
Рекомендации по написанию Composable
В документации Vue расказывается о нескольких правилах которые стоит соблюдать.
Именование
Имя для composable должно начинаться с «use» и быть в формате camelCase.
Входящие величины
Если Ваш composable требует какой-то входящий параметр, то желательно иметь поддержку передачи в него ref и getter, даже если в реальности реактивность не используется. Предпочтительнее использовать toValue вместо рассчета на передачу примитива. Как это делается описывалось выше.
Возврат значений
Рекомендуется всегда возвращать простой объект с набором ref, а не реактивный (результат вызова reactive).
Побочные эффекты
В composable побочные эффекты — это нормально. Но стоит помнить о следующих нюансах:
onMounted()— здесь создаем обработчики событий, таймеры, работаем с DOM.onUnmounted()— здесь всё это очищаем.- При использовании SSR (Server-Side Rendering) нельзя обращаться к
window,documentи DOM внеonMounted(), потому что на сервере их не существует.
Ограничения
Composables должны вызываться только в секции <script setup> или в хуке setup().
Также <script setup> — то место, где мы можем вызвать composable после использования await. В хуке setup() так делать нельзя.
Если у вас еще используется Options API, то composables должны быть вызваны в setup() , а нужные данные из них должны быть в return этого хука.
Польза от Composable
Композиционные функции создаются не только для повторного использования кода, но и для лучшей организации кода. Они позволяют нам разделить сложность в работе с реактивностью одного компонента на несколько модулей, каждый из которых проще удерживать в голове.
Composables организуют код понятнее чем другие, более ранние подходы во Vue — примести (mixins) и компоненты без отображения (renderless components).
Полезные ссылки
- Официальная документация
- Серия статьей Макла Тиссена — Coding Better Composable