
Современные web-фреймворки для реализации управления состоянием используют библиотеки, такие, например, как Redux для React или Pinia для Vue. У традиционной реализации управления состоянием есть недостатки. Store в таком варианте является частью скрипта страницы, и его данные при её перезагрузке теряются. Кроме того, если нам в приложении нужно организовать управление отображением контента в нескольких окнах браузера, оказывается, что традиционный Store не может этого обеспечить.
В данной статье мы рассмотрим другие пути построения Store, которые свободны от этих недостатков.
Вступление
Управление состоянием — это ключевой аспект реактивного веб-программирования, позволяющий стандартизировать логику изменения данных. Основная идея управления состоянием состоит в том, что все данные, влияющие на отображение приложения, находятся в хранилище (Store), которое служит единым источником информации, на основании которой и отображается приложение. При необходимости изменить какие-то детали отображения, обновляются соответствующие данные в хранилище. При изменении данных в хранилище автоматически (реактивно) соответствующим образом изменяется и отображение приложения.
Традиционные решения для построения Store имеют недостатки и ограничения, речь о которых шла выше. Решение, в котором Store построен на основе браузерного хранилища, этих недостатков не имеет. У браузера есть два пригодных для построения Store встроенных хранилища — база данных в виде словаря ключ-значение LocalStorage, и система управления базами данных (СУБД) IndexedDB. В этой статье мы последовательно рассмотрим, как использовать каждое из этих хранилищ в качестве Store в приложении, написанном на Vue.
С чем работаем
LocalStorage — это небольшая NoSQL база данных в виде словаря ключ-значение, в которой данные хранятся в текстовом виде. Её объём ограничен размером ≈ 5 МБ. Работать с ней непосредственно не слишком удобно — при записи и чтении требуется преобразование типов данных. Кроме того, для Store требуются средства реактивного чтения из хранилища, чего сам по себе LocalStorage не предоставляет. Эти проблемы легко решаются при использовании функции useStorage — из библиотеки VueUse, которая является обёрткой над LocalStorage.
IndexedDB — это встроенная в браузер реляционная NoSQL СУБД, которая позволяет хранить большие объёмы структурированных данных. IndexedDB поддерживает индексы, транзакции и асинхронные операции, то есть обладает всем тем, что необходимо для построения сложных приложений.
Работа с IndexedDB непосредственно через её API, требует написания большого количества кода для выполнения даже простых операций. Для работы с IndexedDB удобно использовать библиотеку-обёртку Dexie, которая значительно её упрощает и делает интуитивно понятной. Dexie позволяет манипулировать базами данных и таблицами, создавать индексы по заданным ключам, в т. ч. и комбинированным, выполнять CRUD-операции с записями в таблицах, использовать транзакции, и, что важно для организации реактивности, получать поток данных по заданному запросу, отслеживающий изменения данных в базе, затрагивающие данный запрос.
Основная идея
В этой статье мы рассмотрим три варианта организации Store. Первый вариант — традиционный. Второй — основанный на LocalStorage. И третий — основанный на IndexedDB. В качестве практического примера, мы напишем простое учебное приложение на Vue, состоящее из двух компонентов, в котором реализуем механизм переключения темы оформления с помощью управления состоянием посредством каждого из этих трёх вариантов по очереди. Для всех этих вариантов рассмотрим организацию тестирования, которое для каждого случая имеет свои особенности.
В качестве практического примера, мы напишем простое учебное приложение на Vue, в котором реализован механизм переключения темы оформления с помощью управления состоянием посредством Store. Реализация Store на каждом шаге будет своя.

Наш проект учебный. Он не будет иметь никакой функциональности кроме переключения темы оформления. У нас будет только два компонента. Первый компонент — корневой App.vue, в котором всё приложение оборачивается в корневой элемент div, которому присваивается css-класс, соответствующий теме оформления. Стили css элементов приложения прописаны так, что при переключении класса, задающего тему в корневом элементе, все они меняют своё отображение в соответствии с выбранной темой.
Второй, дочерний по отношению к App.vue, компонент SettingsForm.vue содержит форму настроек, которая будет у нас содержать единственный переключатель тёмной и светлой темы.
Работая с таким простым учебным проектом, условно будем считать, что компонентов у нас гораздо больше, что SettingsForm.vue спрятан где-то глубоко в дереве иерархии компонентов, и что он не один, и есть другие аналогичные компоненты, управляющие темой отображения. И кроме темы отображения есть ещё огромное количество других полей модели... Но для понимания особенностей построения Store нам достаточно приложения с самой простой схемой.
Создаём основу приложения
Проект базируется на шаблоне утилиты командной строки Vite и использует Vue и Typescript.
Запускаем в терминале утилиту командной строки Vite:
> npm create vite@latest
Вводим название проекта, а также выбираем опции vue и typescript.

