Введение: в поисках вариантов

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

В своем исследовании я нашел следующие варианты

  • Локальное хранилище
  • Кэш приложения
  • Веб-SQL
  • IndexedDB

Из этих четырех мне пришлось отказаться от локального хранилища, потому что это просто механизм хранения «ключ-значение» и поэтому он не подходил для приложения, которому необходимы функциональные возможности базы данных. Application Cache и Web SQL - устаревшие API, и я не хотел рисковать, используя их в приложении только для того, чтобы их удалить.

Так что вышел из IndexedDB. Давайте углубимся в этот API и посмотрим, что мы можем с ним сделать.

Начиная с IndexedDB

IndexedDB - это система асинхронных транзакционных баз данных, разработанная специально для хранения больших объемов структурированного контента. И это здорово, поскольку офлайн-приложению нужно будет хранить все в браузере. И в зависимости от приложения мы можем хранить не только текст, но и большие файлы (например, PDF-файлы, изображения).

Однако IndexedDB хранит данные не в табличном столбцовом формате, таком как SQL, а в хранилищах объектов, в которых размещаются объекты JavaScript, индексируемые по ключу. Это означает, что вам не нужно беспокоиться о создании предопределенной схемы, как это было бы для MySQL, хотя вам все равно нужно подумать о том, как вы хотите хранить и структурировать свои данные, чтобы их можно было эффективно извлекать из базы данных.

Итак, давайте создадим небольшую базу данных для хранения счетов. Классы объектов, которые мы можем захотеть сохранить, могут быть следующими:

  • счета (для метаданных)
  • счета-фактуры (для отдельных позиций в счете-фактуре)
  • инвойс-приложения (для сканов инвойса, коносамента и т. д.)

А теперь давайте создадим его!

Реализация IndexedDB

Чтобы впервые начать работу с IndexedDB, нам нужно запустить следующий код:

const request = window.indexedDB.open("database", 1);
// Create schema
request.onupgradeneeded = event => {
    const db = event.target.result;
    
    const invoiceStore = db.createObjectStore(
        "invoices",
        { keyPath: "invoiceId" }
    );
    invoiceStore.createIndex("VendorIndex", "vendor");
    const itemStore = db.createObjectStore(
        "invoice-items",
        { keyPath: [ "invoiceId", "row" ] }
    );
    itemStore.createIndex("InvoiceIndex", "invoiceId");
    const fileStore = db.createObjectStore(
        "attachments",
        { autoIncrement: true }
    );
    fileStore.createIndex("InvoiceIndex", "invoiceId");    
};

В первой строке мы запускаем запрос на открытие версии 1 базы данных с именем database. Поскольку это первая загрузка базы данных, браузер не найдет базы данных с таким именем, поэтому запрос вызовет событие onupgradeneeded. Мы хотим создать обработчик событий для обработки нашей логики создания новой базы данных.

Мы получаем результат запущенного события, которое представляет собой объект, представляющий соединение с базой данных, и сохраняем его в переменной с именем db. Затем мы создаем три хранилища объектов и три индекса внутри этой базы данных.

Каждый вызов .createObjectStore() принимает два параметра: имя и необязательный объект параметров. Мы создаем магазины под названием invoices, invoice-items и attachments. Однако мы передаем разные объекты параметров для каждого из них, поэтому давайте рассмотрим каждый из них.

В первом мы просто говорим, что в каждом объекте, который мы будем хранить, есть свойство с именем invoiceId, и что база данных должна связать это свойство с самим объектом (так что, когда мы ищем объект, нам просто нужно сказать « получить до invoiceId). Однако ключевые пути могут быть составными, поэтому во втором мы, что invoiceId и row будут служить ключом для получения объекта. И, наконец, для последнего хранилища, передавая только autoincrement: true, мы говорим, что ключевого пути нет, и база данных должна сгенерировать его для нас.

Каждое хранилище объектов может дополнительно иметь индекс, созданный для одного или нескольких свойств, что позволяет использовать дополнительный способ извлечения объектов, чем использование пути ключа. Каждый .createIndex() вызов принимает имя индекса и то, какие свойства должны быть проиндексированы.

И последнее, на что следует обратить внимание: если пользователь повторно посещает сайт и выполняет тот же запрос на соединение, событие onupgradeneeded не сработает, если версия базы данных не будет увеличена до более высокого целого числа (в нашем случае 2 или выше).

Реализация операций CRUD

Процесс для всех операций CRUD:

  • открыть соединение с базой данных
  • инициировать транзакцию
  • указать, какое хранилище объектов использовать
  • выполнить действие в этом магазине
  • убирать

Создавать

Чтобы создать новый счет в базе данных, мы должны сделать следующее:

const request = window.indexedDB.open("database", 1);
request.onsuccess = () => {
    const db = request.result;
    const transaction = db.transaction(
        [ "invoices", "invoice-items" ],
        "readwrite"
    );
    const invStore = transaction.objectStore("invoices");
    const itemStore = transaction.objectStore("invoice-items");
  
    // Add data
    invStore.add(
        { invoiceId: "123", vendor: "Whirlpool", paid: false }
    );
    itemStore.add({
        invoiceId: "123",
        row: "1",
        item: "Dish washer",
        cost: 1400
    });
    itemStore.add({
        invoiceId: "123",
        row: "2",
        item: "Labor",
        cost: 500
    });
    // Clean up: close connection
    transaction.oncomplete = () => {
        db.close();
    };
};

