Паттерны 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.
Проверьте себя
1. Зачем useLocalStorage подписывается на событие storage окна?
AЧтобы сохранять данные на сервер
BЧтобы подхватывать изменения хранилища, сделанные в других вкладках браузера
CЧтобы ускорить чтение из localStorage
DЧтобы шифровать данные
2. Почему при сохранении объекта в localStorage нужен JSON.stringify?
AlocalStorage хранит только строки, иначе объект превратится в "[object Object]"
BТак быстрее работает
CJSON.stringify шифрует данные
DЭто требование Vue