Создаётся папка с проектом. Переходим в эту папку и устанавливаем зависимости:
> npm i
Откроем проект в IDE и в файле vite.config.ts
зададим уникальный порт для нашего проекта:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// Добавляем порт
server: {
port: <Номер порта>
},
})
Указывать уникальный порт приложения желательно по той причине, что к нему браузер привязывает своё хранилище. Если у нас с использованием шаблона Vite и с портом по умолчанию будет развёрнуто несколько проектов, то при совпадении имён ключей LocalStorage или баз данных IndexedDB (что очень вероятно, если мы будем переносить код из одного проекта в другой), возникнет конфликт даже если мы никогда не будем запускать их одновременно.
Теперь из полученной заготовки убираем весь лишний для нас код.
В компоненте src/App.vue убираем скрипты и стили, оставляем пустой шаблон:
<!-- src/App.vue -->
<template>Это корень проекта</template>
Из папки components удаляем файл
HelloWorld.vue
.
Папки
src/assets/
иpublic/
с картинками нам тоже не потребуются. Удаляем их.
Правим файл
src/style.css
. Удаляем все классы и стили, кроме обнуления отступовbody
и настроек шрифта. Дополняем их указанием размера шрифта по умолчанию14px
.
/* src/style.css */
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-size: 14px;
}
body {
margin: 0;
}
Мы получили пустую основу для нашего приложения. Начнём его собирать.
Собираем вариант переключения темы с помощью традиционных средств управления состоянием

Определим тип для темы оформления. Создадим папку src/types/
и в ней файл src/types/Theme.ts
:
// src/types/Theme.ts
export type Theme = 'dark' | 'light'
Для управления состоянием приложения мы будем использовать Pinia. Ставим пакет:
> npm i pinia
Теперь подключим Pinia к нашему проекту, доработав файл src/main.ts:
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')
Создаём Store. Создадим папку src/stores/
а в ней файл src/stores/settings.ts
:
// src/stores/settings.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('counter', () => {
const theme = ref('light')
return { theme }
})
Теперь добавим css-переменные цветов темы. У нас спартанский дизайн, поэтому нам хватит двух цветов: --color-dark
и --color-light
. Добавим их в файл src/style.css
:
/* src/style.css */
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-size: 14px;
/* Variables */
--color-dark: black;
--color-light: white;
}
body {
margin: 0;
}
Создадим компонент src/components/SettingsForm.vue
со следующим содержимым:
<!-- src/components/SettingsForm.vue -->
<script lang="ts">
import { useSettingsStore } from '../stores/settings' // — Добавляем
import type { Theme } from '../types/Theme.js'
</script>
<script setup lang="ts">
const settingsStore = useSettingsStore()
const changeTheme = (theme?: Theme) => {
settingsStore.theme = theme
}
</script>
<template>
<div class="settings-envelope">
<div class="switch-envelope">
<div>Тема:</div>
<div class="switch-label" @click="changeTheme('dark')">тёмная</div>
<div class="switch-label" @click="changeTheme('light')">светлая</div>
</div>
</div>
</template>
<style scoped>
.settings-envelope {
padding-top: 30px;
padding-left: 20px;
display: flex;
gap: 20px;
font-weight: bold;
}
.switch-envelope {
display: flex;
align-items: center;
gap: 20px;
}
.theme-button {
cursor: pointer;
padding: 4px;
font-weight: bold;
border: 2px solid #808080;
border-radius: 6px;
}
</style>
Логика работы его простая. Если кликнуть по кнопке с названием темы оформления, то имя темы получит значение, соответствующее названию, по которому кликнули. Имя темы передаётся в Store.
В корневой компонент src/App.vue
поместим следующий код:
<!-- src/App.vue -->
<script lang="ts">
import { useSettingsStore } from './stores/settings'
import SettingsForm from './components/SettingsForm.vue'
</script>
<script setup lang="ts">
const settingsStore = useSettingsStore()
</script>
<template>
<div :class="settingsStore.theme" class="envelope">
<SettingsForm />
</div>
</template>
<style>
.envelope {
width: 100%;
height: 100vh;
}
.light {
background-color: var(--color-light);
color: var(--color-dark);
}
.dark {
background-color: var(--color-dark);
color: var(--color-light);
}
</style>
Запустим dev-сервер:
> npm run dev
Откроем страницу по адресу http://localhost:<порт, который мы указали>, и убедимся, что выбор темы работает:

