Мощная база данных прямо в вашем браузере

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

Рассмотрим Performance API для определения времени событий сети и страниц. Вы можете загрузить все данные, как только они станут доступны, но это значительный объем информации, который, по иронии судьбы, может сказаться на производительности страницы. Лучшим вариантом было бы хранить его локально, обрабатывать статистику с помощью Web Worker и выгружать результаты, когда браузер менее загружен.

Доступны два кроссбраузерных API хранилища на стороне клиента:

  1. Интернет-хранилище
    Синхронное хранилище пар имя-значение, сохраняемое постоянно (localStore) или для текущего сеанса (sessionStore). Браузеры позволяют использовать до 5 МБ веб-хранилища на домен.
  2. IndexedDB
    Асинхронное хранилище значений имени в стиле NoSQL, которое может сохранять данные, файлы и капли. На каждый домен должен быть доступен не менее 1 ГБ, который может достигать 60% оставшегося дискового пространства.

Другой вариант хранилища, WebSQL, доступен в некоторых редакциях Chrome и Safari. Однако он имеет ограничение в 5 МБ, несовместим и не рекомендуется использовать в 2010 году.

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

Весь пример кода доступен на Github, если вы хотите попробовать IndexedDB на своем собственном сайте.

Что такое IndexedDB?

IndexedDB был впервые реализован в 2011 году и стал стандартом W3C в январе 2015 года. Он имеет хорошую поддержку браузера, хотя его обратный вызов и API на основе событий кажутся неуклюжими, теперь у нас есть ES2015 +. В этой статье показано, как написать оболочку на основе Promise, чтобы можно было использовать цепочку и async / await.

Обратите внимание на следующие термины IndexedDB:

  • база данных - магазин верхнего уровня. Домен может создавать любое количество баз данных IndexedDB, но редко бывает больше одной. Только страницы в одном домене могут получить доступ к базе данных.
  • хранилище объектов - хранилище имен и значений для связанных элементов данных. Это похоже на коллекцию в MongoDB или таблицы в реляционной базе данных.
  • ключ - уникальное имя, используемое для ссылки на каждую запись (значение) в хранилище объектов. Его можно сгенерировать с помощью числа autoIncrement или присвоить любому уникальному значению в записи.
  • index - еще один способ организации данных в хранилище объектов. Поисковые запросы могут проверять только ключ или индекс.
  • схема - определение хранилищ объектов, ключей и индексов.
  • версия - номер версии (целое число), присвоенный схеме. IndexedDB предлагает автоматическое управление версиями, поэтому вы можете обновлять базы данных до последней схемы.
  • операция - действия с базой данных, такие как создание, чтение, обновление или удаление записей.
  • транзакция - набор из одной или нескольких операций. Транзакция гарантирует, что все ее операции будут успешными или неудачными. Он не может подвести одних, а других - нет.
  • курсор - способ перебирать записи без необходимости загружать все сразу в память.

Разработка и отладка базы данных

В этом руководстве вы создадите базу данных IndexedDB с именем performance. Он содержит два хранилища объектов:

  1. навигация
    Здесь хранится информация о времени навигации по страницам (перенаправления, поиск DNS, загрузка страницы, размеры файлов, события загрузки и т. д.). Будет добавлена ​​дата для использования в качестве ключа.
  2. ресурс
    Здесь хранится информация о времени использования ресурсов (время для других ресурсов, таких как изображения, таблицы стилей, скрипты, вызовы Ajax и т. д.). Дата будет добавлена, но два или более ресурсов могут быть загружены. в то же время автоматически увеличивающийся идентификатор будет использоваться в качестве ключа. Индексы будут созданы для даты и имени (URL ресурса).

Во всех браузерах на базе Chrome есть вкладка Приложение, где вы можете проверить объем хранилища, искусственно ограничить его и стереть все данные. Запись IndexedDB в дереве хранилища позволяет просматривать, обновлять и удалять хранилища объектов, индексы и отдельные записи. Панель Firefox называется Хранилище.

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

Подключение к базе данных IndexedDB

