Паттерны composables: useLocalStorage, useEventListener
Разбираем, как устроены самые ходовые composables из библиотеки VueUse, написав их с нуля.
VueUse — большая коллекция готовых composables. Понять её философию проще всего, реализовав пару ключевых функций самому: useEventListener, useLocalStorage, useTimeout.
В прошлом уроке мы научились писать composables вообще. Теперь применим это к реальным задачам: подписка на события DOM, синхронизация состояния с localStorage и работа с таймерами. Эти три паттерна покрывают огромную долю практики.
Почему стоит понять их изнутри, а не просто подключить VueUse? Во-первых, вы перестанете бояться «чёрного ящика» — увидите, что внутри всё те же ref и хуки. Во-вторых, в реальных проектах часто нужна чуть изменённая версия (другой формат хранения, дополнительная валидация), и тогда вы быстро допишете своё. В-третьих, это лучшая тренировка композиции: мы соберём useLocalStorage поверх useEventListener, наглядно показав, как маленькие composables складываются в большие.
useEventListener: подписка с автоочисткой
Самый частый источник утечек — забытый removeEventListener. Вынесем подписку в composable, который сам снимает слушатель при размонтировании. Это база, поверх которой строятся многие другие.
<script setup>
import { onMounted, onUnmounted } from 'vue'
function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
// применение: реагируем на нажатия клавиш
useEventListener(window, 'keydown', (e) => {
console.log('нажата клавиша', e.key)
})
</script>
Теперь useMouse из прошлого урока можно переписать поверх useEventListener — композиция composables в действии: маленькие функции собираются в большие.
useLocalStorage: состояние, переживающее перезагрузку
Очень частая задача — сохранить настройку (тема, язык, черновик формы) так, чтобы она пережила обновление страницы. Идея: создаём ref, инициализируем его значением из хранилища и через watch пишем обратно при каждом изменении.
<script setup>
import { ref, watch } from 'vue'
function useLocalStorage(key, defaultValue) {
const raw = localStorage.getItem(key)
const data = ref(raw !== null ? JSON.parse(raw) : defaultValue)
watch(data, (value) => {
localStorage.setItem(key, JSON.stringify(value))
}, { deep: true })
return data
}
const theme = useLocalStorage('theme', 'light')
// theme.value = 'dark' — и значение само запишется в localStorage
</script>
Сериализация — обязательна
localStorage хранит только строки. Поэтому объекты и массивы прогоняем через JSON.stringify при записи и JSON.parse при чтении. Флаг { deep: true } нужен, чтобы watch ловил изменения внутри объекта, а не только его замену целиком. Без него запись theme.value.color = 'red' прошла бы мимо хранилища, потому что сама ссылка на объект не поменялась.
Стоит помнить и про объём: localStorage ограничен примерно пятью мегабайтами на домен и синхронен — большие объекты сериализовать в нём на каждый чих накладно. Для черновика формы или настроек этого с запасом хватает, а вот кеш сотен записей лучше держать в другом месте. И учтите: чтение/запись блокируют главный поток, так что не вызывайте их в горячем цикле.
Смоделируем логику сериализации на чистом JS, имитируя хранилище обычным объектом:
const storage = {}
function save(key, value) {
storage[key] = JSON.stringify(value)
}
function load(key, fallback) {
const raw = storage[key]
return raw !== undefined ? JSON.parse(raw) : fallback
}
save('settings', { theme: 'dark', fontSize: 16 })
const restored = load('settings', {})
console.log(restored.theme)
console.log(restored.fontSize + 2)
console.log(load('missing', 'по умолчанию'))
Вывод:
dark 18 по умолчанию
Синхронизация между вкладками
Боевая версия useLocalStorage идёт дальше: она подписывается на событие storage, которое браузер шлёт другим вкладкам при изменении хранилища. Так настройка, изменённая в одной вкладке, мгновенно подхватывается в остальных. Здесь и пригождается наш useEventListener:
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
function useLocalStorage(key, defaultValue) {
const raw = localStorage.getItem(key)
const data = ref(raw !== null ? JSON.parse(raw) : defaultValue)
watch(data, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
const onStorage = (e) => {
if (e.key === key && e.newValue !== null) {
data.value = JSON.parse(e.newValue)
}
}
onMounted(() => window.addEventListener('storage', onStorage))
onUnmounted(() => window.removeEventListener('storage', onStorage))
return data
}
</script>
useTimeout: таймеры по-человечески
Таймеры тоже про очистку. Composable useTimeout возвращает флаг готовности и сам убирает таймер при размонтировании, чтобы коллбэк не выстрелил по уже исчезнувшему компоненту. Это типичный источник предупреждений «cannot set state on unmounted component»: пользователь ушёл со страницы раньше, чем сработал setTimeout, а коллбэк всё равно трогает уже мёртвый ref.
<script setup>
import { ref, onUnmounted } from 'vue'
function useTimeout(ms) {
const ready = ref(false)
const id = setTimeout(() => { ready.value = true }, ms)
onUnmounted(() => clearTimeout(id))
return ready
}
const ready = useTimeout(2000) // ready.value станет true через 2 сек
</script>
Как это работает под капотом
Все три composable опираются на один и тот же фундамент Composition API: реактивный ref как источник истины и хуки жизненного цикла для подписки/отписки. useLocalStorage добавляет два моста с внешним миром — watch пишет из приложения в хранилище, а слушатель storage вносит изменения из хранилища обратно в приложение. Именно двусторонность делает состояние «общим» между вкладками. VueUse внутри устроен ровно так же, просто с обработкой граничных случаев (SSR, недоступность localStorage, кастомные сериализаторы).
Частые ошибки
- Класть в
localStorageобъект безJSON.stringify— получите строку"[object Object]". - Забыть
{ deep: true }вwatch— мутации внутри объекта не попадут в хранилище. - Не снимать слушатель
storage— утечка и реакция на чужие ключи (всегда сверяйтеe.key). - Парсить «битый» JSON без
try/catch— одна повреждённая запись уронит инициализацию. - Полагать, что событие
storageприходит в ту же вкладку, что изменила данные, — нет, только в другие.
Итоги
useEventListener— базовый кирпич: подписка на событие с автоматической отпиской.useLocalStorage=ref+watch(запись) + слушательstorage(чтение между вкладками).- Данные в
localStorageвсегда сериализуйте через JSON; для вложенных объектов нуженdeep: true. useTimeout/таймеры обязаны очищаться вonUnmounted.- Маленькие composables комбинируются в большие — это и есть стиль VueUse.