Поэкспериментируем с выбором темы. Смотрим, как переключаются темы в двух открытых окнах с нашим приложением. Ожидаемо окна ведут себя независимо одно от другого.
И ещё. Если мы перезагрузим страницу, выбранную накануне тёмную тему мы потеряем. Если мы хотим сохранять пользовательские предпочтения, нам нужно использовать либо LocalStorage, либо IndexedDB, либо держать пользовательские настройки в удалённой базе на сервере. Плюсом такого подхода является возможность переносить настройки на все устройства пользователя. Но такое решение требует дополнительного кода, трафика, и не годится для serverless приложений.
Собираем механизм управления состоянием на основе LocalStorage
Правим код

Сначала немного почистим наш проект.
Удалим папку
src/stores/
с содержимым.Pinia нам больше не потребуется. Удалим её:
> npm un pinia
Вернём файл src/main.ts
в первоначальное состояние:
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
Для реактивной работы с LocalStorage нам потребуется функция useStorage из пакета @vueuse/core. Ставим этот пакет:
> npm i @vueuse/core
Доработаем компонент src/components/SettingsForm.vue
. В коде заменяем одно хранилище на другое:
<!-- src/components/SettingsForm.vue — секции скриптов -->
<script lang="ts">
// Заменяем
// import { useSettingsStore } from '../stores/settings' // — Добавляем
// На
import { useStorage } from '@vueuse/core' // — Добавляем
import type { Theme } from '../types/Theme.js'
</script>
<script setup lang="ts">
// Заменяем
// const settingsStore = useSettingsStore()
// На
const state = useStorage('settings-storage', { theme }) // Добавляем State в LocalStorage
const changeTheme = (theme?: Theme) => {
// Заменяем
// settingsStore.theme = theme
// На
state.value.theme = theme
}
</script>
Также доработаем корневой компонент src/App.vue
:
<!-- src/App.vue -->
<script lang="ts">
// Заменяем
// import { useSettingsStore } from './stores/settings'
// на
import { useStorage } from '@vueuse/core'
import SettingsForm from './components/SettingsForm.vue'
</script>
<script setup lang="ts">
// Заменяем
// const settingsStore = useSettingsStore()
// на
const state = useStorage('settings-storage', { theme: 'light' })
</script>
<template>
<!-- Заменяем -->
<!-- <div :class="settingsStore.theme" class="envelope"> -->
<!-- На -->
<div :class="state.theme" class="envelope">
<SettingsForm />
</div>
</template>
<style>
.envelope {
width: 100%;
height: 100vh;
}
.light {
background-color: var(--color-light);
color: var(--color-dark);
}
.dark {
background-color: var(--color-dark);
color: var(--color-light);
}
</style>
Перезапустим dev-сервер и посмотрим, что у нас получилось. Если мы сделали всё аккуратно, магия должна заработать.
Теперь посмотрим, как наше приложение будет себя вести на двух разных вкладках с одинаковым адресом. Дублируем вкладку в новое окно и... Вау! Мы добились реактивной передачи данных между разными окнами/вкладками!
Тема оформления после закрытия вкладки и открытия её заново также ожидаемо сохраняется.
Пишем тесты для варианта, в котором используется LocalStorage
Теперь перейдём к написанию тестов. Приложение, код которого не покрыт тестами, со временем становится источником головной боли для разработчиков. Чтобы этого избежать, покрытие кода тестами необходимо рассматривать как неотъемлемую составляющую его написания. Поэтому если мы обсуждаем написание Store на LocalStorage и IndexedDB, нам нужно обсудить и то, как всё это тестировать, поскольку могут быть особенности при запуске тестов в среде Node.
Мы будем писать тесты с помощью Vitest и @testing-library/vue.
В качестве пакета, эмулирующего функциональность DOM в среде Node будем использовать happy-dom.
Установим необходимые пакеты:
> npm i -D vitest happy-dom @testing-library/vue
Создадим файл vitest.config.ts
в корне проекта со следующим содержимым:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
},
})
Добавим vitest.config.ts
в секцию include
в файле tsconfig.node.json
.
/* tsconfig.node.json */
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
/* Сюда добавляем vitest.config.ts */
"include": ["vite.config.ts", "vitest.config.ts"]
}
Добавим скрипт запуска тестов в секцию script в package.json:
...
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run --dom"
},
...
Мы написали "vitest run --dom"
, чтобы после выполнения тест сразу завершился. Для нашего изложения так удобнее. Если нужно, чтобы тестирование было непрерывным по мере внесения изменений в код, команду нужно написать без run: "vitest --dom"
Теперь мы готовы писать тесты. Для начала напишем тест для src/App.vue
. В нашем учебном проекте мы будем для наглядности располагать файл теста рядом с файлом тестируемого модуля Создадим файл теста src/App.spec.ts
со следующим содержимым:
// src/App.spec.ts
import { expect, describe, it } from 'vitest'
import { render, fireEvent, waitFor } from '@testing-library/vue'
import App from './App.vue'
describe('App.vue', () => {
it('Проверяем обновление темы', async () => {
// Рендерим компонент
const screen = render(App)
// Находим корневой элемент DOM
let appContainer = screen.container.firstElementChild
// Ищем элемент подписи тёмной темы
const darkThemeLabelElement = screen.getByText(/тёмная/)
// Щёлкаем по нему
fireEvent.click(darkThemeLabelElement)
// Ждём, пока у корневого элемента не появится css-класс тёмной темы
await waitFor(() => {
expect(appContainer?.classList.contains('dark')).toBe(true)
})
// Класса неактивной светлой темы у корневого элемента быть не должно
expect(appContainer?.classList.contains('light')).toBe(false)
// Ищем элемент подписи светлой темы
const lightThemeLabelElement = screen.getByText(/светлая/)
// Щёлкаем по нему
fireEvent.click(lightThemeLabelElement)
// Ждём, пока у корневого элемента не появится css-класс светлой темы
await waitFor(() => {
expect(appContainer?.classList.contains('light')).toBe(true)
})
// Класса неактивной тёмной темы у корневого элемента быть не должно
expect(appContainer?.classList.contains('dark')).toBe(false)
})
})
Тест по сути интеграционный. Мы тестируем появление правильного css-класса темы на корневом элементе при щелчке на названии темы виджета выбора темы или на самом переключателе. Нам здесь не потребовалось каких-то дополнительных решений, связанных с LocalStorage, поскольку happy-dom поддерживает его API.
Запускаем тест и проверяем, что он не падает:

Теперь напишем модульный тест для компонента src/components/SettingsForm.vue
:
// src/components/SettingsForm.vue
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
import { render, fireEvent, cleanup } from '@testing-library/vue'
import SettingsForm from './SettingsForm.vue'
describe('SettingsForm.vue', () => {
let store: Record<string, string> = {}
beforeEach(() => {
// Мок для localStorage
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => ({
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => (store[key] = value.toString()),
removeItem: (key: string) => delete store[key],
clear: () => (store = {}),
length: Object.keys(store).length, // Добавляем свойство length
key: (index: number) => Object.keys(store)[index] || null, // Добавляем метод key
}))
// Очищаем localStorage перед каждым тестом
window.localStorage.clear()
})
afterEach(() => {
// Очищаем DOM после каждого теста
cleanup()
})
it('Проверяем, что подписи тем отображаются правильно', async () => {
const screen = render(SettingsForm)
// Проверяем, что компонент отображает текст "Тема:"
expect(screen.getByText('Тема:')).toBeTruthy()
// Находим подпись для тёмной темы
expect(screen.getAllByText(/тёмная/).length).toBe(1)
// Находим подпись для светлой темы
expect(screen.getAllByText(/светлая/).length).toBe(1)
// Проверяем, что тема по умолчанию — светлая
expect(window.localStorage.getItem('settings-storage')).toBe(
JSON.stringify({ theme: 'light' })
)
})
it('Изменение темы при клике на кнопке "тёмная"', async () => {
const { getByText } = render(SettingsForm)
// Нажимаем на кнопку "тёмная"
await fireEvent.click(getByText('тёмная'))
// Проверяем, что тема изменилась на "dark"
expect(window.localStorage.getItem('settings-storage')).toBe(
JSON.stringify({ theme: 'dark' })
)
// Нажимаем на кнопку "светлая"
await fireEvent.click(getByText('светлая'))
// Проверяем, что тема изменилась на "light"
expect(window.localStorage.getItem('settings-storage')).toBe(
JSON.stringify({ theme: 'light' })
)
})
})
Запускаем тесты и проверяем, что они по-прежнему не падают:

Мы убедились, что тестирование для варианта организации управления приложением с помощью localStorage не сложнее, чем для традиционного варианта.
Собираем механизм управления состоянием на основе IndexedDB и Dexie
Правим код для варианта, в котором используется IndexedDB
@vueuse/core нам в этом варианте не потребуется. Можем её удалить:
> npm un @vueuse/core
Нам нужно установить библиотеку Dexie:
> npm i dexie
Следующий шаг — создадим простейшую базу из одной таблицы и одной записи в ней с id = 1
. В этой записи мы будем хранить настройки приложения. Для начала будем хранить в ней, кроме id
, только один столбец — выбранную тему стилей. Назовём эту базу sdb
— Settings Data Base, а таблицу в ней — settings
.
Создадим файл src/db.ts
:
// src/db.ts
import Dexie from 'dexie'
import type { Table } from 'dexie'
import type { Theme } from '@/types/Theme'
/* Объявление интерфейса таблицы с перечислением столбцов
* -- settings --
* +------------+
* | id | theme |
* +------------+
*/
export interface ISettings {
// Здесь мы всегда будем использовать строку с id === 1,
// поэтому указываем 1 в качестве типа
id: 1
theme?: Theme
}
export class SettingsDatabase extends Dexie {
// Объявляем таблицу
settings!: Table<ISettings>
constructor() {
super('SettingsDatabase')
// Обозначаем поля, для которых будут созданы индексы
this.version(1).stores({
// Здесь прописываются столбцы, по которым в коде будет поиск
// Поскольку по столбцу theme мы искать никогда ничего не будем,
// здесь его и не пишем.
settings: '&id',
})
}
}
// Data Base Settings (sdb) — декларируем базу
// База в хранилище будет создана только при первом обращении-записи.
export const sdb = new SettingsDatabase()
// Этот объект экспортируется, т. к. потребуется при тестировании
export const dbsDefaultSettings: ISettings = { id: 1, theme: 'light' }
// Эта функция тоже потребуется при тестировании
export const dbsInit = async () => {
const notInitialized = !(await sdb.settings.get(1))?.id
// Если записей нет, делаем первую запись, чтобы работал метод update().
// Если запись уже есть, не трогаем.
if (notInitialized) {
sdb.settings.put(dbsDefaultSettings)
}
}
dbsInit()
Этот файл состоит из нескольких частей. Сначала мы задаём интерфейс ISettings
для записи в нашей таблице settings. Интерфейс записи это простое перечисление полей (столбцов) и их типов, а также являются ли они обязательными. В записи таблицы settings
указано два поля. Первое поле — первичный ключ id
, тип которого мы указали не number
, а просто 1
, так как любое другое значение мы использовать не будем, а значит оно и недопустимо. Второе поле, ради хранения которого мы всё это и развернули, — theme
с типом Theme
.
// src/db.ts
import Dexie from 'dexie'
import type { Table } from 'dexie'
import type { Theme } from '@/types/Theme'
/* Объявление интерфейса таблицы с перечислением столбцов
* -- settings --
* +------------+
* | id | theme |
* +------------+
*/
export interface ISettings {
// Здесь мы всегда будем использовать строку с id === 1,
// поэтому указываем 1 в качестве типа
id: 1
theme?: Theme
}
В следующей части объявляется класс таблицы. В его конструкторе указываются поля, на которых должен быть построен индекс. Здесь необходимо указать все поля, по которым будет проводиться поиск. Если этого не сделать, при запросе возникнет ошибка. Строить индекс на единственной записи в таблице смысла большого нет, но нам нужно обозначить его, как индексируемый, иначе не будет работать наше обращение к записи с первичным ключом, равным 1, которое в общем случае предполагает поиск по этому ключу.
// src/db.ts
...
export class SettingsDatabase extends Dexie {
// Объявляем таблицу
settings!: Table<ISettings>
constructor() {
super('SettingsDatabase')
// Обозначаем поля, для которых будут созданы индексы
this.version(1).stores({
// Здесь прописываются столбцы, по которым в коде будет поиск
// Поскольку по столбцу theme мы искать никогда ничего не будем,
// здесь его и не пишем.
settings: '&id',
})
}
}
...
Потом создаётся экземпляр класса таблицы. При этом, сама таблица в браузерном хранилище не создаётся. Она будет создана только после первого вызова метода записи.
// src/db.ts
...
// Data Base Settings (sdb) — декларируем базу
// База будет создана только при первой записи.
export const sdb = new SettingsDatabase()
...
Если таблицы в браузере нет, не будет работать метод update()
, который нам потребуется. Поэтому пишем код инициализации таблицы значениями по умолчанию, если база пустая. Объявляем асинхронную функцию sdbInit()
и сразу её вызываем. Теперь база создана, и в ней есть запись по умолчанию.
// src/db.ts
...
// Если записей нет, делаем первую запись, чтобы мог работать метод update()
const sdbDefaultSettings: ISettings = { id: 1, theme: 'light' }
const sdbInit = async () => {
const notInitialized = !(await sdb.settings.get(1))?.id
if (notInitialized) {
sdb.settings.put(sdbDefaultSettings)
}
}
sdbInit()
Теперь уберём из компонента src/App.vue
код, который относится к LocalStorage и useStorage:
<!-- src/App.vue — секция скриптов -->
<script lang="ts">
import { ref } from 'vue'
import type { Theme } from './types/Theme.js'
import SettingsForm from './components/SettingsForm.vue'
</script>
<script setup lang="ts">
const theme = ref<Theme>('light') // — Пока поставим заглушку
</script>
Из компонента src/components/SettingsForm.vue
тоже убираем код, который относится к LocalStorage и useStorage. Изменим его метод changeTheme()
так, чтобы в ходе его выполнения происходила запись выбранной темы в базу. Все методы взаимодействия с базой асинхронные, поэтому добавляем async
.
<!-- src/components/SettingsForm.vue — секция скриптов -->
<script lang="ts">
import type { Theme } from '../types/Theme.js'
// Добавляем импорт базы данных
import { sdb } from '../db'
</script>
<script setup lang="ts">
// Добавляем async
const changeTheme = async (theme?: Theme) => {
// Добавим запись темы в базу данных
await sdb.settings.update(1, { theme })
}
</script>
Теперь нужно перезапустить dev-сервер. После этого, открываем инструменты разработчика (Ctrl+Shift+I), переходим на вкладку Application и в разделе Storage находим IndexedDB. Видим, что появилась наша база SettingsDatabase, в ней появилась таблица settings, а в ней объект с выбранной темой. Откроем вкладку Настройки и пощёлкаем по переключателю темы. Мы видим, что каждый раз при щелчке на переключателе темы изменения вносятся в объект в базе. DevTools не умеет реактивно отображать изменения, но всякий раз показывает предупреждение Data may be stale. Чтобы увидеть изменения, приходится таблицу обновлять вручную.

Теперь нам нужно научиться не только записывать данные в базу, но и получать их оттуда. И не просто получать по запросу, а получать реактивно. Для этого нам потребуется метод useObservable
из библиотеки VeUse. Ставим пакет с этой библиотекой:
> npm i @vueuse/rxjs
Создадим папку src/helpers/
, а в ней файл с функцией src/helpers/observableQuery.ts
, которая будет осуществлять магию реактивности.

Код функции src/helpers/observableQuery.ts:
// src/helpers/observableQuery.ts
import { liveQuery } from 'dexie'
import { useObservable } from '@vueuse/rxjs'
import type { Ref } from 'vue'
// Функция, обеспечивающая реактивное получение данных из базы.
// https://dexie.org/docs/liveQuery()
// https://vueuse.org/rxjs/useObservable/
export default <T>(
query: () => Promise<T>,
): Readonly<Ref<T>> => {
return useObservable(liveQuery(query) as any) as Readonly<Ref<T>>
}
В компонент src/App.vue
добавляем код реактивного получения theme
из базы с помощью observableQuery()
и импорты к нему.
<!-- src/App.vue секции скриптов -->
<script lang="ts">
// import { ref } from 'vue' — Удаляем
// import type { Theme } from './types/Theme.js' — Удаляем
// Добавляем импорт базы и функции observableQuery
import { sdb } from './db.ts'
import observableQuery from './helpers/observableQuery.ts'
import SettingsForm from './components/SettingsForm.vue'
</script>
<script setup lang="ts">
// const theme = ref('light') — Заменяем на:
const theme = observableQuery(async () => (await sdb.settings.get(1))?.theme)
</script>
Запустим приложение и посмотрим, что у нас получилось. Если мы сделали всё аккуратно, магия должна заработать.
Убедимся, что на двух разных вкладках темы синхронизируются. Также убедимся, что тема сохраняется после закрытия и открытия страницы заново.
Пару слов о методе observableQuery()
.
export default <T>(query: () => Promise<T>): Readonly<Ref<T>> => {
return useObservable(liveQuery(query) as any) as Readonly<Ref<T>>
}
В качестве аргумента он получает функцию query() достаточно произвольную, в которой выполняются запросы в базу, проводятся какие-то расчёты и на их основе возвращается какой-то результат. В нашем примере в качестве аргумента мы передаём функцию
async () => (await sdb.settings.get(1))?.theme
которая просто запрашивает первую строку таблицы settings и возвращает из неё поле theme. Функция query() может быть сложной. В ней могут быть многократные обращения к записям в разных таблицах и базах и возврат произвольного вычисляемого значения.
Как только состояние базы изменится так, что это может влиять на результат функции query()
, сразу же произойдёт перерасчёт. На основе этого механизма liveQuery()
формирует поток данных. Обёртка useObservable()
позволяет из потока данных получить реактивное поле с типом Ref<T>
производным от типа возвращаемого значения запроса query()
. Заметим также, что возвращаемое значение доступно только для чтения, как например и у computed()
без сеттера. Мы не можем менять поле value
результата.
Что касается использования типа any, то причина его появления следующая:
Тип liveQuery(query) — liveQuery(querier: () => T | Promise): Observable<T>
Тип первого аргумента useObservable — observable: Observable<T>
При этом, эти Observable берутся из разных пакетов и оказываются разными типами:
«Аргумент типа "import(".../node_modules/dexie/dist/dexie").Observable<T>" нельзя назначить параметру типа "import(".../node_modules/rxjs/dist/types/internal/Observable").Observable<T>".
В типе "Observable<T>" отсутствуют следующие свойства из типа "Observable<T>": source, operator, lift, forEach и еще 2.ts(2345)»
Я не стал тратить время на то, чтобы их подружить, поскольку всё работает и тип запроса query() правильно передаётся в результат выполнения Readonly>, а проблема нестыковки локализована исключительно в этом одном этом месте. Если кто-то знает, как их подружить, буду благодарен, если вы напишете об этом в комментариях.
И ещё одна вроде бы очевидная особенность функции observableQuery()
, которую всё же стоит отметить. Реактивность срабатывает только при изменениях в базе. Если в функции query()
используются какие-то другие реактивные поля, и они меняют свои значения, liveQuery()
этого не заметит и никакой реактивности не проявит. Этим конструкция observableQuery()
принципиально отличается от computed()
.
Также важно то, что запрос query()
под капотом liveQuery()
оборачивается в транзакцию. Поэтому попытка внутри query()
что-то записать в базу вызовет ошибку.
Итак, мы собрали простейший Store на IndexedDB и Dexie.
Особенности тестирования кода, работающего с IndexedDB
Тестирование функциональности, использующей IndexedDB, имеет свои особенности. Тесты чаще всего запускаются в среде Node и используют виртуальный браузер. Но ни happy-dom, ни jsdom не поддерживают API IndexedDB. Эта проблема может быть решена с помощью библиотеки fakeIndexedDB, которая предоставляет реализацию API IndexedDB, располагается в оперативной памяти и от браузерной среды не зависит.
Ставим fakeIndexedDB:
> npm i -D fake-indexeddb
Создаём папку src/tests/
и в ней файл src/tests/setup.ts
, в котором объявляем фейковую базу и её настройки глобальными объектами:
// src/tests/setup.ts
import { indexedDB, IDBKeyRange } from 'fake-indexeddb'
// Объявляем fake-indexeddb как глобальный объект
global.indexedDB = indexedDB
global.IDBKeyRange = IDBKeyRange
В файле настроек vitest.config.ts
зарегистрируем файл src/tests/setup.ts
:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
// Добавим эту строку:
setupFiles: ['src/tests/setup.ts'],
},
})
Интеграционный тест src/App.spec.ts
не должен упасть после смены LocalStorage на IndexedDB, т. к. мы нигде в нём не полагались на конкретную реализацию Store. Чтобы это проверить, для наглядности временно исключим из тестирования файл src/components/SettingsForm.spec.ts
, переименовав его на src/components/SettingsForm._.ts
, чтобы он нам не мешал, и запустим тесты:
> npm run test
Тест действительно не падает. Значит fakeIndexedDB работает правильно.