Класс-оболочка, созданный в indexeddb.js, проверяет поддержку IndexedDB, используя:

if ('indexedDB' in window) // ...

Затем он открывает соединение с базой данных, используя indexedDB.open(), передавая:

  1. имя базы данных и
  2. необязательное целое число версии.
const dbOpen = indexedDB.open('performance', 1);

Необходимо определить три важные функции обработчика событий:

  1. dbOp en.onerror запускается, когда соединение IndexedDB не может быть установлено.
  2. dbOpen.onupgradeneeded запускается, когда требуемая версия (1) больше текущей версии (0, если база данных не определена). Функция обработчика должна запускать методы IndexedDB, такие как createObjectStore() и createIndex(), для создания структур хранения.
  3. dbOpen.onsuccess запускается, когда соединение установлено и все обновления завершены. Объект соединения в dbOpen.result используется во всех последующих операциях с данными. Ему присваивается this.db в классе-оболочке.

Код конструктора оболочки:

// IndexedDB wrapper class: indexeddb.js
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);
      };
    }
    // success event handler
    dbOpen.onsuccess = () => {
      this.db = dbOpen.result;
      resolve( this );
    };
    // failure event handler
    dbOpen.onerror = e => {
      reject(`IndexedDB error: ${ e.target.errorCode }`);
    };
   });
  }
  // more methods coming later...
}

Сценарий performance.js загружает этот модуль и создает новый объект IndexedDB с именем perfDB после загрузки страницы. Он передает имя базы данных (performance), версию (1) и функцию обновления. Конструктор indexeddb.js вызывает функцию обновления с объектом подключения к базе данных, текущей версией базы данных и новой версией:

// performance.js
import { IndexedDB } from './indexeddb.js';
window.addEventListener('load', async () => {
// IndexedDB connection
  const perfDB = await new IndexedDB(
    'performance',
    1,
    (db, oldVersion, newVersion) => {
      console.log(`upgrading database from ${ oldVersion } to ${ newVersion }`);
      switch (oldVersion) {
        case 0: {
          const
            navigation = db.createObjectStore('navigation', { keyPath: 'date' }),
            resource = db.createObjectStore('resource', { keyPath: 'id', autoIncrement: true });
          resource.createIndex('dateIdx', 'date', { unique: false });
          resource.createIndex('nameIdx', 'name', { unique: false });
      }
    }
  });
  // more code coming later...
});

В какой-то момент потребуется изменить схему базы данных - возможно, чтобы добавить новые хранилища объектов, индексы или обновления данных. В этой ситуации вы должны увеличить версию (с 1 до 2). Следующая загрузка страницы снова запустит обработчик обновления, поэтому вы можете добавить дополнительный блок в оператор switch, например чтобы создать индекс с именем durationIdx для свойства duration в хранилище объектов resource:

case 1: {
  const resource = db.transaction.objectStore('resource');
  resource.createIndex('durationIdx', 'duration', { unique: false });
}

Обычный break в конце каждого case блока опускается. Когда кто-то обращается к приложению в первый раз, запускается блок case 0, затем case 1 и все последующие блоки. Любой, кто уже использует версию 1, запустит обновления, начиная с case 1. Методы обновления схемы IndexedDB включают:

У всех, кто загружает страницу, будет одна и та же версия - , если приложение не запущено на двух или более вкладках. Чтобы избежать конфликтов, в indexeddb.js можно добавить обработчик подключения к базе данных onversionchange, который предлагает пользователю перезагрузить страницу:

// version change handler
dbOpen.onversionchange = () => {
dbOpen.close();
  alert('Database upgrade required - reloading...');
  location.reload();
};

Теперь вы можете добавить сценарий performance.js на страницу и запустить его, чтобы проверить, созданы ли хранилища объектов и индексы (панели DevTools Приложение или Хранилище):

<script type="module" src="./performance.js"></script>

Записывать статистику производительности

Все операции IndexedDB заключены в транзакцию. Используется следующий процесс:

  1. Создайте объект транзакции базы данных. Это определяет одно или несколько хранилищ объектов (одна строка или массив строк) и тип доступа: "readonly" для выборки данных или "readwrite" для вставок и обновлений.
  2. Создайте ссылку на objectStore() в рамках транзакции.
  3. Запустите любое количество (только вставки) или методов (вставки и обновления).

