Мощная база данных прямо в вашем браузере
Не всегда необходимо отправлять данные пользователя на сервер: вы можете сохранить некоторую информацию в браузере. Он идеально подходит для настроек конкретного устройства, таких как конфигурации интерфейса (например, светлый / темный режимы) или личные данные, которые не должны передаваться по сети (например, ключ шифрования).
Рассмотрим Performance API для определения времени событий сети и страниц. Вы можете загрузить все данные, как только они станут доступны, но это значительный объем информации, который, по иронии судьбы, может сказаться на производительности страницы. Лучшим вариантом было бы хранить его локально, обрабатывать статистику с помощью Web Worker и выгружать результаты, когда браузер менее загружен.
Доступны два кроссбраузерных API хранилища на стороне клиента:
- Интернет-хранилище
Синхронное хранилище пар имя-значение, сохраняемое постоянно (localStore
) или для текущего сеанса (sessionStore
). Браузеры позволяют использовать до 5 МБ веб-хранилища на домен. - 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
. Он содержит два хранилища объектов:
- навигация
Здесь хранится информация о времени навигации по страницам (перенаправления, поиск DNS, загрузка страницы, размеры файлов, события загрузки и т. д.). Будет добавлена дата для использования в качестве ключа. - ресурс
Здесь хранится информация о времени использования ресурсов (время для других ресурсов, таких как изображения, таблицы стилей, скрипты, вызовы Ajax и т. д.). Дата будет добавлена, но два или более ресурсов могут быть загружены. в то же время автоматически увеличивающийся идентификатор будет использоваться в качестве ключа. Индексы будут созданы для даты и имени (URL ресурса).
Во всех браузерах на базе Chrome есть вкладка Приложение, где вы можете проверить объем хранилища, искусственно ограничить его и стереть все данные. Запись IndexedDB в дереве хранилища позволяет просматривать, обновлять и удалять хранилища объектов, индексы и отдельные записи. Панель Firefox называется Хранилище.
Вы также можете запустить свое приложение в режиме инкогнито, чтобы все данные удалялись после закрытия окна браузера.
Подключение к базе данных IndexedDB
Класс-оболочка, созданный в indexeddb.js
, проверяет поддержку IndexedDB, используя:
if ('indexedDB' in window) // ...
Затем он открывает соединение с базой данных, используя indexedDB.open()
, передавая:
- имя базы данных и
- необязательное целое число версии.
const dbOpen = indexedDB.open('performance', 1);
Необходимо определить три важные функции обработчика событий:
dbOp en.onerror
запускается, когда соединение IndexedDB не может быть установлено.dbOpen.onupgradeneeded
запускается, когда требуемая версия (1
) больше текущей версии (0
, если база данных не определена). Функция обработчика должна запускать методы IndexedDB, такие какcreateObjectStore()
иcreateIndex()
, для создания структур хранения.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 заключены в транзакцию. Используется следующий процесс:
- Создайте объект транзакции базы данных. Это определяет одно или несколько хранилищ объектов (одна строка или массив строк) и тип доступа:
"readonly"
для выборки данных или"readwrite"
для вставок и обновлений. - Создайте ссылку на
objectStore()
в рамках транзакции. - Запустите любое количество (только вставки) или методов (вставки и обновления).
Добавьте новый метод 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:
IDBKeyRange.lowerBound(X)
- больше или равноX
IDBKeyRange.upperBound(X)
- меньше или равноY
IDBKeyRange.bound(X,Y)
- отX
доY
включительноIDBKeyRange.only(X)
- единичное соответствие ключаX
.
Методы lower, upper и bound имеют необязательный исключительный флаг, например IDBKeyRange.bound(1, 10, true, false)
- ключи больше 1
(но не сам 1
) и меньше или равные 10
.
Считывание всего набора данных в массив становится невозможным по мере роста базы данных. IndexedDB предоставляет курсоры, которые могут перебирать каждую запись по очереди. Метод .openCursor()
передает KeyRange и необязательную строку направления ("next"
, "nextunique"
, "prev"
или "preunique"
).
Добавьте новый метод fetch()
к классу IndexedDB
в indexeddb.js
для поиска в хранилище объектов или индексе с верхней и нижней границами с помощью функции обратного вызова, которой передается курсор. Также требуются два дополнительных метода:
index(storeName, indexName)
- возвращает либо хранилище объектов, либо индекс в этом хранилище, и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
передается функции обратного вызова, где он может:
- получить значение записи с
cursor.value
- перейти к следующей записи с
cursor.continue()
- продвинуть
N
записи сcursor.advance(N)
- обновить запись с помощью
cursor.update(data)
, или - удалить запись с
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 г.