
Эта статья — перевод оригинальной статьи 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 неуклюжий. Это не очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать ее хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.
ссылка на оригинал статьи https://habr.com/ru/articles/569376/
Добавить комментарий