Добавьте новый метод update() в класс IndexedDB в indexeddb.js:

// store item
update(storeName, value, overwrite = false) {
  return new Promise((resolve, reject) => {
    // new transaction
    const
      transaction = this.db.transaction(storeName, 'readwrite'),
      store = transaction.objectStore(storeName);
    // ensure values are in array
    value = Array.isArray(value) ? value : [ value ];
    // write all values
    value.forEach(v => {
      if (overwrite) store.put(v);
      else store.add(v);
    });
    transaction.oncomplete = () => {
      resolve(true); // success
    };
    transaction.onerror = () => {
      reject(transaction.error); // failure
    };
  });
}

Это добавляет или обновляет (если параметр overwrite равен true) одно или несколько значений в названном хранилище и помещает всю транзакцию в Promise. Обработчик событий transaction.oncomplete запускается, когда транзакция автоматически фиксируется в конце функции и все операции с базой данных завершены. Обработчик transaction.onerror сообщает об ошибках.

События IndexedDB передаются от операции к транзакции, хранилищу и базе данных. Вы можете создать единственный onerror обработчик в базе данных, который будет получать все ошибки. Как и события DOM, распространение можно остановить с помощью event.stopPropagation().

Скрипт performance.js теперь может сообщать показатели навигации по страницам:

// record page navigation information
  const
    date = new Date(),
    nav = Object.assign(
      { date },
      performance.getEntriesByType('navigation')[0].toJSON()
    );
await perfDB.update('navigation', nav);

и метрики ресурсов:

const res = performance.getEntriesByType('resource').map(
  r => Object.assign({ date }, r.toJSON())
);
await perfDB.update('resource', res);

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

Мониторинг внешнего интерфейса

OpenReplay - это ориентированная на разработчиков программа с открытым кодом, альтернатива FullStory, LogRocket и Hotjar. Это похоже на то, как если бы вы открыли DevTools в браузере, глядя через плечо пользователю.

OpenReplay является автономным, поэтому вы полностью контролируете свои данные и расходы.

Удачной отладки, для современных команд, работающих с веб-интерфейсом - Начни мониторинг своего веб-приложения бесплатно.

Чтение отчетов о производительности

Поиск в IndexedDB рудиментарен по сравнению с другими базами данных. Вы можете получать записи только по их ключу или индексированному значению. Вы не можете использовать эквиваленты SQL JOIN или такие функции, как AVERAGE() и SUM(). Вся обработка записей должна выполняться с помощью кода JavaScript; практическим вариантом может быть фоновая ветка Web Worker.

Вы можете получить отдельную запись, передав ее ключ в хранилище объектов или индексы и определив обработчик onsuccess:

// EXAMPLE CODE
const
  // new readonly transaction
  transaction = db.transaction('resource', 'readonly'),
  // get resource object store
  resource = transaction.objectStore('resource'),
  // fetch record 1
  request = resource.get(1);
// request complete
request.onsuccess = () => {
  console.log('result:', request.result);
};
// request failed
request.onerror = () => {
  console.log('failed:', request.error);
};

К подобным методам относятся:

  • .count(query) - количество совпадающих записей
  • .getAll(query) - массив совпадающих записей
  • .getKey(query) - соответствующий ключ (а не значение, присвоенное этому ключу)
  • .getAllKeys(query) - массив совпадающих ключей

query также может быть аргументом KeyRange для поиска записей в диапазоне, например IDBKeyRange.bound(1, 10) возвращает все записи с ключом от 1 до 10 включительно:

request = resource.getAll( IDBKeyRange.bound(1, 10) );

Параметры KeyRange:

Методы lower, upper и bound имеют необязательный исключительный флаг, например IDBKeyRange.bound(1, 10, true, false) - ключи больше 1 (но не сам 1) и меньше или равные 10.

