vue-storage-kit — реактивное хранилище для Vue 3 с шифрованием, миграциями и синхронизацией вкладок

24.05.2026
vue-storage-kit — реактивное хранилище для Vue 3 с шифрованием, миграциями и синхронизацией вкладок

Каждое Vue-приложение рано или поздно упирается в хранение данных. localStorage.setItem — три строки, работает. Но потом появляются требования: данные должны протухать через N минут, старые ключи нужно мигрировать под новую схему, секреты нельзя хранить в открытом виде, несколько вкладок должны видеть одно и то же состояние. И всё это — реактивно, как ref, чтобы изменение автоматически перерисовывало интерфейс без ручных подписок и watch.

В какой-то момент вокруг localStorage накапливается столько обёрток — сервис для сериализации, хелпер для TTL, шина событий для синхронизации — что это превращается в самостоятельную инфраструктуру. Которую нужно поддерживать, тестировать и переносить между проектами.

Написал vue-storage-kit — один пакет, который закрывает все эти сценарии. localStorage, sessionStorage, IndexedDB, cookies — всё через единый реактивный API. TTL, AES-GCM шифрование через Web Crypto без внешних библиотек, схема-миграции с up/down-функциями, BroadcastChannel синхронизация вкладок с leader election, Pinia-плагин для персистентности, сжатие через Compression Streams API. Единственная peer-зависимость — Vue 3.


Установка

bash Copy
npm install vue-storage-kit

Пакет поставляется как tree-shakeable ESM и CommonJS одновременно. Субпакеты /crypto, /sync, /pinia, /compress — отдельные entry points, которые не попадают в бандл если вы их не используете. Это важно: шифрование и синхронизация загружаются динамически только когда они реально нужны, а не при каждом импорте useStorage.


useStorage — основа всего

useStorage — это реактивный Ref над любым хранилищем. Под капотом — watch с flush: 'sync', то есть запись в storage происходит в том же микротаске что и присвоение значения. Это важно: не нужно ждать следующего тика или nextTick() чтобы данные оказались в хранилище.

При первом вызове с одним и тем же ключом создаётся экземпляр и кешируется. Если два разных компонента вызывают useStorage('theme', ...) — они получают одну и ту же реактивную ссылку. Изменение в одном компоненте немедленно отражается в другом без каких-либо дополнительных подписок. При уничтожении последнего компонента, который использует ключ, экземпляр убирается из кеша.

ts Copy
import { useStorage } from 'vue-storage-kit'

const { value: theme } = useStorage('theme', { defaultValue: 'light' })

theme.value = 'dark'  // localStorage.setItem('theme', ...) прямо здесь, синхронно

Полный набор опций — в одном объекте можно объединить сразу несколько фич:

ts Copy
const { value, isReady, error, expiry, remove, refresh } = useStorage<UserProfile>('profile', {
  defaultValue: { name: '', role: 'guest' },
  target: 'local',           // 'local' | 'session' | 'memory'
  ttl: 30 * 60 * 1000,       // время жизни — 30 минут
  version: 3,                 // версия схемы данных
  migrations: [...],          // цепочка up/down миграций
  encrypt: { password: 'secret' },
  sync: true,                 // BroadcastChannel синхронизация вкладок
  onError: (err) => console.error(err),
  onExpire: (key) => router.push('/login'),
  onMigrate: (from, to) => console.log(`Migrated ${from}${to}`),
})

Возвращаемые значения:

  • value — реактивный Ref<T>, двусторонняя привязка к хранилищу
  • isReadyfalse пока идёт асинхронная инициализация. Актуально для IndexedDB и зашифрованных значений: первое чтение асинхронное, поэтому рендерить данные до isReady === true бессмысленно
  • error — последняя ошибка: quota-exceeded, parse-error, migration-failed, crypto-error
  • expiryComputedRef<Date | null>, когда истекает TTL
  • remove() — удаляет ключ и сбрасывает value к defaultValue
  • refresh() — принудительно перечитывает из хранилища, если внешний процесс мог записать туда что-то

Типичная связка с isReady в шаблоне:

vue Copy
<template>
  <SkeletonCard v-if="!isReady" />
  <ProfileCard v-else :profile="value" />
</template>

Без isReady — компонент отрендерится с defaultValue пока данные читаются, и потом резко перескочит. Это заметно на медленных устройствах или при большом объёме данных в IDB.

Для localStorage и sessionStorage есть сокращённые варианты с сигнатурой, совместимой с @vueuse/core. Если в проекте уже используется vueuse — достаточно поменять импорт:

ts Copy
import { useLocalStorage, useSessionStorage } from 'vue-storage-kit'

const { value: settings } = useLocalStorage('settings', { theme: 'light', lang: 'en' })
const { value: draft }    = useSessionStorage('draft', '')

По умолчанию поддерживается сериализация Date, Map, Set, undefined и BigInt — они кодируются в специальные JSON-обёртки и корректно восстанавливаются при чтении. Если нужен свой формат — можно передать кастомный Serializer<T>:

ts Copy
import type { Serializer } from 'vue-storage-kit'

// Пример: кастомный сериализатор на основе msgpack
const msgpackSerializer: Serializer<unknown> = {
  serialize:   (v) => Buffer.from(encode(v)).toString('base64'),
  deserialize: (s) => decode(Buffer.from(s, 'base64')),
}

const { value } = useStorage('binary-data', {
  defaultValue: null,
  serializer: msgpackSerializer,
})