Теперь напишем модульный тест для базы данных src/db.ts и разместим его в файле src/db.spec.ts:
// src/db.spec.ts
import { describe, it, expect, beforeAll } from 'vitest'
import { SettingsDatabase, dbsInit, dbsDefaultSettings } from './db'
describe('SettingsDatabase', () => {
let db: SettingsDatabase
beforeAll(async () => {
// Создаём новую базу данных
db = new SettingsDatabase()
await db.open()
await db.settings.clear() // Очищаем таблицу
})
it('Инициализируем базы данных', async () => {
// Проверяем, что база данных пуста
const initialSettings = await db.settings.get(1)
expect(initialSettings).toBeUndefined()
// Инициализируем базу данных
await dbsInit()
// Проверяем, что начальные настройки добавлены
const settings = await db.settings.get(1)
expect(settings).toEqual(dbsDefaultSettings)
await db.settings.clear() // Очищаем таблицу после теста
})
it('Обновляем тему', async () => {
// Инициализируем базу данных
await dbsInit()
// Обновляем тему
await db.settings.update(1, { theme: 'dark' })
// Проверяем, что тема обновилась
const updatedSettings = await db.settings.get(1)
expect(updatedSettings?.theme).toBe('dark')
await db.settings.clear() // Очищаем таблицу после теста
})
it('Инициализируем базу данных, когда она уже используется', async () => {
// Инициализируем базу данных
await dbsInit()
// Проверяем, что начальные настройки добавлены
const initialSettings = await db.settings.get(1)
expect(initialSettings).toEqual(dbsDefaultSettings)
// Меняем значение темы
await db.settings.update(1, { theme: 'dark' })
// Пытаемся инициализировать базу данных ещё раз
await dbsInit()
// Проверяем, что записанная ранее тема не затёрта
const settings = await db.settings.get(1)
expect(settings?.theme).toEqual('dark')
})
})
Запускаем тестирование:
> npm run test
Убеждаемся, что тесты не падают.

Теперь переименуем файл src/components/SettingsForm._.ts обратно в src/components/SettingsForm.spec.ts и дадим ему следующее содержимое:
// src/components/SettingsForm.spec.ts
import { expect, describe, beforeEach, afterEach, it } from 'vitest'
import { render, fireEvent, cleanup } from '@testing-library/vue'
import SettingsForm from './SettingsForm.vue'
import { SettingsDatabase, dbsInit } from '../db.ts'
const waitForDbUpdate = async (db: SettingsDatabase) => {
await db.transaction('rw', db.settings, () => { })
}
describe('SettingsForm.vue', () => {
let sdb: SettingsDatabase
beforeEach(async () => {
// Создаем новую базу данных для каждого теста
sdb = new SettingsDatabase()
// Инициализируем базу данных с настройками по умолчанию
await dbsInit()
})
afterEach(async () => {
// Закрываем базу данных после каждого теста
if (sdb && sdb.isOpen()) {
sdb.close()
}
// Очищаем DOM после каждого теста
cleanup()
})
it('Проверяем, что подписи тем отображаются правильно', async () => {
const screen = render(SettingsForm)
// Проверяем, что компонент отображает текст "Тема:"
expect(screen.getByText('Тема:')).toBeTruthy()
// Находим подпись для тёмной темы
const darkThemeLabelElements = screen.getAllByText(/тёмная/)
expect(darkThemeLabelElements.length).toBe(1)
// Находим подпись для светлой темы
const lightThemeLabelElements = screen.getAllByText(/светлая/)
expect(lightThemeLabelElements.length).toBe(1)
// Проверяем, что тема по умолчанию — светлая
const settings = await sdb.settings.get(1)
expect(settings?.theme).toBe('light')
})
it('Изменение темы при клике на подписи', async () => {
const { getByText } = render(SettingsForm)
// Нажимаем на кнопку "тёмная"
fireEvent.click(getByText('тёмная'))
await waitForDbUpdate(sdb)
// Проверяем, что тема изменилась на "dark"
let settings = await sdb.settings.get(1)
expect(settings?.theme).toBe('dark')
// Нажимаем на кнопку "светлая"
fireEvent.click(getByText('светлая'))
await waitForDbUpdate(sdb)
// Проверяем, что тема изменилась на "light"
settings = await sdb.settings.get(1)
expect(settings?.theme).toBe('light')
})
})
Для того, чтобы отследить момент, когда закончится асинхронная операция обновления базы данных, мы всякий раз при ожидании изменений в базе данных с помощью функции waitForDbUpdate()
открываем транзакцию и ждём её завершения. Код функции под транзакцией оставляем пустым, но указываем таблицу. Изменения в этой таблице запущены со стороны, и наша пустая транзакция ждёт, когда они завершатся, чтобы отработать свой код (пустой) а потом завершиться самой.
Обратим внимание на то, что вызов fireEvent()
перед waitForDbUpdate()
идёт без await
, т. к. нам в этом случае не нужно ждать перестройки DOM.
// src/components/SettingsForm.spec.ts
...
const waitForDbUpdate = async (db: SettingsDatabase) => {
await db.transaction('rw', db.settings, () => { })
}
...
Убеждаемся, что тесты не упали.

Теперь, написав тесты, мы можем видеть, что, сложность тестирования варианта построения Store на основе IndexedDB не принципиально выше, чем сложность тестирования традиционного варианта, работающего с обычным Store. Основное отличие в том, после действий, вносящих изменения в базу данных, нужно запустить пустую транзакцию и ждать её окончания по await.
Выводы
Итак, мы собрали три варианта управления состоянием приложения. Варианты построения Store на браузерном хранилище — LocalStorage или на IndexedDB — обладают определёнными преимуществами по сравнению с традиционными решениями. Они обеспечивают сохранение данных после перезагрузки или закрытия и открытия страницы, а также обмен данными между разными окнами браузера.
Вариант с построением Store на LocalStorage более простой, как по коду, обеспечивающему функциональность, так и по тестированию.
Вариант с использованием для Store IndexedDB несколько более сложный, хотя и не слишком. При работе со Store он привносит асинхронность. Использование этого варианта оправдано, когда в приложении мы имеем дело с моделью, содержащей объёмные списки с поиском по ним и с другими задачами, традиционно решаемыми средствами базы данных.
P.S.
В настоящий момент ищу место в команде, которая занимается интересным проектом на Vue.