vue-storage-kit 0.2.0: React-поддержка и пять новых возможностей

В прошлый раз vue-storage-kit обзавёлся TTL, миграциями схем, шифрованием, синхронизацией вкладок и Pinia-плагином — но вся эта логика была плотно вшита в Vue: watch, ref, effectScope. Переиспользовать её в React-проекте было невозможно в принципе, не то что неудобно.
Последний релиз это меняет. Весь пайплайн useStorage — TTL, миграции, шифрование, сжатие, подпись, синхронизация вкладок, debounce/throttle, undo/redo, восстановление после переполнения квоты — вынесен в отдельный framework-agnostic класс StorageEngine. Vue-composable теперь тонкая обёртка поверх него, а React получил свой хук на том же движке, с теми же опциями и тем же поведением.
Заодно с этим рефакторингом в движок добавили пять новых возможностей, которых раньше не было вовсе или они жили отдельно от основного пайплайна: HMAC-подпись, троттлинг, историю значений с undo/redo, умное восстановление после переполнения квоты и devtools-таймлайн с тестовыми утилитами.
Framework-agnostic ядро и React-поддержка
StorageEngine не импортирует ни vue, ни react — это обычный TS-класс с интерфейсом «внешнего стора»: getSnapshot() возвращает текущий снапшот состояния, subscribe(listener) подписывает слушателя на изменения, onEvent() отдаёт поток событий (write / expire / migrate / sync-received / error) для devtools.
ts
class StorageEngine<T> {
getSnapshot(): StorageSnapshot<T>
subscribe(listener: () => void): () => void
onEvent(listener: (e: EngineEvent) => void): () => void
setValue(v: T): void
undo(): void
redo(): void
remove(): void
refresh(): Promise<void>
}
Этот контур специально спроектирован под React useSyncExternalStore — React читает его напрямую, без адаптеров. Vue поверх той же сигнатуры строит привычный ref через watch(..., { flush: 'sync' }).
Важная деталь: инстансы движка разделяются между фреймворками через общий refcounted кэш. Если один и тот же ключ хранилища запрошен и из Vue-компонента, и из React-компонента — оба получают буквально один и тот же StorageEngine, с одними и теми же таймерами и одной подпиской на BroadcastChannel, а не по независимой копии на каждого. Значит, синхронизация между фреймворками работает "бесплатно" — без единой строчки кода для моста между ними.
tsx
// React: тот же ключ, тот же движок, что и в соседнем Vue-компоненте
import { useStorage } from 'vue-storage-kit/react'
function Counter() {
const { value, setValue, isReady } = useStorage('count', {
defaultValue: 0,
target: 'local',
})
if (!isReady) return <p>Loading…</p>
return <button onClick={() => setValue((c) => c + 1)}>{value}</button>
}
Опции, поведение, TTL, шифрование, синхронизация вкладок — всё то же самое, что и в Vue-версии. Разница только в возвращаемом объекте: вместо Ref — обычные значения и setValue, потому что React не умеет в реактивные ссылки.
Сценарий: витрина на Vue и чекаут на React
Магазин целиком написан на Vue — каталог, корзина, личный кабинет. Но страница оплаты — отдельный React-виджет, встроенный по требованию платёжного провайдера (его SDK поставляется именно как React-компонент, и переписывать его на Vue никто не будет). Корзина при этом должна оставаться одной и той же на всех шагах: пользователь наполняет её на Vue-витрине, а видит и подтверждает — уже в React-виджете оплаты.
ts
interface CartItem { id: string; title: string; price: number; qty: number }
vue
<!-- Vue: витрина и корзина, основная часть магазина -->
<script setup lang="ts">
import { useStorage } from 'vue-storage-kit'
const { value: cart } = useStorage('cart', {
defaultValue: [] as CartItem[],
target: 'local',
ttl: 24 * 60 * 60 * 1000,
})
function addToCart(item: CartItem) {
cart.value = [...cart.value, item]
}
</script>
tsx
// React: виджет оплаты от платёжного провайдера, встроен только на /checkout
import { useStorage } from 'vue-storage-kit/react'
function CheckoutWidget() {
const { value: cart, isReady } = useStorage('cart', {
defaultValue: [] as CartItem[],
target: 'local',
ttl: 24 * 60 * 60 * 1000,
})
if (!isReady) return <Spinner />
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0)
return <PaymentForm items={cart} total={total} />
}
Оба обращаются к одному и тому же ключу cart с одинаковым ttl — значит, к одному и тому же StorageEngine, с общим TTL и общей записью. Никакой передачи корзины через query-параметры или postMessage между виджетом и остальным магазином не нужно: React-виджет — просто ещё один читатель того же реактивного значения, о существовании которого остальной Vue-код даже не обязан знать.
Пока для React реализован именно useStorage — это самый частоиспользуемый composable пакета. useCookie, useIndexedDB, useStorageList и Pinia-эквивалент для React остаются в бэклоге: это осознанная граница объёма текущего релиза, а не забытая часть.
HMAC-подпись — обнаружение случайной порчи данных
Шифрование скрывает содержимое, но не проверяет, что данные не были изменены с момента записи. Новая опция sign добавляет лёгкую проверку целостности: значение остаётся полностью читаемым (в отличие от encrypt), но при каждом чтении сверяется подпись, и если она не совпадает — значение сбрасывается к defaultValue, а через onError прилетает { type: 'signature-invalid' }.
ts
const { value: plan } = useStorage('subscription-tier', {
defaultValue: 'free',
sign: { password: import.meta.env.VITE_SIGNING_KEY },
})
sign можно комбинировать с encrypt — подпись всегда оборачивает самый внешний слой, то есть покрывает и уже зашифрованные/сжатые данные:
ts
const { value: vault } = useStorage('vault', {
defaultValue: {},
encrypt: { password: 'encrypt-pw' },
sign: { password: 'sign-pw' }, // может быть другим паролем
})
Важная оговорка: это не барьер безопасности против пользователя, который контролирует собственный браузер. Ключ подписи, как и ключ шифрования, всё равно достижим через DevTools или чтение бандла — подпись не остановит того, кто целенаправленно хочет отредактировать свои же данные. Реальная польза sign — ловить случайную порчу: баг в другом месте приложения, который пишет мусор в тот же ключ, гонку в синхронизации вкладок, странности конкретного браузера при записи в storage.
Сценарий: кешированный тариф подписки
SPA кеширует тариф пользователя в localStorage, чтобы не дёргать сервер на каждой загрузке страницы. Если данные повреждены — например, из-за старой версии приложения в другой открытой вкладке, которая пишет в тот же ключ в старом формате — нужно надёжно это заметить и перезапросить тариф с сервера, а не показать пользователю неверный уровень доступа.
ts
const { value: plan, isReady } = useStorage('subscription-tier', {
defaultValue: null as 'free' | 'pro' | 'enterprise' | null,
sign: { password: signingKey },
onError: (err) => {
if (err.type === 'signature-invalid') {
// Локальный кеш повреждён — не доверяем ему, перезапрашиваем с сервера
refetchSubscriptionTier()
}
},
})
watch(isReady, (ready) => {
if (ready && plan.value === null) refetchSubscriptionTier()
})
Троттлинг записи
debounce пишет в хранилище только после паузы в изменениях — удобно для текстовых полей, но плохо подходит для непрерывных изменений вроде перетаскивания слайдера: если тащить его 5 секунд без остановки, запись вообще не произойдёт до самого конца. Новая опция throttle решает это иначе — гарантирует запись не реже чем раз в N миллисекунд, даже во время непрерывного изменения.
ts
const { value: volume } = useStorage('player-volume', {
defaultValue: 50,
throttle: 200, // не чаще раза в 200мс, даже если ползунок тащат непрерывно
})
debounce и throttle взаимоисключающие — если заданы оба, побеждает throttle. Первое изменение в окне пишется сразу, дальше гарантированно происходит "хвостовая" запись по истечении интервала с последним значением, и при уничтожении компонента отложенная запись всегда флашится, чтобы не потерять последнее состояние.
Сценарий: громкость плеера
Ползунок громкости меняется на каждый mousemove, пока пользователь его тащит — это может быть сотни событий в секунду. Записывать в localStorage на каждое из них — заметная нагрузка, а ждать полной остановки (debounce) означает, что пользователь, отпустивший слайдер на середине движения через 3 секунды перетаскивания, всё это время не увидел бы сохранённого значения при случайном закрытии вкладки.
vue
<script setup lang="ts">
import { useStorage } from 'vue-storage-kit'
const { value: volume } = useStorage('player-volume', {
defaultValue: 50,
throttle: 200,
})
</script>
<template>
<input
type="range"
min="0" max="100"
v-model.number="volume"
/>
</template>
История значений — undo/redo
Опция history держит в памяти последние N значений и добавляет undo()/redo() с реактивными canUndo/canRedo — без ручного управления стеком изменений.
ts
const { value: draft, undo, redo, canUndo, canRedo } = useStorage('post-draft', {
defaultValue: '',
history: 20,
})
История сознательно не персистится между перезагрузками страницы — переживает только текущее значение (оно, как обычно, пишется в storage). Новый setValue() после undo() очищает стек redo, как и ожидается от обычного текстового редактора.
Сценарий: черновик поста с отменой правок
Редактор автосохраняет текст в localStorage при каждом изменении (комбинируется с debounce, чтобы не писать на каждое нажатие клавиши), а Ctrl+Z/Ctrl+Shift+Z работает поверх той же реактивной привязки без отдельного стейт-менеджера:
vue
<script setup lang="ts">
import { useStorage } from 'vue-storage-kit'
import { onMounted, onUnmounted } from 'vue'
const { value: draft, undo, redo, canUndo, canRedo } = useStorage('post-draft', {
defaultValue: '',
history: 20,
debounce: 500,
})
function onKeydown(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo() }
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); redo() }
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="toolbar">
<button :disabled="!canUndo" @click="undo">↩ Отменить</button>
<button :disabled="!canRedo" @click="redo">↪ Повторить</button>
</div>
<textarea v-model="draft" rows="12" />
</template>
Умное восстановление после переполнения квоты
Раньше useStorage умел только одно: при QuotaExceededError подчищал свои же протухшие по TTL ключи и повторял запись один раз. Если это не помогало — просто репортил ошибку. Новая опция evictOnQuota расширяет этот механизм LRU-эвакуацией: если TTL-очистки не хватило, движок удаляет наименее давно записанные чужие ключи (по возрасту, старые первыми) и повторяет попытку, пока не наберётся нужное место или не закончится лимит попыток.
ts
const { value: cachedPage } = useStorage('page-cache:home', {
defaultValue: null as CachedPage | null,
evictOnQuota: { max: 5 }, // до 5 эвакуаций чужих ключей за одну попытку записи
})
По умолчанию выключено — удаление чужих ключей это заметный побочный эффект, на который нужно осознанно согласиться. При включении механизм умеет судить о возрасте только тех ключей, чей exp/ts можно прочитать без знания их пароля — это касается и обычных, и зашифрованных/сжатых/подписанных значений одинаково, поскольку служебные метаданные хранятся отдельно от самого содержимого.
Сценарий: офлайн-кеш страниц каталога
PWA кеширует отрендеренные страницы каталога в localStorage для офлайн-доступа. Активное использование быстро упирается в лимит браузера (~5–10 МБ на origin). Вместо того чтобы просто переставать кешировать новые страницы при переполнении, разумнее вытеснять самые старые записи кеша — они с большей вероятностью уже не актуальны:
ts
function usePageCache(pageId: string) {
return useStorage(`page-cache:${pageId}`, {
defaultValue: null as CachedPage | null,
ttl: 60 * 60 * 1000, // страница считается свежей час
evictOnQuota: { max: 3 }, // при переполнении вытесняем до 3 старых страниц
onError: (err) => {
if (err.type === 'quota-exceeded') {
// Даже вытеснение не помогло — кеш действительно переполнен,
// просто продолжаем работать без кеширования этой страницы
console.warn(`Не удалось закешировать страницу ${pageId}`)
}
},
})
}
Devtools-таймлайн и тестовые утилиты
Custom-инспектор Vue Devtools (vue-storage-kit/devtools) теперь ведёт таймлайн событий движка — write, expire, migrate, sync-received, error — с ключом, временем и деталями каждого события. Удобно, когда нужно понять, почему значение внезапно поменялось: пришло ли оно из другой вкладки, истёк ли TTL, или сработала миграция схемы.
ts
// main.ts
import { setupDevtools } from 'vue-storage-kit/devtools'
const app = createApp(App)
setupDevtools(app)
Подключение полностью опционально и не попадает в основной бандл — грузится динамически только если вызвать setupDevtools явно.
Отдельно появился subpath vue-storage-kit/testing — паттерны, которые тестовый набор самого пакета годами использовал вручную, теперь доступны и потребителям:
ts
import { mockStorage, seedEnvelope, flushAsync } from 'vue-storage-kit/testing'
it('показывает сохранённое имя пользователя', async () => {
const { adapter } = mockStorage()
await seedEnvelope(adapter, 'username', 'Алиса')
const wrapper = mount(ProfileCard)
await flushAsync()
expect(wrapper.text()).toContain('Алиса')
})
mockStorage() подменяет фабрику адаптеров на in-memory реализацию без единого обращения к vi.mock/jest.mock — пакет не тянет за собой зависимость от конкретного тест-раннера.
Заодно — стабильность
Несколько изменений, которые не добавляют новых опций, но напрямую влияют на надёжность существующих сценариев:
- Восстановление после переполнения квоты (TTL-очистка и LRU-эвакуация) теперь корректно работает и для зашифрованных/сжатых/подписанных значений — раньше не могло прочитать срок жизни такого ключа и просто пропускало его.
- Синхронизация между вкладками стала надёжнее в граничных случаях: правки с
debounce, гонки при старте приложения, сообщения не по порядку — теперь всегда побеждает действительно самое свежее значение. - Плагин для Pinia больше не может потерять правку стора, сделанную в тот же момент, что и восстановление состояния из хранилища.
- SSR-совместимый
useCookieв Nuxt-модуле больше не падает на некорректно закодированной cookie в заголовке входящего запроса.
Итог
React-поддержка пока покрывает useStorage — самый востребованный composable пакета. useCookie, useIndexedDB, useStorageList и Pinia-эквивалент для React остаются в бэклоге — это следующий естественный шаг для тех, кто уже пишет на React поверх этого пакета.
NPM: https://www.npmjs.com/package/vue-storage-kit
GitHub: https://github.com/macrulezru/vue-storage-kit