
Эта статья — перевод оригинальной статьи Craig Buckler "How to Use IndexDB to Manage State in JavaScript".
Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.
Вступление
В этой статье объясняется, как использовать IndexedDB для хранения состояния в типичном клиентском приложении на JavaScript.
Код доступен на Github. Он представляет собой пример to-do приложения, которое вы можете использовать или адаптировать для своих собственных проектов.
Что я имею ввиду под "состоянием"?
Все приложения хранят состояние. Для to-do приложения это список задач. Для игры это текущий счет, доступное оружие, время перезарядки и т. д. Переменные сохраняют состояние, но они могут стать слишком большими по мере увеличения сложности.
Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на события изменений. Например, когда пользователь переключает светлый/темный режим, все компоненты соответственно обновляют свои стили.
Большинство систем управления состоянием хранят значения в памяти, хотя доступны техники и плагины для передачи данных в localStorage, cookie и т. д.
Подходит ли IndexedDB для хранения состояния?
Как всегда: зависит от обстоятельств.
IndexedDB предлагает некоторые преимущества:
Обычно он может хранить 1 ГБ данных, что делает его подходящим для больших объектов, файлов, изображений и т. д. Перемещение этих элементов из памяти может сделать приложение более быстрым и эффективным.
В отличие от cookie и веб-хранилища (localStorage и sessionStorage), IndexedDB хранит данные в виде нативных объектов JavaScript. Нет необходимости сериализовать в строки JSON и потом снова парсить в объект.
Доступ к IndexedDB является асинхронным, поэтому он оказывает минимальное влияние на основной поток обработки JavaScript.
Обратите внимание, что localStorage и sessionStorage являются синхронными: ваш код JavaScript приостанавливает выполнение, пока он обращается к данным. Это может вызвать проблемы с производительностью при сохранении больших наборов данных.
Асинхронный доступ к данным имеет ряд недостатков:
API IndexedDB использует старый подход с коллбэками и событиями, поэтому библиотека-обёртка на основе промисов будет лучшим решением.
Асинхронные конструкторы классов и Proxy get/set невозможны в JavaScript. Это создает некоторые проблемы для систем управления состоянием.
Создание системы управления состоянием на основе IndexedDB
В приведенном ниже примере кода реализована простая система управления состоянием в 35 строчек JS кода. Она предлагает следующие функции:
Вы можете определить состояние с помощью имени (строки) и значения (примитив, массив, объект и т. д.). Хранилище объектов IndexedDB сохраняет эти значения, используя имя в качестве индекса.
Любой компонент JavaScript может устанавливать или получать значение по имени.
Когда значение установлено, менеджер состояний предупреждает все подписанные компоненты об изменении. Компонент подписывается через конструктор State или путем установки или получения именованного значения.
Приложение to-do демонстрирует управление состоянием. Оно определяет два веб-компонента, которые обращаются к одному и тому же массиву задач, управляемому объектами State:
todo-list.js: отображает HTML-код списка задач и удаляет элемент, когда пользователь нажимает кнопку «Done».
todo-add.js: показывает форму «add new item», которая добавляет новые задачи в массив todolist.
Примечание. Один компонент todolist был бы более практичным, но проект демонстрирует, как два изолированных класса могут совместно использовать одно и то же состояние.
Создание класса-обёртки IndexedDB
В статье «Начало работы с IndexDB» была представлена оболочка IndexedDB на основе Promise. Нам нужен аналогичный класс, но он может быть проще, потому что он выбирает отдельные записи по имени.
Скрипт js/lib/indexeddb.js определяет класс IndexedDB с конструктором. Он принимает имя базы данных, версию и функцию обновления. Он возвращает созданный объект после успешного подключения к базе данных IndexedDB:
// IndexedDB класс-обёртка
export class IndexedDB {
// подключение к IndexedDB
constructor(dbName, dbVersion, dbUpgrade) {
return new Promise((resolve, reject) => {
// объект соединения с базой данных
this.db = null;
// обработка ошибки если браузер не поддерживает indexedDb
if (!('indexedDB' in window)) reject('not supported');
// открывает базу данных
const dbOpen = indexedDB.open(dbName, dbVersion);
if (dbUpgrade) {
// слушаем событие upgrade
dbOpen.onupgradeneeded = e => {
dbUpgrade(dbOpen.result, e.oldVersion, e.newVersion);
};
}
dbOpen.onsuccess = () => {
this.db = dbOpen.result;
resolve( this );
};
dbOpen.onerror = e => {
reject(`IndexedDB error: ${ e.target.errorCode }`);
};
});
}
Асинхронный метод set сохраняет значение с идентификатором имени в хранилище объектов storeName. IndexedDB обрабатывает все операции в транзакции, которая запускает события, разрешающие или отклоняющие обещание:
// сохраняет элемент
set(storeName, name, value) {
return new Promise((resolve, reject) => {
// новая транзакция
const
transaction = this.db.transaction(storeName, 'readwrite'),
store = transaction.objectStore(storeName);
// записываем элемент
store.put(value, name);
transaction.oncomplete = () => {
resolve(true); // успех
};
transaction.onerror = () => {
reject(transaction.error); // ошибка
};
});
}
Точно так же асинхронный метод get извлекает значение с идентификатором имени в хранилище объектов storeName:
// получение значение по имени
get(storeName, name) {
return new Promise((resolve, reject) => {
// новая транзакция
const
transaction = this.db.transaction(storeName, 'readonly'),
store = transaction.objectStore(storeName),
// получить значение
request = store.get(name);
request.onsuccess = () => {
resolve(request.result); // успех
};
request.onerror = () => {
reject(request.error); // ошибка
};
});
}
}
Создание класса управления состоянием
Скрипт js/lib/state.js импортирует IndexedDB и определяет класс State. Он разделяет пять значений статических свойств для всех экземпляров:
dbName: имя базы данных IndexedDB, используемой для хранения состояний («stateDB»).
dbVersion: номер версии базы данных (1)
storeName: имя хранилища объектов, которое используется для хранения всех пар имя/значение («состояние»).
БД: ссылка на объект IndexedDB, используемый для доступа к базе данных
target: объект EventTarget(), который может отправлять и получать события по всем объектам State.
// простой обработчик состояний
import { IndexedDB } from './indexeddb.js';
export class State {
static dbName = 'stateDB';
static dbVersion = 1;
static storeName = 'state';
static DB = null;
static target = new EventTarget();
Конструктор принимает два необязательных параметра:
массив наблюдаемых имен
функцию updateCallback. Эта функция получает имя и значение всякий раз, когда обновляется состояние.
Обработчик прослушивает установленные события, вызываемые при изменении состояния. Он запускает функцию updateCallback, когда переданное имя отслеживается.
// объект конструктора
constructor(observed, updateCallback) {
// колбэк изменения состояния
this.updateCallback = updateCallback;
// наблюдаемые свойства
this.observed = new Set(observed);
// подписка на события set
State.target.addEventListener('set', e => {
if (this.updateCallback && this.observed.has( e.detail.name )) {
this.updateCallback(e.detail.name, e.detail.value);
}
});
}
Класс не подключается к базе данных IndexedDB, пока это не потребуется. Метод dbConnect устанавливает соединение и повторно использует его для всех объектов State. При первом запуске он создает новое хранилище объектов с именем state (как определено в статическом свойстве storeName):
Асинхронный метод set обновляет именованное значение. Он добавляет имя в наблюдаемый список, подключается к базе данных IndexedDB, устанавливает новое значение и запускает набор CustomEvent, который получают все объекты State:
// устанавливает значение по имени
async set(name, value) {
// добавляем наблюдаемое свойство
this.observed.add(name);
// обновляем базу
const db = await this.dbConnect();
await db.set( State.storeName, name, value );
// отправляем соытие
const event = new CustomEvent('set', { detail: { name, value } });
State.target.dispatchEvent(event);
}
Асинхронный метод get возвращает именованное значение. Он добавляет имя в наблюдаемый список, подключается к базе данных IndexedDB и извлекает проиндексированные данные:
// получение данных из базы
async get(name) {
// добавляем наблюдаемое свойство
this.observed.add(name);
// получаем значение
const db = await this.dbConnect();
return await db.get( State.storeName, name );
}
}
Вы можете получать и обновлять значения состояния с помощью нового объекта State
import { State } from './state.js';
(async () => {
// создаём экземпляр
const state = new State([], stateUpdated);
// получаем последнее значение и по умолчанию ноль
let myval = await state.get('myval') || 0;
// устанавливаем новое значение
await state.set('myval', myval + 1);
// колбэк запускается когда myval обновится
function stateUpdated(name, value) {
console.log(`${ name } is now ${ value }`)
}
})()
Другой код может получать уведомления об обновлении состояния для того же элемента:
new State(['myval'], (name, value) => {
console.log(`I also see ${ name } is now set to ${ value }!`)
});
Создаём приложение to-do с использованием управления состоянием
Простое приложение со списком дел демонстрирует систему управления состоянием:

