Интеграция с Vue: composables поверх idb-keyval

В экосистеме Vue 3 подход Composition API позволяет инкапсулировать логику работы с данными в переиспользуемые функции — composables. При работе с idb-keyval это особенно удобно, поскольку IndexedDB асинхронна, а состояние приложения должно быть реактивным.

Основная задача composable — связать:

  • асинхронное хранилище (idb-keyval)
  • реактивное состояние (ref, reactive)
  • жизненный цикл Vue-компонента

Результатом становится единый слой доступа к данным, скрывающий детали работы IndexedDB.


Базовый composable для работы с ключом

Простейшая абстракция — composable, синхронизирующий одно значение с IndexedDB:

import { ref, watch } from 'vue'
import { get, set } from 'idb-keyval'

export function useIDBKey(key, defaultValue = null) {
  const state = ref(defaultValue)
  const isLoading = ref(true)
  const error = ref(null)

  // Загрузка значения
  get(key)
    .then(value => {
      if (value !== undefined) {
        state.value = value
      }
    })
    .catch(err => {
      error.value = err
    })
    .finally(() => {
      isLoading.value = false
    })

  // Автосохранение при изменении
  watch(state, (newValue) => {
    set(key, newValue).catch(err => {
      error.value = err
    })
  }, { deep: true })

  return {
    state,
    isLoading,
    error
  }
}

Особенности реализации:

  • Ленивая загрузка: значение подтягивается при инициализации
  • Реактивность: state автоматически обновляет UI
  • Синхронизация: любые изменения записываются в IndexedDB
  • Обработка ошибок: отдельный реактивный канал

Работа с объектами и глубокой реактивностью

При хранении сложных структур (объекты, массивы) важно учитывать deep-наблюдение:

watch(state, (newValue) => {
  set(key, newValue)
}, { deep: true })

Без deep: true изменения вложенных свойств не будут отслеживаться.


Расширенный composable с CRUD-операциями

Для более гибкого управления состоянием полезно явно разделить операции:

import { ref } from 'vue'
import { get, set, del } from 'idb-keyval'

export function useIDB(key) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const load = async () => {
    loading.value = true
    try {
      data.value = await get(key)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  const save = async (value) => {
    try {
      await set(key, value)
      data.value = value
    } catch (e) {
      error.value = e
    }
  }

  const remove = async () => {
    try {
      await del(key)
      data.value = null
    } catch (e) {
      error.value = e
    }
  }

  return {
    data,
    loading,
    error,
    load,
    save,
    remove
  }
}

Преимущества:

  • контроль над моментом записи
  • отсутствие лишних операций при каждом изменении
  • возможность внедрения логики валидации

Синхронизация с жизненным циклом компонента

Часто требуется загрузка данных при монтировании:

import { onMounted } from 'vue'

onMounted(() => {
  load()
})

Или в composable:

import { onMounted } from 'vue'

export function useAutoLoadIDB(key) {
  const { data, load } = useIDB(key)

  onMounted(load)

  return { data }
}

Кэширование и предотвращение лишних запросов

Повторные вызовы get могут быть избыточными. Решение — локальный кэш:

const cache = new Map()

export async function getCached(key) {
  if (cache.has(key)) {
    return cache.get(key)
  }

  const value = await get(key)
  cache.set(key, value)
  return value
}

Интеграция в composable:

data.value = await getCached(key)

Работа с коллекциями

Для хранения списков используется единый ключ:

export function useIDBList(key) {
  const list = ref([])

  const load = async () => {
    list.value = (await get(key)) || []
  }

  const add = async (item) => {
    list.value.push(item)
    await set(key, list.value)
  }

  const remove = async (index) => {
    list.value.splice(index, 1)
    await set(key, list.value)
  }

  return { list, load, add, remove }
}

Важные моменты:

  • изменения массива должны происходить через реактивные методы (push, splice)
  • после каждой мутации — запись в IndexedDB

Оптимизация: дебаунс записи

Частые изменения состояния могут перегружать IndexedDB. Решение — дебаунс:

import { watch } from 'vue'
import { set } from 'idb-keyval'
import debounce from 'lodash.debounce'

const saveDebounced = debounce((key, value) => {
  set(key, value)
}, 300)

watch(state, (val) => {
  saveDebounced(key, val)
}, { deep: true })

Синхронизация между вкладками

IndexedDB не уведомляет напрямую о изменениях. Для синхронизации используется storage или BroadcastChannel:

const channel = new BroadcastChannel('idb-sync')

channel.onmess age = (event) => {
  if (event.data.key === key) {
    state.value = event.data.value
  }
}

watch(state, (val) => {
  set(key, val)
  channel.postMessage({ key, value: val })
})

Типизация (TypeScript)

Composables удобно типизировать:

import { Ref } from 'vue'

export function useIDBKey<T>(key: string, defaultValue: T): {
  state: Ref<T>
  isLoading: Ref<boolean>
  error: Ref<Error | null>
} {
  // реализация
}

Преимущества:

  • автодополнение
  • контроль типов данных в хранилище
  • снижение числа ошибок

Обработка ошибок и отказоустойчивость

IndexedDB может быть недоступна (например, в приватном режиме). Стратегия:

const isSupported = 'indexedDB' in window

if (!isSupported) {
  // fallback на localStorage
}

Пример fallback:

const storage = isSupported ? idb : localStorage

Паттерн “источник истины”

При использовании composables важно определить:

  • IndexedDB — источник истины
  • или только слой кеша

Подход 1: IndexedDB как основной storage

  • данные читаются и пишутся только туда
  • подходит для offline-first приложений

Подход 2: IndexedDB как кеш API

  • данные приходят с сервера
  • IndexedDB используется для ускорения

Composables могут инкапсулировать оба сценария.


Разделение логики по доменам

Рекомендуется создавать composables по смыслу:

useUserSettings()
useCart()
usePreferences()

Внутри каждого — использование idb-keyval, но интерфейс остаётся бизнес-ориентированным.


Тестирование composables

Тесты можно писать с моками idb-keyval:

jest.mock('idb-keyval', () => ({
  get: jest.fn(),
  set: jest.fn(),
}))

Проверяется:

  • загрузка данных
  • запись при изменении
  • обработка ошибок

Расширение: кастомные store’ы

idb-keyval поддерживает пользовательские базы:

import { createStore, get, set } from 'idb-keyval'

const customStore = createStore('app-db', 'settings')

set('theme', 'dark', customStore)
get('theme', customStore)

В composable:

export function useCustomStore(key) {
  const store = createStore('app-db', 'settings')
  // использование store
}

Интеграция с глобальным состоянием

Composables можно комбинировать с:

  • Vuex (legacy)
  • Pinia (рекомендуется)

Пример с Pinia:

import { defineStore } from 'pinia'
import { get, set } from 'idb-keyval'

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    theme: 'light'
  }),

  actions: {
    async load() {
      this.theme = await get('theme') || 'light'
    },

    async save() {
      await set('theme', this.theme)
    }
  }
})

Контроль производительности

Ключевые рекомендации:

  • избегать частых set
  • использовать дебаунс
  • группировать изменения
  • не хранить слишком большие объёмы данных в одном ключе

Безопасность данных

IndexedDB:

  • изолирована по origin
  • недоступна другим сайтам
  • но не шифруется автоматически

Для чувствительных данных:

  • использовать шифрование перед записью
  • хранить ключи отдельно

Итоговая структура composable

Хороший composable включает:

  • реактивное состояние
  • методы загрузки/сохранения
  • обработку ошибок
  • оптимизацию записи
  • (опционально) синхронизацию между вкладками

Такой подход превращает idb-keyval из низкоуровневого API в удобный и безопасный слой хранения, органично встроенный в реактивную модель Vue.