Считывание всего набора данных в массив становится невозможным по мере роста базы данных. IndexedDB предоставляет курсоры, которые могут перебирать каждую запись по очереди. Метод .openCursor() передает KeyRange и необязательную строку направления ("next", "nextunique", "prev" или "preunique").

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

  1. index(storeName, indexName) - возвращает либо хранилище объектов, либо индекс в этом хранилище, и
  2. bound(lowerBound, upperBound) - возвращает соответствующий объект KeyRange.
// get items using cursor
  fetch(storeName, indexName, lowerBound = null, upperBound = null, callback) {
    const
      request = this.index(storeName, indexName)
        .openCursor( this.bound(lowerBound, upperBound) );
    // pass cursor to callback function
    request.onsuccess = () => {
      if (callback) callback(request.result);
    };
    request.onerror = () => {
      return(request.error); // failure
    };
  }
  // start a new read transaction on object store or index
  index(storeName, indexName) {
    const
      transaction = this.db.transaction(storeName),
      store = transaction.objectStore(storeName);
    return indexName ? store.index(indexName) : store;
  }
  // get bounding object
  bound(lowerBound, upperBound) {
    if (lowerBound && upperBound) return IDBKeyRange.bound(lowerBound, upperBound);
    else if (lowerBound) return IDBKeyRange.lowerBound(lowerBound);
    else if (upperBound) return IDBKeyRange.upperBound(upperBound);
  }

Скрипт performance.js теперь может получать показатели навигации по страницам, например вернуть все domContentLoadedEventEnd в течение июня 2021 г .:

// fetch page navigation objects in June 2021
perfDB.fetch(
  'navigation',
  null, // not an index
  new Date(2021,5,1,10,40,0,0), // lower
  new Date(2021,6,1,10,40,0,0), // upper
  cursor => { // callback function
    if (cursor) {
      console.log(cursor.value.domContentLoadedEventEnd);
      cursor.continue();
    }
  }
);

Точно так же вы можете рассчитать среднее время загрузки для конкретного файла и сообщить об этом в OpenReplay:

// calculate average download time using index
let
  filename = 'http://mysite.com/main.css',
  count = 0,
  total = 0;
perfDB.fetch(
    'resource', // object store
    'nameIdx',  // index
    filename,   // matching file
    filename,
    cursor => { // callback
      if (cursor) {
        count++;
        total += cursor.value.duration;
        cursor.continue();
      }
      else {
        // all records processed
        if (count) {
          const avgDuration = total / count;
          console.log(`average duration for ${ filename }: ${ avgDuration } ms`);
          // report to OpenReplay
          if (asayer) asayer.event(`${ filename }`, { avgDuration });
     }
   }
});

В обоих случаях объект cursor передается функции обратного вызова, где он может:

  1. получить значение записи с cursor.value
  2. перейти к следующей записи с cursor.continue()
  3. продвинуть N записи с cursor.advance(N)
  4. обновить запись с помощью cursor.update(data), или
  5. удалить запись с cursor.delete()

cursor равно null, когда все совпадающие записи были обработаны.

Проверьте оставшееся место для хранения

Браузеры выделяют для IndexedDB значительный объем хранилища, но в конечном итоге он закончится. Новый основанный на Promise StorageManager API может рассчитать оставшееся пространство для домена:

(async () => {
  if (!navigator.storage) return;
  const
    estimate = await navigator.storage.estimate(),
    // calculate remaining storage in MB
    available = Math.floor((estimate.quota - estimate.usage) / 1024 / 1024);
  console.log(`${ available } MB remaining`);
})();

API не поддерживается в IE или Safari. По мере приближения к пределу вы можете удалить более старые записи.

Вывод

IndexedDB - один из старых и более сложных API-интерфейсов браузера, но вы можете добавить методы-оболочки для принятия Promises и async / await. Готовые библиотеки, такие как idb, могут помочь, если вы не хотите делать это самостоятельно.

Несмотря на свои недостатки и некоторые необычные дизайнерские решения, IndexedDB остается самым быстрым и крупнейшим хранилищем данных на основе браузера.

Первоначально опубликовано на https://blog.asayer.io 3 июня 2021 г.