Кому нужен Redux, если вы можете написать свой собственный менеджер состояний с помощью vanilla JS?

В моей предыдущей статье Начало работы с IndexedDB для хранения больших данных было показано, как использовать базу данных IndexedDB NoSQL браузера для хранения данных. IndexedDB имеет хорошую кросс-браузерную поддержку и предлагает не менее 1 ГБ дискового пространства.

В этой статье объясняется, как использовать IndexedDB для хранения состояния в типичном клиентском приложении JavaScript.

Код доступен на Github. Он предоставляет пример приложения, которое вы можете использовать или адаптировать для своих собственных проектов.

Что мы подразумеваем под словом «государство»?

Все приложения сохраняют состояние. Для приложения списка дел это список элементов. Для игры это текущий счет, доступное оружие, оставшееся время включения и т. д. Переменные хранят состояние, но по мере увеличения сложности они могут становиться громоздкими.

Системы управления состоянием, такие как Redux и Vuex, предоставляют централизованные хранилища данных. Любой компонент JavaScript может читать, обновлять или удалять данные. Некоторые системы позволяют компонентам подписываться на изменение событий. Например, когда пользователь переключает светлый/темный режим, все компоненты соответствующим образом обновляют свои стили.

Большинство систем управления состоянием хранят значения в памяти, хотя доступны методы и плагины для передачи данных в localStorage, файлы cookie и т. д.

Подходит ли IndexedDB для хранения состояния?

Как всегда: это зависит.

IndexedDB предлагает некоторые преимущества:

  1. Обычно он может хранить 1 ГБ данных, что делает его подходящим для больших объектов, файлов, изображений и т. д. Перемещение этих элементов из памяти может сделать приложение более быстрым и эффективным.
  2. В отличие от файлов cookie и веб-хранилища (localStorage и sessionStorage), IndexedDB хранит собственные данные объектов JavaScript. Нет необходимости сериализовать в строки JSON или снова десериализовать.
  3. Доступ к IndexedDB является асинхронным, поэтому он оказывает минимальное влияние на основной поток обработки JavaScript.

Обратите внимание, что веб-хранилище является синхронным:ваш код JavaScript приостанавливает выполнение при доступе к данным. Это может вызвать проблемы с производительностью при сохранении больших наборов данных.

Асинхронный доступ к данным имеет некоторые недостатки:

  • API IndexedDB использует более старые методы обратного вызова и обработки событий, поэтому библиотека-оболочка на основе Promise является практичной.
  • async конструкторы классов и обработчики получения/установки Proxy невозможны в JavaScript. Это создает некоторые проблемы для систем государственного управления.

Создание системы управления состоянием на основе IndexedDB

Пример кода ниже реализует простую систему управления State в 35 строках JavaScript. Он предлагает следующие функции:

  • Вы можете определить состояние с помощью name (строка) и value (примитив, массив, объект и т. д.). Хранилище объектов IndexedDB сохраняет эти значения, используя имя в качестве индекса.
  • Любой компонент JavaScript может set или get значение по имени.
  • Когда значение равно set, менеджер состояния уведомляет все подписанные компоненты об изменении. Компонент подписывается через конструктор State или путем установки или получения именованного значения.

Проект списка дел демонстрирует менеджера State. Он определяет два веб-компонента, каждый из которых обращается к одному и тому же todolist массиву задач, управляемому State объектами:

  1. todo-list.js: отображает todolist HTML и удаляет элемент, когда пользователь нажимает кнопку "Готово".
  2. 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 для всех экземпляров:

  1. dbName: имя базы данных IndexedDB, используемой для хранения состояния ("stateDB")
  2. dbVersion: номер версии базы данных (1)
  3. storeName: имя хранилища объектов, используемое для хранения всех пар имя/значение ("state")
  4. DB: ссылка на один объект IndexedDB, используемый для доступа к базе данных, и
  5. 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();

Конструктор принимает два необязательных параметра:

  1. массив из observed имен и
  2. функция 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. Веб-компоненты выходят за рамки этой статьи, но основы таковы:

  1. Вы можете определить пользовательский элемент HTML (например, <todo-list>). Имя должно содержать тире (-), чтобы избежать конфликтов с текущими или будущими элементами HTML.
  2. Класс JavaScript, который extends HTMLElement определяет функциональность. Конструктор должен вызвать super().
  3. Браузер вызывает метод connectedCallback(), когда он готов обновить DOM. Метод может добавлять содержимое, при желании используя инкапсулированный Shadow DOM, недоступный для других скриптов.
  4. 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 готов. Это:

  1. создает новый Shadow DOM и передает последнее состояние todolist методу render(), и
  2. прикрепляет обработчик события клика, который удаляет элемент из состояния 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 готов. Это:

  1. извлекает последнее состояние todolist в локальное свойство, которое по умолчанию равно пустому массиву
  2. добавляет форму HTML в Shadow DOM и
  3. прикрепляет обработчик события отправки, который добавляет новый элемент в состояние 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 г.