Как использовать IndexDB для управления состоянием в JavaScript

Kate

Administrator
Команда форума
Эта статья — перевод оригинальной статьи 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. Обычно он может хранить 1 ГБ данных, что делает его подходящим для больших объектов, файлов, изображений и т. д. Перемещение этих элементов из памяти может сделать приложение более быстрым и эффективным.
  2. В отличие от cookie и веб-хранилища (localStorage и sessionStorage), IndexedDB хранит данные в виде нативных объектов JavaScript. Нет необходимости сериализовать в строки JSON и потом снова парсить в объект.
  3. Доступ к 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. Он разделяет пять значений статических свойств для всех экземпляров:

  1. dbName: имя базы данных IndexedDB, используемой для хранения состояний («stateDB»).
  2. dbVersion: номер версии базы данных (1)
  3. storeName: имя хранилища объектов, которое используется для хранения всех пар имя/значение («состояние»).
  4. БД: ссылка на объект IndexedDB, используемый для доступа к базе данных
  5. 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 с использованием управления состоянием​

Простое приложение со списком дел демонстрирует систему управления состоянием:

26689b7ad136214070037573e9d4c6db.png

В файле 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/post/569376/
 
Сверху