Если запрос на открытие базы данных выполнен успешно, обработчик событий onsuccess выполнит транзакции. Сначала мы назначаем базу данных переменной db, затем открываем транзакцию в этой базе данных, указывая, что это readwrite операция. Первый параметр db.transaction() - это список хранилищ объектов, которые мы хотим, чтобы транзакция могла видеть. В нашем случае, поскольку существует два типа данных для представления счета-фактуры, мы используем магазины invoices и invoice-items.

Затем мы обращаемся к каждому хранилищу объектов и .add() данным. После успешного завершения транзакции мы закрываем соединение с нашей базой данных.

Обновлять

Для обновления мы выполняем те же шаги открытия (подключение к базе данных, создание транзакции и доступ к хранилищу объектов). Допустим, мы хотим пересмотреть стоимость позиции для посудомоечной машины:

// ...open database, initiate transaction
const itemStore = objectStore.put({
    invoiceId: "123",
    row: "1",
    item: "Dish washer",
    cost: 1300
});
// ...clean up

Эта операция определит ключевой путь (согласно определению нашей схемы выше) и обновит этот объект. Сам объект заменяется объектом, указанным в .put(), поэтому убедитесь, что вы включили все исходные поля и исходные данные, чтобы документ не потерял никакой информации.

Для хранилищ объектов, в которых явно определены пути ключей, нет необходимости добавлять ключ в качестве второго параметра. Нам нужно указать ключ только в том случае, если база данных автоматически сгенерирует его. Итак, если бы мы хотели обновить отсканированный файл в нашем хранилище attachments, мы бы хотели выполнить

attachmentStore.put({ new data}, KEY_NUMBER);

И последнее о .put(): он выполняет двойную функцию. Если бы у нас не было строки для счета 123 и строки 1 в нашем хранилище объектов, этот метод создал бы ее для нас (действуя как .add() выше).

Удалить

Если мы хотим удалить вторую строку в нашем счете-фактуре, все, что нам нужно сделать, это указать желаемый ключ, который мы хотим удалить из хранилища invoice-items:

itemStore.delete([ "123", "2" ]);

Читать

Чтение данных из базы данных меньше всего похоже на остальные три операции по форме в том смысле, что для того, чтобы данные можно было использовать, мы должны реализовать другой обработчик событий. Давайте получим счет, который мы добавили в нашу базу данных ранее:

// ...open database, initiate transaction
const getRequest = invStore.get("123");
getRequest.onsuccess = () => {
    // Do something with the data
    console.log(getRequest.result);
};

Чтобы получить данные, возвращаемые из нашего хранилища объектов, мы должны создать обработчик событий onsuccess. Это событие возникает после того, как данные были извлечены из базы данных и готовы к обработке. Необходимо использовать обработчик событий из-за асинхронности транзакции; мы не хотим, чтобы в последующих строках кода предполагалось, что данные готовы, когда их еще нет.

Как мы можем справиться с этой ситуацией? Мы могли бы создать переменную во внешней области видимости обработчика onsuccess:

let data;
const getRequest = invStore.get("123");
getRequest.onsuccess = () => {
    data= getRequest.result;
};

Нам все еще нужно беспокоиться о том, что getData может не иметь назначенных ему данных, прежде чем они будут использованы позже при вызове функции. Так что, хотя это работает, это не идеально.

Я справляюсь с этим, создавая функцию-оболочку вокруг логики запроса на получение, которая выполняет обратный вызов в обработчике onsuccess.

const getFromDB(key, callbackFn) {
    const request = window.indexedDB.open("database", 1);
    request.onsuccess = () => {
        const db = request.result;
        const transaction = db.transaction("invoices", "readwrite");
        const invStore = transaction.objectStore("invoices");
        const get = invStore.get(key);
        get.onsuccess = () => {
            callbackFn(get.result);
        };
    };
};

Таким образом, вы можете инкапсулировать всю бизнес-логику, необходимую для обработки этих данных, и быть уверенным, что вы будете обрабатывать ее должным образом из-за асинхронного характера транзакций IndexedDB.

Есть еще одна вещь, о которой нужно сказать о чтении данных из базы данных IndexedDB: мы также можем сделать это, используя любые индексы, которые мы создали в хранилище объектов.

Например, если мы хотим получить список всех счетов от определенного поставщика, мы можем запросить индекс:

// ...open database, initiate transaction
const invStore = transaction.objectStore("invoices");
const invIndex = invStore.index("VendorIndex");
const getRequest = invIndex.getAll("Whirlpool");
getRequest.onsuccess = () => {
    // Do something with the data
    console.log(getRequest.result);
};

Мы просто обращаемся к индексу, объявленному в нашем хранилище объектов, и запускаем .getAll("Whirlpool"), чтобы получить все счета из Whirlpool. Мы также могли бы использовать .get("Whirlpool"), но это вернет только одно найденное совпадение.

Заключение

У нас было краткое введение в инициализацию новой базы данных и выполнение с ней основных операций CRUD. IndexedDB - это гораздо больше, чем может дать эта статья (просто просмотр документации MDN показывает, что с ней связано гораздо больше), и по мере того, как я узнаю больше, я надеюсь написать о них дополнительные статьи. Я оставлю эту суть в качестве полного примера базового использования IndexedDB.