Эта статья — перевод оригинальной статьи Craig Buckler "How to Use IndexDB to Manage State in JavaScript".
Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.
Код доступен на Github. Он представляет собой пример to-do приложения, которое вы можете использовать или адаптировать для своих собственных проектов.
Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на события изменений. Например, когда пользователь переключает светлый/темный режим, все компоненты соответственно обновляют свои стили.
Большинство систем управления состоянием хранят значения в памяти, хотя доступны техники и плагины для передачи данных в localStorage, cookie и т. д.
IndexedDB предлагает некоторые преимущества:
Асинхронный доступ к данным имеет ряд недостатков:
Скрипт 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); // ошибка
};
});
}
}
import { IndexedDB } from './indexeddb.js';
export class State {
static dbName = 'stateDB';
static dbVersion = 1;
static storeName = 'state';
static DB = null;
static target = new EventTarget();
Конструктор принимает два необязательных параметра:
// объект конструктора
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 }!`)
});
В файле 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>
import './components/todo-add.js';
import './components/todo-list.js';
Скрипты создают веб-компоненты без фреймворка, которые получают и устанавливают общее состояние списка задач. Веб-компоненты выходят за рамки этой статьи, но основные моменты:
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 готов.
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 );
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
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 );
Источник статьи: https://habr.com/ru/post/569376/
Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.
Вступление
В этой статье объясняется, как использовать IndexedDB для хранения состояния в типичном клиентском приложении на JavaScript.Код доступен на Github. Он представляет собой пример to-do приложения, которое вы можете использовать или адаптировать для своих собственных проектов.
Что я имею ввиду под "состоянием"?
Все приложения хранят состояние. Для to-do приложения это список задач. Для игры это текущий счет, доступное оружие, время перезарядки и т. д. Переменные сохраняют состояние, но они могут стать слишком большими по мере увеличения сложности.Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на события изменений. Например, когда пользователь переключает светлый/темный режим, все компоненты соответственно обновляют свои стили.
Большинство систем управления состоянием хранят значения в памяти, хотя доступны техники и плагины для передачи данных в localStorage, cookie и т. д.
Подходит ли IndexedDB для хранения состояния?
Как всегда: зависит от обстоятельств.IndexedDB предлагает некоторые преимущества:
- Обычно он может хранить 1 ГБ данных, что делает его подходящим для больших объектов, файлов, изображений и т. д. Перемещение этих элементов из памяти может сделать приложение более быстрым и эффективным.
- В отличие от cookie и веб-хранилища (localStorage и sessionStorage), IndexedDB хранит данные в виде нативных объектов JavaScript. Нет необходимости сериализовать в строки JSON и потом снова парсить в объект.
- Доступ к IndexedDB является асинхронным, поэтому он оказывает минимальное влияние на основной поток обработки JavaScript.
Асинхронный доступ к данным имеет ряд недостатков:
- API IndexedDB использует старый подход с коллбэками и событиями, поэтому библиотека-обёртка на основе промисов будет лучшим решением.
- Асинхронные конструкторы классов и Proxy get/set невозможны в JavaScript. Это создает некоторые проблемы для систем управления состоянием.
Создание системы управления состоянием на основе IndexedDB
В приведенном ниже примере кода реализована простая система управления состоянием в 35 строчек JS кода. Она предлагает следующие функции:- Вы можете определить состояние с помощью имени (строки) и значения (примитив, массив, объект и т. д.). Хранилище объектов IndexedDB сохраняет эти значения, используя имя в качестве индекса.
- Любой компонент JavaScript может устанавливать или получать значение по имени.
- Когда значение установлено, менеджер состояний предупреждает все подписанные компоненты об изменении. Компонент подписывается через конструктор State или путем установки или получения именованного значения.
- todo-list.js: отображает HTML-код списка задач и удаляет элемент, когда пользователь нажимает кнопку «Done».
- todo-add.js: показывает форму «add new item», которая добавляет новые задачи в массив 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. Эта функция получает имя и значение всякий раз, когда обновляется состояние.
// объект конструктора
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.
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() будет выполняться автоматически, поскольку состояние изменилось.
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>). Затем он очищает поле ввода, чтобы вы могли добавить еще одну задачу.
// получить 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/