Схема-миграции — версионирование данных без боли

Это одна из фич, которой нет в @vueuse/core и которой мне больше всего не хватало в реальных проектах.

Ситуация: год назад вы сохранили в localStorage объект { darkMode: true }. Потом дизайн вырос: появились темы 'light' | 'dark' | 'system', локализация, настройки уведомлений. Структура данных стала принципиально другой. Что делать со старыми данными у пользователей? Варианта три: молча сбросить к дефолту (потеря данных), написать кучу if-else с проверками версий в коде чтения (технический долг), или использовать нормальные миграции.

SchemaManager строит цепочку миграций от текущей версии в хранилище до целевой версии в коде. Каждая миграция — это объект с version (до какой версии она поднимает данные), функцией up (обновление) и опциональной down (откат). При несовпадении версий цепочка запускается автоматически, результат пишется обратно в хранилище.

ts Copy
interface SettingsV3 {
  theme: 'light' | 'dark' | 'system'
  locale: string
}

const { value: settings } = useStorage<SettingsV3>('settings', {
  defaultValue: { theme: 'system', locale: 'en' },
  version: 3,
  migrations: [
    {
      version: 2,
      // v1 хранил { darkMode: boolean } — конвертируем в строковую тему
      up:   (d: any) => ({ ...d, theme: d.darkMode ? 'dark' : 'light' }),
      down: (d: any) => { const { theme, ...rest } = d; return { ...rest, darkMode: theme === 'dark' } },
    },
    {
      version: 3,
      // v2 не имел локали — берём из старого поля lang или ставим дефолт
      up:   (d: any) => ({ ...d, locale: d.lang ?? 'en' }),
      down: (d: any) => { const { locale, ...rest } = d; return { ...rest, lang: locale } },
    },
  ],
  onMigrate: (from, to) => console.log(`Migrated settings ${from}${to}`),
})

Пользователь с данными v1 открывает приложение: читается { darkMode: true, v: 1 }, запускаются миграции v1→v2→v3, на выходе { theme: 'dark', locale: 'en' }. Мигрированное значение тут же пишется обратно в storage с новой версией — при следующем открытии миграции уже не запустятся.

Важный нюанс: миграции должны быть идемпотентными — повторный запуск up не должен ломать данные. Если поле уже есть — не трогаем его, не перезаписываем. Downgrade работает зеркально: если переключились на старую версию кода и down для какой-то миграции нет — ключ сбрасывается к defaultValue и вызывается onError с типом 'migration-failed'.


TTL — данные с автоматическим сроком жизни

Стандартный localStorage не умеет хранить данные с временем жизни. Обычно это решают таймерами или проверкой на старте приложения. Оба подхода неудобны: таймеры нужно отменять, проверки на старте забываются.

Здесь TTL хранится прямо внутри envelope — служебной обёртки { v, d, exp, ts } которую useStorage кладёт в хранилище вместо сырых данных. При каждом чтении: если Date.now() > exp, ключ немедленно удаляется, возвращается defaultValue, вызывается onExpire. Никаких фоновых процессов — всё ленивое.

ts Copy
const { value: otp, expiry, remove } = useStorage('otp-code', {
  defaultValue: '',
  ttl: 5 * 60 * 1000,   // 5 минут
  onExpire: () => {
    toast.warn('Код истёк, запросите новый')
    router.push('/request-otp')
  },
})

// Реактивный обратный отсчёт — expiry обновляется при каждом новом OTP
const timeLeft = computed(() =>
  expiry.value ? Math.max(0, expiry.value.getTime() - Date.now()) : null
)

