Как использовать 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="https://habr.com/ru/post/569376/./css/main.css" />
<script type="module" src="https://habr.com/ru/post/569376/./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 неуклюжий. Это не очевидный выбор для управления состоянием, но индексированная база данных и большой объем хранилища могут сделать ее хорошим вариантом для сложных проектов, в которых хранятся значительные объемы данных.

Источник 📢