Кому нужен Redux, если вы можете написать свой собственный менеджер состояний с помощью vanilla JS?
В моей предыдущей статье Начало работы с IndexedDB для хранения больших данных было показано, как использовать базу данных IndexedDB NoSQL браузера для хранения данных. IndexedDB имеет хорошую кросс-браузерную поддержку и предлагает не менее 1 ГБ дискового пространства.
В этой статье объясняется, как использовать IndexedDB для хранения состояния в типичном клиентском приложении JavaScript.
Код доступен на Github. Он предоставляет пример приложения, которое вы можете использовать или адаптировать для своих собственных проектов.
Что мы подразумеваем под словом «государство»?
Все приложения сохраняют состояние. Для приложения списка дел это список элементов. Для игры это текущий счет, доступное оружие, оставшееся время включения и т. д. Переменные хранят состояние, но по мере увеличения сложности они могут становиться громоздкими.
Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на изменение событий. Например, когда пользователь переключает светлый/темный режим, все компоненты соответствующим образом обновляют свои стили.
Большинство систем управления состоянием хранят значения в памяти, хотя доступны методы и плагины для передачи данных в localStorage, файлы cookie и т. д.
Подходит ли IndexedDB для хранения состояния?
Как всегда: это зависит.
IndexedDB предлагает некоторые преимущества:
- Обычно он может хранить 1 ГБ данных, что делает его подходящим для больших объектов, файлов, изображений и т. д. Перемещение этих элементов из памяти может сделать приложение более быстрым и эффективным.
- В отличие от файлов cookie и веб-хранилища (
localStorage
иsessionStorage
), IndexedDB хранит собственные данные объектов JavaScript. Нет необходимости сериализовать в строки JSON или снова десериализовать. - Доступ к IndexedDB является асинхронным, поэтому он оказывает минимальное влияние на основной поток обработки JavaScript.
Обратите внимание, что веб-хранилище является синхронным:ваш код JavaScript приостанавливает выполнение при доступе к данным. Это может вызвать проблемы с производительностью при сохранении больших наборов данных.
Асинхронный доступ к данным имеет некоторые недостатки:
- API IndexedDB использует более старые методы обратного вызова и обработки событий, поэтому библиотека-оболочка на основе Promise является практичной.
async
конструкторы классов и обработчики получения/установки Proxy невозможны в JavaScript. Это создает некоторые проблемы для систем государственного управления.
Создание системы управления состоянием на основе IndexedDB
Пример кода ниже реализует простую систему управления State
в 35 строках JavaScript. Он предлагает следующие функции:
- Вы можете определить состояние с помощью
name
(строка) иvalue
(примитив, массив, объект и т. д.). Хранилище объектов IndexedDB сохраняет эти значения, используя имя в качестве индекса. - Любой компонент JavaScript может
set
илиget
значение по имени. - Когда значение равно
set
, менеджер состояния уведомляет все подписанные компоненты об изменении. Компонент подписывается через конструкторState
или путем установки или получения именованного значения.
Проект списка дел демонстрирует менеджера State
. Он определяет два веб-компонента, каждый из которых обращается к одному и тому же todolist
массиву задач, управляемому State
объектами:
todo-list.js
: отображаетtodolist
HTML и удаляет элемент, когда пользователь нажимает кнопку "Готово".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 обрабатывает все операции в транзакции, которые запускают события, разрешающие или отклоняющие промис:
// 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 — единственная доступная в настоящее время альтернатива с открытым исходным кодом.
Удачной отладки, для современных фронтенд-команд — Начните бесплатно отслеживать свое веб-приложение.
Создание класса менеджера состояния
Сценарий js/lib/state.js
импортирует IndexedDB
и определяет класс State
. Он разделяет пять значений свойства static
для всех экземпляров:
dbName
: имя базы данных IndexedDB, используемой для хранения состояния ("stateDB"
)dbVersion
: номер версии базы данных (1
)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
, вызываемые при изменении состояния. Он запускает функцию updateCallback
, когда переданное name
равно observed
.
// 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
в список observed
, подключается к базе данных IndexedDB, устанавливает новое значение и запускает set
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
в список 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
.
./js/main.js
загружает оба модуля компонентов:
Сценарии определяют бескаркасные веб-компоненты, которые получают и устанавливают общее состояние todolist
. Веб-компоненты выходят за рамки этой статьи, но основы таковы:
- Вы можете определить пользовательский элемент HTML (например,
<todo-list>
). Имя должно содержать тире (-
), чтобы избежать конфликтов с текущими или будущими элементами HTML. - Класс JavaScript, который
extends
HTMLElement определяет функциональность. Конструктор должен вызватьsuper()
. - Браузер вызывает метод
connectedCallback()
, когда он готов обновить DOM. Метод может добавлять содержимое, при желании используя инкапсулированный Shadow DOM, недоступный для других скриптов. - customElements.define регистрирует класс с пользовательским элементом.
Компонент <todo-list>
./js/components/todo-list.js
определяет класс TodoList
для компонента <todo-list>
. Он показывает список задач и обрабатывает удаление, когда пользователь нажимает кнопку «Готово». Класс устанавливает статические строки HTML и создает новый объект State
. Это отслеживает переменную a 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
(поступит только todolist
). Он сохраняет список как свойство локального объекта, а затем добавляет 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
для компонента <todo-add>
. Он показывает форму, которая может добавлять новые задачи в состояние todolist
. Он устанавливает статическую строку HTML и создает новый объект State
. Это отслеживает состояние 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
в локальное свойство, которое по умолчанию равно пустому массиву - добавляет форму 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(); }); }
Затем класс TodoAdd
регистрируется для компонента <todo-add>
:
} // register component customElements.define( 'todo-add', TodoAdd );
Вывод
Проекты часто избегают IndexedDB, потому что его API неуклюж. Это не самый очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать его хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.
Первоначально опубликовано на https://blog.openreplay.com 8 июля 2021 г.