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

23.08.2026
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 Copy
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 Copy
// 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 Copy
interface CartItem { id: string; title: string; price: number; qty: number }
vue Copy
<!-- 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 Copy
// 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 Copy
const { value: plan } = useStorage('subscription-tier', {
  defaultValue: 'free',
  sign: { password: import.meta.env.VITE_SIGNING_KEY },
})

sign можно комбинировать с encrypt — подпись всегда оборачивает самый внешний слой, то есть покрывает и уже зашифрованные/сжатые данные:

ts Copy
const { value: vault } = useStorage('vault', {
  defaultValue: {},
  encrypt: { password: 'encrypt-pw' },
  sign: { password: 'sign-pw' },   // может быть другим паролем
})

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

Сценарий: кешированный тариф подписки

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

ts Copy
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 Copy
const { value: volume } = useStorage('player-volume', {
  defaultValue: 50,
  throttle: 200,   // не чаще раза в 200мс, даже если ползунок тащат непрерывно
})

debounce и throttle взаимоисключающие — если заданы оба, побеждает throttle. Первое изменение в окне пишется сразу, дальше гарантированно происходит "хвостовая" запись по истечении интервала с последним значением, и при уничтожении компонента отложенная запись всегда флашится, чтобы не потерять последнее состояние.

Сценарий: громкость плеера

Ползунок громкости меняется на каждый mousemove, пока пользователь его тащит — это может быть сотни событий в секунду. Записывать в localStorage на каждое из них — заметная нагрузка, а ждать полной остановки (debounce) означает, что пользователь, отпустивший слайдер на середине движения через 3 секунды перетаскивания, всё это время не увидел бы сохранённого значения при случайном закрытии вкладки.

vue Copy
<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 Copy
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 Copy
<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 Copy
const { value: cachedPage } = useStorage('page-cache:home', {
  defaultValue: null as CachedPage | null,
  evictOnQuota: { max: 5 },   // до 5 эвакуаций чужих ключей за одну попытку записи
})

По умолчанию выключено — удаление чужих ключей это заметный побочный эффект, на который нужно осознанно согласиться. При включении механизм умеет судить о возрасте только тех ключей, чей exp/ts можно прочитать без знания их пароля — это касается и обычных, и зашифрованных/сжатых/подписанных значений одинаково, поскольку служебные метаданные хранятся отдельно от самого содержимого.

Сценарий: офлайн-кеш страниц каталога

PWA кеширует отрендеренные страницы каталога в localStorage для офлайн-доступа. Активное использование быстро упирается в лимит браузера (~5–10 МБ на origin). Вместо того чтобы просто переставать кешировать новые страницы при переполнении, разумнее вытеснять самые старые записи кеша — они с большей вероятностью уже не актуальны:

ts Copy
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 Copy
// main.ts
import { setupDevtools } from 'vue-storage-kit/devtools'

const app = createApp(App)
setupDevtools(app)

Подключение полностью опционально и не попадает в основной бандл — грузится динамически только если вызвать setupDevtools явно.

Отдельно появился subpath vue-storage-kit/testing — паттерны, которые тестовый набор самого пакета годами использовал вручную, теперь доступны и потребителям:

ts Copy
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

Читать далее

rest-pipeline-js 2.1.0: живое демо вместо документации и вычищенные гонки

23.08.2026

Новый релиз добавляет восемь новых возможностей — от умного джиттера в retry до WebSocket-стадий и офлайн-очереди — и показывает их не в README, а на живом CI/CD-дашборде и торговом терминале. Заодно закрыта дюжина гонок в конкурентном коде и наведён порядок в структуре src/. Обратной несовместимости нет.

Метки
javascripttypescriptopensourcenpmwebdev

@macrulez/vue-form-schema 0.2.2: типы из Zod, Nuxt-модуль и дискриминированные поля

21.08.2026

Новая версия @macrulez/vue-form-schema приносит сразу семь крупных возможностей: автовывод TypeScript-типов из Zod/Yup/Valibot, официальный Nuxt-модуль, три новые UI-темы, поддержку OpenAPI, маппинг серверных ошибок, плагин для Vue DevTools и дискриминированные схемы.

Метки
vuevue-form-schemanuxttypescriptopensource

vue-image-kit 1.1.0: от art direction до собственного image-сервера

20.08.2026

В версии 1.1.0 vue-image-kit дорос с «удобного компонента для картинок» до полноценного стека: авто-детект 8 CDN, self-hosted сервер ресайза на замену CDN, Nuxt-модуль с реальным Nitro-роутом, инкрементальная сборка CLI и ещё десяток менее заметных, но полезных вещей. Ниже — подробный разбор того, что попало в релиз и почему сделано именно так.

Метки
vuevue-image-kitnuxtimage-optimizationopensource