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
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
import { useStorage } from 'vue-storage-kit'
const { value: theme } = useStorage('theme', { defaultValue: 'light' })
theme.value = 'dark' // localStorage.setItem('theme', ...) прямо здесь, синхронно
Полный набор опций — в одном объекте можно объединить сразу несколько фич:
ts
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>, двусторонняя привязка к хранилищуisReady—falseпока идёт асинхронная инициализация. Актуально для IndexedDB и зашифрованных значений: первое чтение асинхронное, поэтому рендерить данные доisReady === trueбессмысленноerror— последняя ошибка:quota-exceeded,parse-error,migration-failed,crypto-errorexpiry—ComputedRef<Date | null>, когда истекает TTLremove()— удаляет ключ и сбрасываетvalueкdefaultValuerefresh()— принудительно перечитывает из хранилища, если внешний процесс мог записать туда что-то
Типичная связка с isReady в шаблоне:
vue
<template>
<SkeletonCard v-if="!isReady" />
<ProfileCard v-else :profile="value" />
</template>
Без isReady — компонент отрендерится с defaultValue пока данные читаются, и потом резко перескочит. Это заметно на медленных устройствах или при большом объёме данных в IDB.
Для localStorage и sessionStorage есть сокращённые варианты с сигнатурой, совместимой с @vueuse/core. Если в проекте уже используется vueuse — достаточно поменять импорт:
ts
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
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
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
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
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
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
// Генерируем ключ один раз при входе пользователя
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
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
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
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
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 === false — post() молча ничего не делает, messages остаётся пустым.
IndexedDB — для данных, которые не влезают в localStorage
localStorage имеет лимит около 5–10 МБ на origin. Этого хватает для настроек, токенов, небольших кешей — но не для больших документов, бинарных данных, истории операций или офлайн-кеша. IndexedDB — правильный инструмент для хранения нескольких мегабайт и более.
Нативный IDB API основан на событиях и довольно многословен. useIndexedDB оборачивает его в promise-based интерфейс и лениво открывает базу при первом обращении:
ts
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
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
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
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
<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
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
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
npm install pinia
ts
// 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
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
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
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
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
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
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
import { clearStorage } from 'vue-storage-kit'
// Удалить только данные приложения, не трогая ключи сторонних скриптов
clearStorage('myapp:', 'local')
// Или очистить конкретный модуль
clearStorage('myapp:cache:', 'local') // только кеш
clearStorage('myapp:prefs:', 'local') // только настройки
Vue plugin — глобальный префикс и обработчик ошибок
Без плагина каждый вызов useStorage нужно конфигурировать отдельно — указывать префикс, передавать onError. Это быстро превращается в копипасту. Плагин позволяет задать глобальные дефолты один раз.
ts
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
// 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
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
<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
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
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