// Форматируем для отображения: "4:32"
const timeLeftFormatted = computed(() => {
  if (!timeLeft.value) return null
  const sec = Math.floor(timeLeft.value / 1000)
  return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`
})

Если пользователь не открывал страницу долго — ключи просто остаются в хранилище мёртвым грузом. Для очистки при старте приложения есть TTLManager.cleanExpired:

ts Copy
import { TTLManager, StorageAdapterFactory } from 'vue-storage-kit'

// Запускаем при старте — удаляет все ключи с префиксом 'myapp:' у которых exp прошёл
const adapter = StorageAdapterFactory.get('local')
TTLManager.cleanExpired(adapter, 'myapp:')

// Посмотреть когда истекает конкретный ключ (без чтения данных)
const exp = TTLManager.getExpiry(adapter, 'myapp:otp-code')
if (exp) console.log(`OTP expires at ${exp.toLocaleTimeString()}`)

Это удобно комбинировать с Vue plugin — можно вызвать cleanExpired один раз в main.ts до монтирования приложения, и в хранилище не будет накапливаться мусор.


AES-GCM шифрование — Web Crypto, без зависимостей

Хранить токены, ключи API или персональные данные в открытом localStorage — плохая идея. Любой скрипт на странице (включая сторонние аналитические скрипты) может прочитать их через localStorage.getItem. Шифрование не даёт 100% защиты, но серьёзно повышает планку.

Реализация использует только нативный crypto.subtle — никаких crypto-js, sjcl или других библиотек. Схема: ключ выводится из пароля через PBKDF2 (по умолчанию 100 000 итераций), затем данные шифруются AES-GCM с уникальным IV. В хранилище записывается base64(salt[16] + iv[12] + ciphertext). Производные ключи кешируются в памяти по паре (password, iterations, salt) — PBKDF2 запускается только при первом обращении с новым salt.

ts Copy
const { value: apiKey } = useStorage('api-key', {
  defaultValue: '',
  encrypt: { password: 'user-passphrase', iterations: 100_000 },
})

apiKey.value = 'sk-abc123'
// В localStorage записано что-то вроде:
// "kL9mN2pQr8...очень длинная base64-строка..."
// Прочитать без пароля — невозможно

Количество итераций PBKDF2 — компромисс между безопасностью и производительностью. 100 000 на современном железе занимает ~100ms. Если пароль используется часто — ключ закешируется и дальнейшие операции будут быстрыми. Если нужен баланс другой — можно уменьшить, но ниже 10 000 не рекомендуется.

Альтернатива PBKDF2 — передать уже готовый CryptoKey. Это подходит, если ключ генерируется один раз при логине и хранится в памяти сессии:

ts Copy
// Генерируем ключ один раз при входе пользователя
const sessionKey = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 },
  false,    // не извлекаемый — нельзя утащить через JS
  ['encrypt', 'decrypt'],
)

const { value: sensitiveData } = useStorage('vault', {
  defaultValue: {},
  encrypt: { key: sessionKey },
})

Шифрование вынесено в отдельный entry point — в основной бандл не попадает. Загружается динамически только когда в опциях useStorage указан encrypt:

ts Copy
import { encrypt, decrypt } from 'vue-storage-kit/crypto'

// Можно использовать и вне useStorage — для произвольных данных
const ciphertext = await encrypt('sensitive data', { password: 'pass' })
const plaintext  = await decrypt(ciphertext, { password: 'pass' })

Синхронизация вкладок — BroadcastChannel из коробки

Классическая проблема: пользователь открыл приложение в двух вкладках. Меняет настройки в одной — вторая не знает об этом. Стандартный window.addEventListener('storage', ...) ловит изменения, но только сделанные в другом контексте. Внутри той же вкладки — нет. И это только localStorage; sessionStorage не синхронизируется между вкладками совсем.

BroadcastChannel — более правильный инструмент для этого. Он позволяет отправлять сообщения между всеми вкладками, воркерами и service worker одного origin. useStorage с опцией sync: true использует его для рассылки обновлений при каждой записи. Получатели применяют новое значение к своему ref без записи обратно в хранилище (чтобы не зациклиться). Если BroadcastChannel недоступен — автоматически падает на storage event.

ts Copy
const { value: cart } = useStorage('cart', {
  defaultValue: [] as CartItem[],
  sync: true,
})

// Обе вкладки показывают одинаковую корзину.
// Изменение в одной вкладке через BroadcastChannel появляется в другой мгновенно.

Конфликты разрешаются по принципу last-write-wins с помощью timestamp из envelope (ts поле). Каждая запись включает ts: Date.now(). При получении апдейта сравниваем его timestamp с локальным — применяем только если входящий новее. Это защищает от «прыжков назад» при быстрых одновременных изменениях в нескольких вкладках.

Для более строгой гарантии есть leader election — одна вкладка становится «лидером» через navigator.locks и при конфликте её версия приоритетна:

ts Copy
const { value: sharedState } = useStorage('shared-state', {
  defaultValue: { count: 0 },
  sync: {
    channel: 'app-sync',
    leader: true,    // navigator.locks: одна вкладка держит named lock
    debounce: 100,   // объединяем частые изменения, не рассылаем каждый tick
  },
})

Когда вкладка-лидер закрывается — другая вкладка автоматически получает лок. Это работает через navigator.locks.request() с колбеком, который держит промис живым пока вкладка открыта.

Помимо синхронизации хранилища, есть отдельный useBroadcastChannel — для произвольного обмена сообщениями между вкладками без привязки к storage:

ts Copy
import { useBroadcastChannel } from 'vue-storage-kit'

interface Notification { type: 'info' | 'warn'; message: string; ts: number }

const { isSupported, post, messages, lastMessage, close } =
  useBroadcastChannel<Notification>('app-notifications')

// Отправить уведомление во все вкладки
function broadcastAlert(message: string) {
  if (isSupported) {
    post({ type: 'warn', message, ts: Date.now() })
  }
}

// Реагировать на входящие
watch(lastMessage, (notification) => {
  if (notification) showToast(notification.message)
})

// messages — Ref<Notification[]>, история всех полученных сообщений

isSupported позволяет безопасно использовать composable даже если BroadcastChannel не реализован (некоторые WebView, старые браузеры). При isSupported === falsepost() молча ничего не делает, messages остаётся пустым.


IndexedDB — для данных, которые не влезают в localStorage

localStorage имеет лимит около 5–10 МБ на origin. Этого хватает для настроек, токенов, небольших кешей — но не для больших документов, бинарных данных, истории операций или офлайн-кеша. IndexedDB — правильный инструмент для хранения нескольких мегабайт и более.

Нативный IDB API основан на событиях и довольно многословен. useIndexedDB оборачивает его в promise-based интерфейс и лениво открывает базу при первом обращении:

ts Copy
import { useIndexedDB } from 'vue-storage-kit'

interface FileRecord { id: string; name: string; size: number; content: ArrayBuffer }

// Параметры: имя базы, имя стора, обработчик ошибок (опционально), опции
const idb = useIndexedDB<FileRecord>('files-db', 'records', (err) => {
  console.error('IDB error:', err)
})

// Стандартный CRUD
await idb.set('doc-1', { id: 'doc-1', name: 'report.pdf', size: 204800, content: buffer })
const record = await idb.get('doc-1')       // FileRecord | null
const allIds = await idb.keys()             // IDBValidKey[]
const all    = await idb.getAll()           // FileRecord[]
console.log(await idb.count())              // 1
await idb.delete('doc-1')
await idb.clear()                           // удалить всё в сторе

// Сырая транзакция для нестандартных операций
await idb.transaction((store) => store.put({ id: 'doc-2', ... }, 'doc-2'))

Для реактивного доступа к одному ключу — useIDBRef. Это аналог useStorage, но бэкенд — IndexedDB. Особенно удобно для редакторов, где данные большие и обновляются часто:

ts Copy
import { useIDBRef } from 'vue-storage-kit'

// Реактивный черновик поста
const { value: draft, isReady, error } = useIDBRef('editor-db', 'drafts', 'post-42', '')

// isReady === false пока идёт первое асинхронное чтение из IDB
// Как только isReady === true — draft.value содержит сохранённый черновик

draft.value = 'Начало поста...'  // пишет в IDB через watch автоматически

Вторичные индексы позволяют делать быстрый поиск по полям объекта — без перебора всего стора:

ts Copy
interface Contact {
  id: number
  email: string
  department: string
  lastName: string
}

const idb = useIndexedDB<Contact>('crm-db', 'contacts', undefined, {
  version: 2,  // увеличиваем версию базы при добавлении новых индексов
  indexes: [
    { name: 'by-email',      keyPath: 'email',      unique: true  },
    { name: 'by-department', keyPath: 'department', unique: false },
    { name: 'by-last-name',  keyPath: 'lastName',   unique: false },
  ],
})

await idb.set(1, { id: 1, email: 'alice@co.com', department: 'engineering', lastName: 'Smith' })
await idb.set(2, { id: 2, email: 'bob@co.com',   department: 'engineering', lastName: 'Jones' })
await idb.set(3, { id: 3, email: 'carol@co.com', department: 'design',      lastName: 'White' })

// Поиск по индексу — быстро, даже на тысячах записей
const alice = await idb.getByIndex('by-email', 'alice@co.com')             // одна запись
const team  = await idb.getAllByIndex('by-department', 'engineering')       // [alice, bob]

Важный нюанс с version: если добавляете новые индексы в уже существующую базу, нужно увеличить номер версии — иначе onupgradeneeded не запустится и индексы не создадутся.


useCookie — реактивные cookies с JSON-сериализацией

Работа с cookies через document.cookie — одно из самых неудобных API в браузере. Чтобы установить cookie нужно собрать правильно форматированную строку. Чтобы прочитать — распарсить всю строку document.cookie. Чтобы удалить — установить с истёкшей датой.

useCookie абстрагирует всё это в реактивный Ref. Присвоение — строит Set-Cookie строку с нужными флагами и пишет в document.cookie. Первоначальное значение — читается из document.cookie при инициализации. Сериализация поддерживает Date, Map, Set, BigInt — те же возможности что и в useStorage.

ts Copy
import { useCookie } from 'vue-storage-kit'

// Session cookie — удаляется когда закрывается браузер
const consent = useCookie('gdpr-consent', { defaultValue: false })
consent.value = true
// document.cookie теперь: "gdpr-consent=true; path=/"

// Persistent cookie — явно задаём срок
const locale = useCookie('locale', {
  defaultValue: 'ru',
  expires: 30,        // 30 дней, или можно передать Date
  sameSite: 'lax',    // безопасно для большинства случаев
})

// Cookie с полным объектом — JSON-сериализация прозрачна
interface UserPrefs { theme: string; lang: string; lastLogin: Date }
const prefs = useCookie<UserPrefs>('user-prefs', {
  defaultValue: { theme: 'dark', lang: 'ru', lastLogin: new Date() },
  secure: true,
  sameSite: 'strict',
  expires: 365,
})

prefs.value = { ...prefs.value, theme: 'light' }
// В cookie: URL-encoded JSON с корректной сериализацией Date

В Nuxt-приложениях одна и та же строка работает и на сервере, и на клиенте. На сервере composable читает из event.node.req.headers.cookie, на клиенте — из document.cookie. Флаг httpOnly понятен только серверу — на сервере передаётся в H3 setCookie, в браузере игнорируется (как и должно быть):

vue Copy
<script setup lang="ts">
// Этот код работает одинаково при SSR и CSR
const authToken = useCookie('auth-token', {
  defaultValue: '',
  secure: true,
  httpOnly: true,      // на сервере уходит в Set-Cookie заголовок
  sameSite: 'strict',
  expires: 7,          // 7 дней
})
</script>

useStorageList — CRUD-коллекция поверх localStorage

Часто нужно хранить не просто значение, а список элементов — задачи, закладки, история поиска, сохранённые фильтры. Можно сделать через useStorage<T[]> и вручную мутировать массив. useStorageList — сахар поверх этого: добавляет типизированные методы для работы с коллекцией и управляет идентификацией элементов.

Коллекция хранится как JSON-массив в одном ключе. Все методы реактивны — изменения немедленно отражаются в шаблоне и персистируются в storage.

ts Copy
import { useStorageList } from 'vue-storage-kit'

interface Task {
  id: string
  title: string
  done: boolean
  priority: 'low' | 'medium' | 'high'
  createdAt: Date
}

const { items, add, update, remove, find, findAll, set, clear } = useStorageList<Task>('tasks', {
  keyField: 'id',   // по какому полю идентифицировать элементы (default: 'id')
})

// Добавить
add({
  id: crypto.randomUUID(),
  title: 'Написать тесты',
  done: false,
  priority: 'high',
  createdAt: new Date(),
})

// Обновить по id (патч — только изменённые поля)
update('some-uuid', { done: true })

// Удалить по id
remove('another-uuid')

// Найти один элемент — возвращает ComputedRef, реагирует на изменения
const task = find('some-uuid')      // ComputedRef<Task | undefined>

// Найти несколько — тоже ComputedRef
const urgent = findAll((t) => t.priority === 'high' && !t.done)

// Полностью заменить коллекцию
set(importedTasks)

// Очистить всё (удаляет ключ из storage)
clear()

Поле keyField — это просто имя свойства для идентификации. По умолчанию 'id', но можно использовать любое поле типа:

ts Copy
interface Bookmark { slug: string; url: string; title: string }

const { items: bookmarks, add, remove } = useStorageList<Bookmark>('bookmarks', {
  keyField: 'slug',   // идентифицируем по slug, не по id
})

add({ slug: 'vue-docs', url: 'https://vuejs.org', title: 'Vue.js Docs' })
remove('vue-docs')  // удаляет запись где slug === 'vue-docs'

Pinia-персистентность — один плагин на всё приложение

Существуют отдельные пакеты для персистентности Pinia-сторов (pinia-plugin-persistedstate и т.д.). vue-storage-kit предоставляет свой плагин в рамках единой экосистемы — без дополнительных зависимостей, с той же конфигурацией адаптеров.

Плагин работает через $subscribe — Pinia-хук который срабатывает при любом изменении состояния стора. При изменении — весь стейт (или выбранные поля) сериализуется и пишется в storage. При инициализации стора — читается и восстанавливается через $patch.

bash Copy
npm install pinia
ts Copy
// main.ts
import { createPinia } from 'pinia'
import { createPiniaPersist } from 'vue-storage-kit/pinia'

const pinia = createPinia()

// Один вызов — персистирует ВСЕ сторы приложения
pinia.use(createPiniaPersist({ target: 'local' }))

createApp(App).use(pinia).mount('#app')

Никакой дополнительной разметки в самом сторе — любой существующий стор начинает персистироваться автоматически:

ts Copy
const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, username: 'User', tempFlag: false }),
  actions: {
    inc()   { this.count++ },
    dec()   { this.count-- },
    reset() { this.count = 0 },
  },
})

// Перезагружаем страницу — count и username восстанавливаются
// Ключ в localStorage: "counter" → { count: 5, username: 'Alice', tempFlag: false }

Опции для тонкой настройки:

ts Copy
pinia.use(createPiniaPersist({
  target: 'local',

  // Кастомное имя ключа (по умолчанию — storeId)
  key: (storeId) => `persist:v2:${storeId}`,

  // Сохранять только нужные поля (whitelist)
  pick: ['count', 'username'],

  // Или исключить ненужные (blacklist)
  // omit: ['tempFlag', 'internalState'],

  // Колбеки жизненного цикла восстановления
  beforeRestore: (ctx) => {
    console.log(`Restoring store "${ctx.store.$id}"`)
  },
  afterRestore: (ctx) => {
    // Например, помечаем стор как гидрированный
    if ('hydrated' in ctx.store) ctx.store.$patch({ hydrated: true })
  },
}))

pick и omit полезны когда в сторе есть вычисляемые или временные поля, которые не должны персистироваться. Например, флаги загрузки (isLoading), ошибки (lastError), состояние UI-элементов.


Сжатие — Compression Streams API, ноль зависимостей

localStorage имеет жёсткий лимит на объём (~5–10 МБ). Если данные большие — gzip-сжатие может существенно уменьшить занимаемое место. Compression Streams API — нативный браузерный API, появился в Chrome 80+, Firefox 113+, Safari 16.4+. Никаких полифиллов, никаких зависимостей.

Сжатые данные хранятся с magic-префиксом vsk:algorithm: — это позволяет автоматически определить алгоритм при распаковке и не перепутать сжатые данные с обычными строками:

ts Copy
import { compress, decompress, isCompressed } from 'vue-storage-kit/compress'

const original   = JSON.stringify(largeCatalog)   // ~800 КБ JSON
const compressed = await compress(original, { algorithm: 'gzip' })
// "vsk:gzip:H4sIAAAAAAAA..."  — magic prefix + base64

// Степень сжатия зависит от данных
// JSON с повторяющимися ключами сжимается на 60–80%
// Случайные строки — практически не сжимаются

isCompressed(compressed)                   // true
isCompressed('обычная строка')             // false

const restored = await decompress(compressed)
// decompress сам читает алгоритм из префикса, параметры не нужны

Поддерживаемые алгоритмы: 'gzip' (рекомендуется), 'deflate', 'deflate-raw'. На практике разница в степени сжатия минимальная — gzip универсальный выбор.

CompressAdapter оборачивает любой StorageAdapter и добавляет прозрачное сжатие. Это позволяет легко добавить сжатие к существующему коду без изменения логики:

ts Copy
import { CompressAdapter } from 'vue-storage-kit/compress'
import { LocalStorageAdapter } from 'vue-storage-kit'

// Оборачиваем localStorage-адаптер в сжимающую обёртку
const base    = new LocalStorageAdapter()
const adapter = new CompressAdapter(base, { algorithm: 'gzip' })

// Все данные автоматически сжимаются при записи и распаковываются при чтении
await adapter.setCompressed('catalog', JSON.stringify(bigCatalog))
const catalog = JSON.parse(await adapter.getDecompressed('catalog') ?? '{}')

// Можно посмотреть что лежит в сыром виде
const rawValue = base.getItem('catalog')  // "vsk:gzip:H4sI..." — сжатые данные

Сжатие особенно эффективно для данных с высокой энтропийностью структуры — JSON с повторяющимися ключами, текстовые документы, списки похожих объектов. Для небольших значений (< 100 байт) или уже сжатых данных (JPEG, zip) смысла нет.


Утилиты для управления хранилищем

Несколько вспомогательных функций для задач, которые регулярно нужны в production-приложениях.

Мониторинг квоты — покажи пользователю предупреждение до того как он упрётся в лимит:

ts Copy
import { getStorageQuota } from 'vue-storage-kit'

const { used, total, percentage } = await getStorageQuota()
// { used: 1048576, total: 5242880, percentage: 20 }
// used и total — в байтах

if (percentage > 80) {
  showWarning(`Хранилище заполнено на ${percentage}%. Рекомендуем очистить кеш.`)
}

Экспорт / импорт — перенос данных между устройствами или бэкап настроек пользователя:

ts Copy
import { exportStorage, importStorage } from 'vue-storage-kit'

// Снапшот всех ключей с префиксом 'myapp:' из localStorage
// Возвращает простой объект { ключ: сериализованное_значение }
const snapshot = exportStorage('myapp:', 'local')
const json = JSON.stringify(snapshot, null, 2)

// Сохранить в файл
const blob = new Blob([json], { type: 'application/json' })
const url  = URL.createObjectURL(blob)
// <a :href="url" download="my-settings-backup.json">Скачать настройки</a>

// Восстановить из файла
async function restore(file: File) {
  const text     = await file.text()
  const snapshot = JSON.parse(text)
  importStorage(snapshot, 'local')   // пишет все ключи обратно в localStorage
  location.reload()                   // перезагружаем чтобы composables подхватили новые данные
}

Очистка по префиксу — сброс данных конкретного модуля без затрагивания остальных:

ts Copy
import { clearStorage } from 'vue-storage-kit'

// Удалить только данные приложения, не трогая ключи сторонних скриптов
clearStorage('myapp:', 'local')

// Или очистить конкретный модуль
clearStorage('myapp:cache:', 'local')   // только кеш
clearStorage('myapp:prefs:', 'local')   // только настройки

Vue plugin — глобальный префикс и обработчик ошибок

Без плагина каждый вызов useStorage нужно конфигурировать отдельно — указывать префикс, передавать onError. Это быстро превращается в копипасту. Плагин позволяет задать глобальные дефолты один раз.

ts Copy
import { createApp } from 'vue'
import { VueStoragePlugin } from 'vue-storage-kit'

const app = createApp(App)

app.use(VueStoragePlugin, {
  // Все ключи автоматически получают этот префикс.
  // useStorage('profile', ...) реально пишет в 'myapp:profile'
  prefix: 'myapp:',

  defaultTarget: 'local',

  // Глобальное шифрование — все ключи будут шифроваться если не переопределено
  defaultEncrypt: { password: import.meta.env.VITE_STORAGE_SECRET },

  // Один обработчик ошибок на всё приложение
  onError: (err) => {
    if (err.type === 'quota-exceeded') {
      toast.error('Браузерное хранилище заполнено. Зайдите в настройки и очистите кеш.')
      analytics.track('storage_quota_exceeded', { key: err.key })
    }
    if (err.type === 'parse-error') {
      // Повреждённые данные — логируем, но не падаем
      console.warn(`[storage] Parse error for key "${err.key}", resetting to default`)
    }
  },
})

Настройки плагина можно переопределить на уровне конкретного вызова — они имеют более высокий приоритет. Например, если глобально включено шифрование, но один ключ должен храниться открыто: useStorage('public-key', { encrypt: false, ... }).


Nuxt module — авто-импорты и SSR

В Nuxt-проектах добавляется поддержка серверного рендеринга: useCookie читает cookies из заголовков входящего запроса, а не из document.cookie. Модуль подключает плагин автоматически и настраивает авто-импорты.

ts Copy
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['vue-storage-kit/nuxt'],

  storageKit: {
    prefix:      'myapp_',
    autoImports: true,    // по умолчанию true
  },
})

После подключения useStorage, useLocalStorage, useSessionStorage, useIndexedDB, useIDBRef, useCookie доступны в любом компоненте без явного импорта — Nuxt добавляет их через авто-импорты. Префикс из конфига подставляется через runtimeConfig, так что его можно переопределить через переменные окружения в разных средах (dev / staging / prod).

При SSR поведение такое: первый рендер на сервере использует MemoryStorageAdapter (данные не персистируются, это нормально — сервер stateless). После гидрации на клиенте composables перечитывают реальные данные из браузерного хранилища. isReady переходит в true после этого перечитывания — компоненты могут показать скелетон на время гидрации.


Сценарии в реальных проектах

Корзина в интернет-магазине — TTL + синхронизация вкладок

Пользователь добавляет товары в корзину. Требования: корзина живёт 24 часа и автоматически очищается (не нужно хранить вечно), должна синхронизироваться между вкладками в реальном времени (открыл каталог в одной вкладке, оформляешь заказ в другой — корзина одинакова), содержимое должно переживать перезагрузку страницы.

Раньше это требовало отдельного Pinia-стора с watch на изменения и ручной синхронизацией через storage-события. Теперь — один useStorage с нужными опциями:

ts Copy
interface CartItem { id: string; sku: string; title: string; qty: number; price: number }

const { value: cart, expiry } = useStorage<CartItem[]>('cart', {
  defaultValue: [],
  ttl: 24 * 60 * 60 * 1000,  // 24 часа — потом корзина автоматически очищается
  sync: true,                  // мгновенная синхронизация между всеми открытыми вкладками
  onExpire: () => {
    // Пользователь пришёл через сутки — предупреждаем
    showModal({
      title: 'Корзина устарела',
      message: 'Товары в вашей корзине больше не зарезервированы. Проверьте наличие.',
    })
  },
})

function addToCart(product: Product) {
  const existing = cart.value.find((i) => i.sku === product.sku)
  if (existing) {
    // Увеличиваем количество если уже в корзине
    cart.value = cart.value.map((i) =>
      i.sku === product.sku ? { ...i, qty: i.qty + 1 } : i
    )
  } else {
    cart.value = [
      ...cart.value,
      { id: crypto.randomUUID(), sku: product.sku, title: product.title, qty: 1, price: product.price },
    ]
  }
}

function updateQty(sku: string, qty: number) {
  if (qty <= 0) {
    removeFromCart(sku)
    return
  }
  cart.value = cart.value.map((i) => i.sku === sku ? { ...i, qty } : i)
}

function removeFromCart(sku: string) {
  cart.value = cart.value.filter((i) => i.sku !== sku)
}

const itemCount = computed(() => cart.value.reduce((n, i) => n + i.qty, 0))
const total     = computed(() => cart.value.reduce((sum, i) => sum + i.price * i.qty, 0))

В шаблоне показываем когда истекает корзина:

vue Copy
<template>
  <div class="cart">
    <p v-if="expiry" class="cart__expires">
      Корзина действует до {{ expiry.toLocaleString('ru', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) }}
    </p>

    <p v-if="cart.length === 0" class="cart__empty">Корзина пуста</p>

    <CartItemRow
      v-for="item in cart"
      :key="item.sku"
      :item="item"
      @change-qty="updateQty(item.sku, $event)"
      @remove="removeFromCart(item.sku)"
    />

    <div class="cart__footer">
      <span>{{ itemCount }} товара на сумму</span>
      <strong>{{ total.toLocaleString('ru') }} ₽</strong>
    </div>
  </div>
</template>

Открываешь вторую вкладку с каталогом — добавляешь товар там. В первой вкладке с корзиной изменение появляется мгновенно через BroadcastChannel. Закрываешь все вкладки, открываешь через 23 часа — корзина на месте. Открываешь через 25 часов — корзина очищена, пользователь видит уведомление.


Редактор с автосохранением — IndexedDB + шифрование

Пользователь пишет приватные заметки. Требования: заметки могут быть большими (несколько килобайт текста, вложения), должны шифроваться паролём который пользователь вводит при входе и который нигде не хранится. Список заметок (заголовки, даты) — быстрый и реактивный.

Архитектура: заголовки и метаданные — в useStorageList в localStorage (быстрый доступ, реактивность), полный текст — в IndexedDB (без лимита на объём), шифрование — отдельным слоем поверх.

ts Copy
interface NoteMeta { id: string; title: string; updatedAt: Date; size: number }

// Индекс заметок — быстрый реактивный список в localStorage
const { items: notes, add: addMeta, update: updateMeta, remove: removeMeta, find } =
  useStorageList<NoteMeta>('notes-index', { keyField: 'id' })

// IDB для контента — без лимита объёма
const idb = useIndexedDB<string>('notes-db', 'content')

// Пароль пользователя — в памяти сессии, не хранится в storage
const sessionPassword = ref('')   // вводится в диалоге при открытии приложения
const isUnlocked      = computed(() => sessionPassword.value.length > 0)

// Активная заметка
const activeNoteId = ref<string | null>(null)
const activeContent = ref('')
const isSaving      = ref(false)

async function openNote(id: string) {
  activeNoteId.value = id
  activeContent.value = ''

  const raw = await idb.get(id)
  if (!raw) return

  // Расшифровываем — если пароль неверный, decrypt выбросит ошибку
  try {
    const { decrypt } = await import('vue-storage-kit/crypto')
    activeContent.value = await decrypt(raw, { password: sessionPassword.value })
  } catch {
    toast.error('Неверный пароль')
    sessionPassword.value = ''
  }
}

async function saveNote() {
  if (!activeNoteId.value || !isUnlocked.value) return
  isSaving.value = true

  try {
    const { encrypt } = await import('vue-storage-kit/crypto')
    const encrypted = await encrypt(activeContent.value, { password: sessionPassword.value })

    await idb.set(activeNoteId.value, encrypted)

    // Обновляем метаданные в индексе
    updateMeta(activeNoteId.value, {
      updatedAt: new Date(),
      size: activeContent.value.length,
    })
  } finally {
    isSaving.value = false
  }
}

async function createNote(title: string) {
  const id = crypto.randomUUID()
  addMeta({ id, title, updatedAt: new Date(), size: 0 })
  await openNote(id)
}

async function deleteNote(id: string) {
  removeMeta(id)
  await idb.delete(id)
  if (activeNoteId.value === id) {
    activeNoteId.value = null
    activeContent.value = ''
  }
}

// Автосохранение с debounce
const debouncedSave = useDebounceFn(saveNote, 1000)
watch(activeContent, debouncedSave)

Список заметок в левой панели реагирует мгновенно — это Ref<NoteMeta[]> из useStorageList. Открытие заметки — асинхронная загрузка из IDB с расшифровкой. Редактирование — автосохранение через секунду после последнего изменения.


SaaS-дашборд — настройки пользователя с миграциями

Продукт живёт годами, команда активно добавляет фичи. Каждые несколько месяцев структура настроек меняется: появляются новые поля, переименовываются старые, меняется тип значений. Нужно обновлять данные существующих пользователей без потерь и без ручного вмешательства.

Сценарий трёх релизов:

  • v1 (год назад): { darkMode: boolean }
  • v2 (полгода назад): { theme: 'light' | 'dark' | 'system', sidebarCollapsed: boolean }
  • v3 (три месяца назад): { ..., locale: string, pinnedWidgets: string[] }
  • v4 (сейчас): { ..., dataRefreshInterval: number } — раньше было refreshInterval в секундах, теперь в миллисекундах
ts Copy
interface DashboardSettings {
  theme: 'light' | 'dark' | 'system'
  locale: string
  sidebarCollapsed: boolean
  pinnedWidgets: string[]
  dataRefreshInterval: number   // миллисекунды
}

const { value: settings } = useStorage<DashboardSettings>('dashboard-settings', {
  defaultValue: {
    theme: 'system',
    locale: navigator.language.slice(0, 2) || 'en',
    sidebarCollapsed: false,
    pinnedWidgets: [],
    dataRefreshInterval: 30_000,
  },
  version: 4,
  migrations: [
    {
      version: 2,
      // v1 → v2: boolean-тема заменена на строку, добавлен sidebarCollapsed
      up: (d: any) => ({
        ...d,
        theme: d.darkMode ? 'dark' : 'light',
        sidebarCollapsed: false,
      }),
      down: (d: any) => {
        const { theme, sidebarCollapsed, ...rest } = d
        return { ...rest, darkMode: theme === 'dark' }
      },
    },
    {
      version: 3,
      // v2 → v3: добавили locale и pinnedWidgets, locale берём из старого поля lang
      up: (d: any) => ({
        ...d,
        locale: d.lang ?? navigator.language.slice(0, 2) ?? 'en',
        pinnedWidgets: d.pinnedWidgets ?? [],
      }),
      down: (d: any) => {
        const { locale, pinnedWidgets, ...rest } = d
        return { ...rest, lang: locale }
      },
    },
    {
      version: 4,
      // v3 → v4: refreshInterval (секунды) → dataRefreshInterval (миллисекунды)
      up: (d: any) => ({
        ...d,
        dataRefreshInterval: (d.refreshInterval ?? 30) * 1000,
      }),
      down: (d: any) => {
        const { dataRefreshInterval, ...rest } = d
        return { ...rest, refreshInterval: Math.round(dataRefreshInterval / 1000) }
      },
    },
  ],

  // Синхронизируем между вкладками — изменил тему в одной вкладке,
  // в остальных тема тоже переключилась
  sync: true,

  onMigrate: (from, to) => {
    // Отправляем в аналитику — можно отслеживать сколько пользователей ещё на старых версиях
    analytics.track('settings_migrated', { from, to, userId: currentUser.id })
  },

  onError: (err) => {
    if (err.type === 'migration-failed') {
      // Сброс к дефолту — лучше потерять настройки, чем сломать приложение
      console.warn(`[settings] Migration failed (${err.from}${err.to}), resetting defaults`)
      analytics.track('settings_migration_failed', { from: err.from, to: err.to })
    }
  },
})

// Экспорт данных пользователя — GDPR compliance или перенос на другое устройство
function exportMyData() {
  const snapshot = exportStorage('myapp:', 'local')
  const payload = {
    exportedAt: new Date().toISOString(),
    version: 4,
    data: snapshot,
  }
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
  const url  = URL.createObjectURL(blob)
  const a    = document.createElement('a')
  a.href     = url
  a.download = `my-data-${new Date().toISOString().slice(0, 10)}.json`
  a.click()
  URL.revokeObjectURL(url)
}

// Импорт на новом устройстве
async function importMyData(file: File) {
  const payload = JSON.parse(await file.text())

  if (payload.version !== 4) {
    toast.error('Файл от неподдерживаемой версии приложения')
    return
  }

  importStorage(payload.data, 'local')
  toast.success('Данные восстановлены. Перезагружаем приложение...')
  setTimeout(() => location.reload(), 1500)
}

Пользователь с данными v1 открывает текущую версию: читается { darkMode: true, v: 1 }, цепочка запускает три миграции последовательно, на выходе { theme: 'dark', locale: 'ru', sidebarCollapsed: false, pinnedWidgets: [], dataRefreshInterval: 30000 }. Мигрированное значение тут же пишется обратно в storage. При следующем открытии версии совпадают — миграции не запускаются.


NPM: https://www.npmjs.com/package/vue-storage-kit
GitHub: https://github.com/macrulezru/vue-storage-kit

Читать далее

23.05.2026

Command Palette для Vue 3 — fuzzy-поиск, вложенные палитры и глобальные хоткеи из коробки

@macrulez/vue-command-palette — готовый Command+K интерфейс для Vue 3 с fuzzy-поиском, группами, вложенными палитрами, историей команд и полной поддержкой тем. Единственная peer-зависимость — Vue 3.

Метки
vuevue3command-palettetypescriptopen-source