От автора: в этой статье объясняется, как использовать IndexedDB для хранения состояния в типичном клиентском приложении JavaScript.
Код доступен на Github. Он предоставляет пример приложения для списка задач, которое вы можете использовать или адаптировать для своих собственных проектов.
Что мы подразумеваем под «состоянием»?
Все приложения хранят состояние. Для приложения со списком задач — это список этих самых задач. Для игры — это текущий счет, доступное оружие, оставшееся время и т. д. Переменные сохраняют состояние, но они могут становиться громоздкими по мере увеличения сложности.
Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на события изменений. Например, когда пользователь переключает светлый/темный режим, все компоненты соответственно обновляют свои стили.
Большинство систем управления состоянием хранят значения в памяти, хотя доступны методы и плагины для передачи данных в localStorage, файлы cookie и т. д.
Подходит ли IndexedDB для хранения состояния?
Как всегда: зависит от обстоятельств. IndexedDB предлагает некоторые преимущества:
Обычно она может хранить 1 ГБ данных, что делает ее подходящей для больших объектов, файлов, изображений и т. д. Получение этих элементов из памяти может сделать приложение более быстрым и эффективным.
В отличие от файлов cookie и веб-хранилища (localStorage и sessionStorage), IndexedDB хранит данные нативных объектов JavaScript. Нет необходимости сериализовать в JSON или снова десериализовать.
Доступ к IndexedDB является асинхронным, поэтому он оказывает минимальное влияние на основной поток обработки JavaScript.
Обратите внимание, что веб-хранилище является синхронным: ваш код JavaScript приостанавливает выполнение, пока он обращается к данным. Это может вызвать проблемы с производительностью при сохранении больших наборов данных.
Асинхронный доступ к данным имеет ряд недостатков:
API IndexedDB использует более старые методы обратного вызова и поэтому библиотека-оболочка на основе рromise является практичней.
Конструкторы класса аsync и обработчики геттеров и сеттеров для Proxy невозможны в JavaScript. Это создает некоторые проблемы для систем управления состоянием.
Создание системы управления состоянием на основе IndexedDB
В приведенном ниже примере кода реализована простая система управления состоянием в 35 строках JavaScript. Она предлагает следующие функции:
Вы можете определить состояние с помощью name(строка) и value(примитив, массив, объект и т. д.). Хранилище объектов IndexedDB сохраняет эти значения, используя имя в качестве индекса.
Любой компонент JavaScript может установить или получить значение по имени.
Когда устанавливается значение, диспетчер состояний предупреждает все подписанные компоненты об изменении. Компонент подписывается через конструктор State или путем установки или получения соответствующего значения.
Проект списка дел демонстрирует менеджер состояния. Он определяет два веб-компонента, которые обращаются к одному и тому же массиву задач todolist, управляемых объектами State:
todo-list.js: отображает todolistHTML и удаляет элемент, когда пользователь нажимает кнопку «Готово».
todo-add.js: показывает форму «добавить новый элемент», которая добавляет новые задачи в массив todolist.
Примечание. Один компонент списка задач был бы более практичным, но в нашем примере демонстрируется, как два изолированных класса могут совместно использовать одно и то же состояние.
Создание класса-оболочки IndexedDB
В статье «Начало работы» представлена оболочка IndexedDB на основе Promise. Нам нужен аналогичный класс, но он может быть проще, потому что он извлекает отдельные записи name.
Скрипт js/lib/indexeddb.js определяет класс IndexedDB с конструктором. Он принимает имя базы данных, версию и функцию обновления и возвращает созданный объект после успешного подключения к базе данных IndexedDB:
// IndexedDB wrapper class export class IndexedDB { // connect to IndexedDB database constructor(dbName, dbVersion, dbUpgrade) { return new Promise((resolve, reject) => { // connection object this.db = null; // no support if (!('indexedDB' in window)) reject('not supported'); // open database const dbOpen = indexedDB.open(dbName, dbVersion); if (dbUpgrade) { // database upgrade event 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 сохраняет value с идентификатором name в хранилище объектов storeName. IndexedDB обрабатывает все операции в транзакции, которая запускает события, разрешающие или отклоняющие Promise:
// store item set(storeName, name, value) { return new Promise((resolve, reject) => { // new transaction const transaction = this.db.transaction(storeName, 'readwrite'), store = transaction.objectStore(storeName); // write record store.put(value, name); transaction.oncomplete = () => { resolve(true); // success }; transaction.onerror = () => { reject(transaction.error); // failure }; }); }
Точно так же асинхронный метод get извлекает value с идентификатором name из хранилища объектов storeName:
// get named item get(storeName, name) { return new Promise((resolve, reject) => { // new transaction const transaction = this.db.transaction(storeName, 'readonly'), store = transaction.objectStore(storeName), // read record request = store.get(name); request.onsuccess = () => { resolve(request.result); // success }; request.onerror = () => { reject(request.error); // failure }; }); } }
Воспроизведение сеанса пользователя
Независимо от того, используете ли вы React, Vue или просто vanillaJS, отладка веб-приложения в рабочей среде может быть сложной и трудоемкой. OpenReplay — это альтернатива с открытым исходным кодом для FullStory, LogRocket и Hotjar. Он позволяет отслеживать и воспроизводить все, что делают ваши пользователи, и показывает, как ваше приложение ведет себя при каждой проблеме. Это похоже на то, как если бы инструмент для веб-разработчика вашего браузера был открыт, когда вы смотрите через плечо пользователя. OpenReplay — единственная доступная альтернатива с открытым исходным кодом.
Создание класса State
Сценарий js/lib/state.js импортирует IndexedDB и определяет класс State. В классе обьявляются пять статических значений свойств для экземпляров:
dbName: имя базы данных IndexedDB, используемой для хранения состояний ( «stateDB»)
dbVersion: номер версии базы данных
storeName: имя хранилища объектов, в котором хранятся все пары имя / значение ( «state»)
DB: ссылка на объект IndexedDB, используемый для доступа к базе данных
target: объект EventTarget (), который может отправлять и получать события для всех объектов State.
// simple state handler import { IndexedDB } from './indexeddb.js'; export class State { static dbName = 'stateDB'; static dbVersion = 1; static storeName = 'state'; static DB = null; static target = new EventTarget();
Конструктор принимает два необязательных параметра:
массив имен observed
функцию updateCallback. Эта функция получает name и value всякий раз, когда обновляется состояние.
Обработчик отслеживает события set, вызываемые при изменении состояния.
// object constructor constructor(observed, updateCallback) { // state change callback this.updateCallback = updateCallback; // observed properties this.observed = new Set(observed); // subscribe to set events 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):
// connect to IndexedDB database async dbConnect() { State.DB = State.DB || await new IndexedDB( State.dbName, State.dbVersion, (db, oldVersion, newVersion) => { // upgrade database switch (oldVersion) { case 0: { db.createObjectStore( State.storeName ); } } }); return State.DB; }
Асинхронный метод set обновляет значение переменной name. Он добавляет name к списку observed, подключается к базе данных IndexedDB, устанавливает новое значение, и объявляет CustomEvent, которое получают все объекты State:
// set value in DB async set(name, value) { // add observed property this.observed.add(name); // database update const db = await this.dbConnect(); await db.set( State.storeName, name, value ); // raise event const event = new CustomEvent('set', { detail: { name, value } }); State.target.dispatchEvent(event); }
Асинхронный метод get возвращает значение name. Он добавляет name к списку observed, подключается к базе данных IndexedDB и извлекает индексированные данные:
// get value from DB async get(name) { // add observed property this.observed.add(name); // database fetch const db = await this.dbConnect(); return await db.get( State.storeName, name ); } }
Вы можете получать и обновлять значения состояния, используя новый объект State, например:
import { State } from './state.js'; (async () => { // instantiate const state = new State([], stateUpdated); // get latest value and default to zero let myval = await state.get('myval') || 0; // set a new state value await state.set('myval', myval + 1); // callback runs when myval updates 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>
<todo-list>- список задач, управляемый ./js/components/todo-list.js, который обновляет список при добавлении и удалении задач,
<todo-add>- форма для добавления элементов в управляемый список задач ./js/components/todo-list.js.
Cценарий ./js/main.js загружает оба компонентных модуля:
// load components import './components/todo-add.js'; import './components/todo-list.js';
Эти сценарии определяют веб-компоненты, которые получают и устанавливают общее состояние todolist. Веб-компоненты выходят за рамки данной статьи, но основные моменты:
Вы можете определить собственный HTML-элемент (например, todo-list). Имя должно содержать дефис (-), чтобы избежать конфликтов с текущими или будущими элементами HTML.
Класс JavaScript который наследуется от HTMLElement, определяет функционал. Конструктор должен вызвать super().
Браузер вызывает метод connectedCallback(), когда он готов обновить DOM. Метод может добавлять контент, при необходимости используя инкапсулированную теневую DOM, недоступную для других скриптов.
customElements.define регистрирует класс с пользовательским элементом.
Компонент todo-list
Сценарий ./js/components/todo-list.js определяет класс TodoList для компонента todo-list. Он показывает список задач и обрабатывает удаление, когда пользователь нажимает кнопку «Готово». Класс устанавливает статические строки 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() получает обновленные name и value. Он сохраняет список как свойство локального объекта, а затем добавляет HTML в Shadow DOM (созданный методом connectedCallback()):
// show todo list render(name, value) { // update state this[name] = value; // create new list 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(),
присоединяет обработчик события клика, который удаляет элемент из todolist. Метод render() будет выполняться автоматически, так как состояние изменилось.
// initialise async connectedCallback() { this.shadow = this.attachShadow({ mode: 'closed' }); this.render('todolist', await this.state.get('todolist') || []); // remove item event 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:
// register component customElements.define( 'todo-list', TodoList );
Компонент todo-add
Сценарий ./js/components/todo-add.js определяет класс TodoAdd для
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() cработает, когда будет готова DOM. Он:
извлекает последнее состояние todolist в локальное свойство, которое по умолчанию является пустым массивом
добавляет HTML-форму в Shadow DOM
присоединяет обработчик события, который добавляет в состояние новый элемент todolist (который, в свою очередь, обновляет todo-list компонент). Затем он очищает поле ввода, чтобы вы могли добавить еще одну задачу.
// initialise async connectedCallback() { // get latest todo list 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(); // add item to list await this.state.set('todolist', this.todolist.concat(add.value)); add.value = ''; add.focus(); }); }
Заключение
Проекты часто избегают IndexedDB, потому что его API тяжеловесный. Это не очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать IndexedDB хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.
Автор: Craig Buckler
Источник: blog.openreplay.com
Редакция: Команда webformyself.
Читайте нас в Telegram, VK, Яндекс.Дзен