В файле index.html определены два настраиваемых элемента:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>IndexedDB state management to-do list</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="./css/main.css" />
<script type="module" src="./js/main.js"></script>
</head>
<body>
<h1>IndexedDB state management to-do list</h1>
<todo-list></todo-list>
<todo-add></todo-add>
</body>
</html>
<todo-list> - список задач, управляемый ./js/components/todo-list.js, который обновляет список при добавлении и удалении задач
<todo-add> - форма для добавления элементов в список задач, управляемый ./js/components/todo-list.js.
./js/main.js импортирует оба компонента:
import './components/todo-add.js';
import './components/todo-list.js';
Скрипты создают веб-компоненты без фреймворка, которые получают и устанавливают общее состояние списка задач. Веб-компоненты выходят за рамки этой статьи, но основные моменты:
Вы можете определить собственный HTML-элемент (например, ). Имя должно содержать дефис (-), чтобы избежать конфликтов с текущими или будущими элементами HTML.
Это JavaScript класс, расширяющий HTMLElement, определяет функциональность. Конструктор должен вызывать функцию super().
Браузер вызывает метод connectedCallback(), когда готов обновить DOM. Метод может добавлять контент, при необходимости используя инкапсулированный shadow DOM, недоступный для других скриптов.
customElements.define регистрирует класс с настраиваемым элементом.
<todo-list> компонент
./js/components/todo-list.js определяет класс TodoList для компонента . Он показывает список задач и обрабатывает удаление, когда пользователь нажимает кнопку «Done». Класс устанавливает статичные HTML строки и создает новый объект State. Он отслеживает переменную todolist и запускает метод render() объекта при изменении его значения:
import { State } from '../lib/state.js';
class TodoList extends HTMLElement {
static style = `
<style>
ol { padding: 0; margin: 1em 0; }
li { list-style: numeric inside; padding: 0.5em; margin: 0; }
li:hover, li:focus-within { background-color: #eee; }
button { width: 4em; float: right; }
</style>
`;
static template = `<li>$1 <button type="button" value="$2">done</button></li>`;
constructor() {
super();
this.state = new State(['todolist'], this.render.bind(this));
}
Метод render() получает обновленное имя и значение (поступит только todolist). Он сохраняет список как свойство this объекта, а затем добавляет HTML в Shadow DOM (созданный методом connectedCallback()):
// показать to-do лист
render(name, value) {
// обновить состояние
this[name] = value;
// создать новый список
let list = '';
this.todolist.map((v, i) => {
list += TodoList.template.replace('$1', v).replace('$2', i);
});
this.shadow.innerHTML = `${ TodoList.style }<ol>${ list }</ol>`;
}
Метод connectedCallback () запускается, когда DOM готов.
Он создает новый Shadow DOM и передает последнее состояние todolist методу render()
Он присоединяет обработчик события клика, который удаляет элемент из состояния списка задач. Метод render() будет выполняться автоматически, поскольку состояние изменилось.
async connectedCallback() {
this.shadow = this.attachShadow({ mode: 'closed' });
this.render('todolist', await this.state.get('todolist') || []);
// удаляем элемент
this.shadow.addEventListener('click', async e => {
if (e.target.nodeName !== 'BUTTON') return;
this.todolist.splice(e.target.value, 1);
await this.state.set('todolist', this.todolist);
});
}
Затем регистрируем класс TodoList для компонента <todo-list>:
customElements.define( 'todo-list', TodoList );
<todo-add> компонент
./js/components/todo-add.js определяет класс TodoAdd для компонента . Он показывает форму, которая может добавлять новые задачи в состояние списка задач. Он устанавливает статическую строку HTML и создает новый объект State. Это отслеживает состояние списка задач и сохраняет его как свойство this:
class TodoAdd extends HTMLElement {
static template = `
<style>
form { display: flex; justify-content: space-between; padding: 0.5em; }
input { flex: 3 1 10em; font-size: 1em; padding: 6px; }
button { width: 4em; }
</style>
<form method="post">
<input type="text" name="add" placeholder="add new item" required />
<button>add</button>
</form>
`;
constructor() {
super();
this.state = new State(['todolist'], (name, value) => this[name] = value );
}
Метод connectedCallback() запускается, когда DOM готов.
Он загружает последнее состояние todolist в локальное свойство, которое по умолчанию представляет собой пустой массив.
Он добавляет HTML форму в Shadow DOM
Он присоединяет обработчик события отправки формы, который добавляет новый элемент в состояние todolist (который, в свою очередь, обновляет компонент <todo-list>). Затем он очищает поле ввода, чтобы вы могли добавить еще одну задачу.
async connectedCallback() {
// получить todolist
this.todolist = await this.state.get('todolist') || [];
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = TodoAdd.template;
const add = shadow.querySelector('input');
shadow.querySelector('form').addEventListener('submit', async e => {
e.preventDefault();
// добавить элемент в список
await this.state.set('todolist', this.todolist.concat(add.value));
add.value = '';
add.focus();
});
}
Затем регистрируем класс TodoAdd для компонента <todo-add>:
customElements.define( 'todo-add', TodoAdd );
Заключение
Проекты часто избегают IndexedDB, потому что его API неуклюжий. Это не очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать ее хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.