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

от автора

Эта статья — перевод оригинальной статьи 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 с использованием управления состоянием

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

В файле 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 неуклюжий. Это не очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать ее хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Использовали ли вы IndexedDB в продакшене?

14.47% Да11
85.53% Нет65

Проголосовали 76 пользователей. Воздержались 11 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/569376/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *