Хранение данных

cookieПозволяет прочитать или установить cookie
WebSorageБаза данных на стороне клиента, содержащая пары ключ-значение.
IndexedDBПредоставляет функционал для работы с базой данных, которая находится в браузере.

cookie

Свойство cookie возвращает и устанавливает cookies в браузере.

Синтаксис

document.cookie  // получить весь сохраненый набор cookies

document.cookie = strCookie
strCookie

Для сохранения cookie нужно присвоить текстовую строку, которая содержит свойства создаваемого cookie:

document.cookie = "name=значение; expires=дата; path=путь; domain=домен; secure"

name=значение Основной параметр cookie. Здесь name — уникальное имя для cookie. В качестве значения может быть что угодно, любая структура хранимых данных (да хоть JSON), а также какие угодно символы. Единственная пара, которая является обязательной при установке cookie.
expires=дата Устанавливает дату истечения срока хранения cookie. Дата должна быть представлена в формате, который возвращает метод toUTCString() объекта Date. Если значение expires не задано, cookie будет удалено при закрытии браузера.
max-age=числоВремя жизни куки в секундах (альтернатива параметру expires)
path=путь Данная опция устанавливает путь на сайте, в рамках которого действует cookie. Получить значение cookie могут только документы из указанного пути. Обычно данное свойство оставляют пустым, что означает что только документ установивший cookie может получит доступ к нему.
domain=домен Данная опция устанавливает домен, в рамках которого действует cookie. Получить значение cookie могут только сайты из указанного домена. Обычно данное свойство оставляют пустым, что означает, что только домен установивший cookie может получит доступ к нему.
secure Данная опция указывает браузеру, что для пересылки cookie на сервер следует использовать SSL. Очень редко используется.
samesite=None|Strict|LaxНастройка, необходимая для защиты от XSRF-атак. Атрибут SameSite может иметь разные значения:
  • None,  в этом случае ограничения на файлы Cookie не устанавливаются;
  • Strict,  устанавливается полный запрет на отправку любых Cookie;
  • Lax,    в этом случае файлы Cookie полностью блокируются для межсайтовых запросов (включая изображения, iframe и т.д.).
Ознакомьтесь с этой с этой статьёй, если хотите узнать больше о подобных атаках и предназначении данного параметра.

Комментарии

Cookies предназначены для сохранения небольших объемов данных серверными сценариями, которые должны передаваться на сервер при обращении к каждому соответствующему URL-адресу. Стандарт, определяющий cookies, рекомендует производителям браузеров не ограничивать количество и размеры сохраняемых cookies, но браузеры не обязаны сохранять в сумме более 300 cookies, 20 cookies на один веб-сервер или по 4 Кбайт данных на один cookie (в этом ограничении учитываются и значение cookie, и его имя). На практике браузеры позволяют сохранять гораздо больше 300 cookies, но ограничение на размер 4 Кбайт для одного cookie в некоторых браузерах по-прежнему соблюдается.

Конкурентом для Cookies яыляется HTML5 Web Storage - база данных на стороне клиента, которая позволяет пользователям сохранять данные в виде пары ключ/значение.

В Интернете можно найти много информации о Cookies, в том числе и функции для работы с Cookies.

Здесь предлагается библиотека cookies.js

Иногда посетители отключают cookie. Отловить это можно проверкой свойства navigator.cookieEnabled

if (!navigator.cookieEnabled) {
  Alert( 'Включите cookie для комфортной работы с этим сайтом' );
}

getCookies()

// Возвращает cookies документа в виде объекта с парами имя:значение. 
// Предполагается, что значения cookie кодируются с помощью 
// функции encodeURIComponent()
function getCookies() {
 var cookies = {};  // Возвращаемый объект
 var all = document.cookie;  // Получить все cookies в одной строке
 if (all === "") return cookies; // Если получена пустая строка, вернуть пустой объект

// Разбить на пары имя:значение
 var list = all.split("; ");
 for(var i = 0; i < list.length; i++) {
   // Для каждого cookie удалим пробельные символы в начале и в конце
   var cookie = list[i].replace(/^\s+|\s+$/g, '');
   var t = cookie.split('=');
   var name = t[0].replace(/\s+$/,'');
   t[1] = t[1].replace(/^\s+/,'');
                 // Декодировать значение
   var value = decodeURIComponentX(t[1]);
   cookies[name] = value; // Сохранить имя и значение в объекте 
  }
 return cookies; 
}

getCookie()

// возвращает cookie с именем name, если есть, если нет, то undefined
function getCookie(name) {
  var matches = document.cookie.match(new RegExp(
    "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)"
  ));
  return matches ? decodeURIComponentX(matches[1]) : undefined;
}

setCookie()

// устанавливает cookie с именем name и значением value и 
// с свойствами expires, path, domain, secure
function setCookie(name, value, expires, path, domain, secure) {
  if (!name) return false;
  var str = name + '=' + encodeURIComponent(value);
  if (typeof expires == "number" && expires) { // секунды
    var d = new Date();
    d.setTime(d.getTime() + expires * 1000);
    expires = d; }
  if (expires && expires.toUTCString) { 
    str += '; expires=' + expires.toUTCString(); }
  if (path)    str += '; path=' + path;
  if (domain)  str += '; domain=' + domain;
  if (secure)  str += '; secure';
  document.cookie = str;
  return true;
 }

deleteCookie()

 // удаляет cookie с именем name
function deleteCookie(name) {  setCookie(name, "", -1); }

decodeURIComponentX()

// Вспомогательная функция для Декодирования
function decodeURIComponentX (text) { var value; 
 try { value = decodeURIComponent(text); }
 catch (e) { value = unescape(text);} 
 return value;  }

Примеры

var a = getCookies(), s=''; 
for (var t in a) {s+='\n'+t+': '+a[t];} Alert (s);

setCookie('name','Вася',5*60*60);  // 5 часов
setCookie('address','Я из Сибири',new Date (2021,1,1) );
setCookie('login','Gato');
setCookie('password','qwert; йцуке;');

Alert (document.cookie);
a = getCookies(), s='';
for (var t in a) {s+='\n'+t+': '+a[t];} Alert (s);

Alert ( getCookie('login')+'\n'+getCookie('password') );

deleteCookie ('name'); deleteCookie ('address'); deleteCookie ('Settlement');

a = getCookies(), s='';
for (var t in a) {s+='\n'+t+': '+a[t];} Alert (s);

Web Storage

HTML5 Web Storage - Это база данных на стороне клиента, которая позволяет пользователям сохранять данные в виде пары ключ/значение.

HTML5 Web Storage имеет достаточно простой API для извлечения записи данных локального хранилища. Он может хранить до 10 Мб данных для одного домена. В отличие от файлов cookie, Web Storage не делает каждый раз запрос HTTP.

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

Проверить, поддерживает ли браузер эти API можно с помощью следующей строки:

if (window.sessionStorage && window.localStorage) {
      Alert ('объекты sessionStorage и localtorage поддерживаются');
    }
else { Alert ('объекты sessionStorage и localtorage НЕ поддерживаются');
    }

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

Методы и свойство объектов sessionStorage и localStorage

.getItem(key)
Метод getItem(key) используется для получения значения элемента хранилища по его ключу (key).
.setItem(key,value)
Метод setItem(key,value) предназначен для добавления в хранилище элемента с указанным ключом (key) и значением (value). Если в хранилище уже есть элемент с указанным ключом (key), то в этом случае произойдет изменения его значения (value).
.key(индекс)
Метод key(индекс) возвращает ключ элемента по его порядковому номеру (индексу), который находится в данном хранилище.
.removeItem(key)
Метод removeItem(key) удаляет из контейнера sessionStorage или localStorage элемент, имеющий указанный ключ.
.clear()
Метод clear() удаляет все элементы из контейнера.
.length
Свойство length возвращает количество элементов, находящихся в контейнере.

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

// Создаем объект UserInfo
var user1 = {name: 'Вася', family: 'Петров'}
// Сохраняем этот объект в формате JSON
sessionStorage.setItem ('userinfo', JSON.stringify(user1));

// Преобразуем JSON-текст в соответствующий объект
var user2 = JSON.parse(sessionStorage['userinfo']);

Alert("Привет " + user2.name + " " + user2.family);

События storage

Когда мы установим или удалим данные из веб — хранилища, хранилище запустит событие объекта window. Мы можем добавить обработку события:

window.addEventListener('storage', storageEventHandler, false);
// примечание: для IE 8,0- нужно использовать метод:  attacheEvent() 
function storageEventHandler(e) {
    var message = document.getElementById("updateMessage");
    message.innerHTML = "Обновление локального хранилища: "+ e.storageArea;
    message.innerHTML += "
Ключ: " + e.key; message.innerHTML += "
Старое значение: " + e.oldValue; message.innerHTML += "
Новое значение: " + e.newValue; message.innerHTML += "
URL: " + e.url; }

Событие event имеет следующие атрибуты:

Безопасность данных localStorage и sessionStorage

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

Пример

<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Пример</title>
</head>
<body onload="applySetting()">
<form onsubmit="return false;"><label>Выбрать цвет для фона: </label>
  <input id="favcolor" type="color" value="#ffffff" />
  <label>Select Font Size: </label>
  <input id="fontwt" type="number" max="14" min="10" value="13" />
  <input type="submit" value="Save" onclick="setSettings()" />
  <input onclick="clearSettings()" type="reset" value="Стереть" />
</form>
</body></html>
var testStorage = true;
function setSettings() {
  if (testStorage) {
    try {
      var favcolor = document.getElementById('favcolor').value;
      var fontwt = document.getElementById('fontwt').value;
      localStorage.setItem('bgcolor', favcolor);
      localStorage.fontweight = fontwt; location.reload(true);
     } catch (e) {
        if (e == QUOTA_EXCEEDED_ERR) {
        Alert('Quota exceeded!'); // исключение, если лимит хранилища превышает 5 Мб
       } } } 
  else { Alert('Данные не сохранятся, ваш браузер не поддерживает Local storage'); } }

function applySetting() {
 if ('localStorage' in window && window['localStorage'] == null) 
   {testStorage = false; Alert ('Ваш браузер не поддерживает Local storage'); return;}
 if (localStorage.length != 0) {
    document.body.style.backgroundColor = localStorage.getItem('bgcolor');
    document.body.style.fontSize = localStorage.fontweight + 'px';
    document.getElementById('favcolor').value = localStorage.bgcolor;
    document.getElementById('fontwt').value = localStorage.fontweight;
   } 
  else {
    document.body.style.backgroundColor = '#FFFFFF';
    document.body.style.fontSize = '14px'
    document.getElementById('favcolor').value = '#FFFFFF';
    document.getElementById('fontwt').value = '14';
  }
}

function clearSettings() {
  localStorage.removeItem("bgcolor");
  localStorage.removeItem("fontweight");
  document.body.style.backgroundColor = '#FFFFFF';
  document.body.style.fontSize = '14px'
  document.getElementById('favcolor').value = '#FFFFFF';
  document.getElementById('fontwt').value = '14';
}

IndexedDB

IndexedDB – это встроенная база данных, более мощная, чем localStorage.

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

Интерфейс для IndexedDB, описанный в спецификации https://www.w3.org/TR/IndexedDB, основан на событиях.

Мы также можем использовать async/await с помощью обёртки, которая основана на промисах, например https://github.com/jakearchibald/idb. Это очень удобно, но обёртка не идеальна, она не может полностью заменить события. Поэтому мы начнём с событий, а затем, когда разберёмся в IndexedDB, рассмотрим и обёртку.

Где хранятся данные?

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

У разных браузеров и пользователей на уровне ОС есть своё собственное независимое хранилище.

Открыть базу данных

Для начала работы с IndexedDB нужно открыть базу данных.

Синтаксис:

let openRequest = indexedDB.open(name, version);
name
Название базы данных, строка.
version
Версия базы данных, положительное целое число, по умолчанию 1 (объясняется ниже).

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

После этого вызова необходимо назначить обработчик событий для объекта openRequest:

IndexedDB имеет встроенный механизм «версионирования схемы», который отсутствует в серверных базах данных.

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

Если локальная версия базы данных меньше, чем версия, определённая в open, то сработает специальное событие upgradeneeded, и мы сможем сравнить версии и обновить структуры данных по мере необходимости.

Это событие также сработает, если базы данных ещё не существует, так что в этом обработчике мы можем выполнить инициализацию.

Допустим, мы опубликовали первую версию нашего приложения.

Затем мы можем открыть базу данных с версией 1 и выполнить инициализацию в обработчике upgradeneeded вот так:

let openRequest = indexedDB.open("store", 1);

openRequest.onupgradeneeded = function() {
  // срабатывает, если на клиенте нет базы данных
  // ...выполнить инициализацию...
};

openRequest.onerror = function() {
  console.error("Error", openRequest.error);
};

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // продолжить работу с базой данных, используя объект db
};

Затем, позже, мы публикуем 2-ю версию.

Мы можем открыть его с версией 2 и выполнить обновление следующим образом:

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = function(event) {
  // версия существующей базы данных меньше 2 (или база данных не существует)
  let db = openRequest.result;
  switch(event.oldVersion) { // существующая (старая) версия базы данных
    case 0:
      // версия 0 означает, что на клиенте нет базы данных
      // выполнить инициализацию
    case 1:
      // на клиенте версия базы данных 1
      // обновить
  }
};

Таким образом, в openRequest.onupgradeneeded мы обновляем базу данных. Скоро подробно увидим, как это делается. А после того, как этот обработчик завершится без ошибок, сработает openRequest.onsuccess.

После openRequest.onsuccess у нас есть объект базы данных в openRequest.result, который мы будем использовать для дальнейших операций.

Удалить базу данных:

let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess/onerror отслеживает результат

Что если мы попробуем открыть базу с более низкой версией, чем текущая? Например, на клиенте база версии 3, а мы вызываем open(...2).

Возникнет ошибка, сработает openRequest.onerror.

Такое может произойти, если посетитель загрузил устаревший код, например, из кеша прокси. Нам следует проверить db.version и предложить ему перезагрузить страницу. А также проверить наши кеширующие заголовки, убедиться, что посетитель никогда не получит устаревший код.

Проблема параллельного обновления

Раз уж мы говорим про версионирование, рассмотрим связанную с этим небольшую проблему.

Допустим:

  1. Посетитель открыл наш сайт во вкладке браузера, с базой версии 1.
  2. Затем мы выпустили обновление, так что наш код обновился.
  3. И затем тот же посетитель открыл наш сайт в другой вкладке.

Так что есть две вкладки, на которых открыт наш сайт, но в одной открыто соединение с базой версии 1, а другая пытается обновить версию базы в обработчике upgradeneeded.

Проблема заключается в том, что база данных всего одна на две вкладки, так как это один и тот же сайт, один источник. И она не может быть одновременно версии 1 и 2. Чтобы обновить на версию 2, все соединения к версии 1 должны быть закрыты.

Чтобы это можно было организовать, при попытке обновления на объекте базы возникает событие versionchange. Нам нужно слушать его и закрыть соединение к базе (а также, возможно, предложить пользователю перезагрузить страницу, чтобы получить обновлённый код).

Если мы его не закроем, то второе, новое соединение будет заблокировано с событием blocked вместо success.

Код, который это делает:

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;

openRequest.onsuccess = function() {
  let db = openRequest.result;

  db.onversionchange = function() {
    db.close();
    alert("База данных устарела, пожалуйста, перезагрузите страницу.")
  };

  // ...база данных готова, используйте ее...
};

openRequest.onblocked = function() {
  // это событие не должно срабатывать, если мы правильно обрабатываем onversionchange

  // это означает, что есть ещё одно открытое соединение с той же базой данных
  // и он не был закрыт после того, как для него сработал db.onversionchange
};

…Другими словами, здесь мы делаем две вещи:

  1. Обработчик db.onversionchange сообщает нам о попытке параллельного обновления, если текущая версия базы данных устарела.
  2. Обработчик OpenRequest.onblocked сообщает нам об обратной ситуации: в другом месте есть соединение с устаревшей версией, и оно не закрывается, поэтому новое соединение установить невозможно.

Мы можем более изящно обращаться с вещами в db.onversionchange, например предлагать посетителю сохранить данные до закрытия соединения и так далее.

Или альтернативным подходом было бы не закрывать базу данных в db.onversionchange, а вместо этого использовать обработчик onblocked (на новой вкладке), чтобы предупредить посетителя, что более новая версия не может быть загружена, пока они не закроют другие вкладки.

Такой конфликт при обновлении происходит редко, но мы должны как-то его обрабатывать, хотя бы поставить обработчик onblocked, чтобы наш скрипт не «умирал» молча, удивляя посетителя.

Хранилище объектов

Чтобы сохранить что-то в IndexedDB, нам нужно хранилище объектов.

Хранилище объектов – это основная концепция IndexedDB. В других базах данных это «таблицы» или «коллекции». Здесь хранятся данные. В базе данных может быть множество хранилищ: одно для пользователей, другое для товаров и так далее.

Несмотря на то, что название – «хранилище объектов», примитивы тоже могут там храниться.

Мы можем хранить почти любое значение, в том числе сложные объекты.

IndexedDB использует стандартный алгоритм сериализации для клонирования и хранения объекта. Это как JSON.stringify, но более мощный, способный хранить гораздо больше типов данных.

Пример объекта, который нельзя сохранить: объект с циклическими ссылками. Такие объекты не сериализуемы. JSON.stringify также выдаст ошибку при сериализации.

Каждому значению в хранилище должен соответствовать уникальный ключ.

Ключ должен быть одним из следующих типов: number, date, string, binary или array. Это уникальный идентификатор: по ключу мы можем искать/удалять/обновлять значения.

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

Но для начала нужно создать хранилище.

Синтаксис для создания хранилища объектов:

db.createObjectStore(name[, keyOptions]);

Обратите внимание, что операция является синхронной, использование await не требуется.

name
это название хранилища, например "books" для книг,
keyOptions
это необязательный объект с одним или двумя свойствами:
  • keyPath – путь к свойству объекта, которое IndexedDB будет использовать в качестве ключа, например id.
  • autoIncrement – если true, то ключ будет формироваться автоматически для новых объектов, как постоянно увеличивающееся число.

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

Например, это хранилище объектов использует свойство id как ключ:

db.createObjectStore('books', {keyPath: 'id'});

Хранилище объектов можно создавать/изменять только при обновлении версии базы данных в обработчике upgradeneeded.

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

Для обновления версии базы есть два основных подхода:

  1. Мы можем реализовать функции обновления по версиям: с 1 на 2, с 2 на 3 и т.д. Потом в upgradeneeded сравнить версии (например, была 2, сейчас 4) и запустить операции обновления для каждой промежуточной версии (2 на 3, затем 3 на 4).
  2. Или мы можем взять список существующих хранилищ объектов, используя db.objectStoreNames. Этот объект является DOMStringList, в нём есть метод contains(name), используя который можно проверить существование хранилища. Посмотреть, какие хранилища есть и создать те, которых нет.

Для простых баз данных второй подход может быть проще и предпочтительнее.

Вот демонстрация второго способа:

let openRequest = indexedDB.open("db", 2);

// создаём хранилище объектов для books, если ешё не существует
openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('books')) { // если хранилище "books" не существует
    db.createObjectStore('books', {keyPath: 'id'}); // создаём хранилище
  }
};

Чтобы удалить хранилище объектов:

db.deleteObjectStore('books')

Транзакции

Термин «транзакция» является общеизвестным, транзакции используются во многих видах баз данных.

Транзакция – это группа операций, которые должны быть или все выполнены, или все не выполнены (всё или ничего).

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

  1. Вычесть деньги с его счёта.
  2. Отправить ему покупку.

Будет очень плохо, если мы успеем завершить первую операцию, а затем что-то пойдёт не так, например отключат электричество, и мы не сможем завершить вторую операцию. Обе операции должны быть успешно завершены (покупка сделана, отлично!) или необходимо отменить обе операции (в этом случае пользователь сохранит свои деньги и может попытаться купить ещё раз).

Транзакции гарантируют это.

Все операции с данными в IndexedDB могут быть сделаны только внутри транзакций.

Для начала транзакции:

db.transaction(store[, type]);
store
это название хранилища, к которому транзакция получит доступ, например, "books". Может быть массивом названий, если нам нужно предоставить доступ к нескольким хранилищам.
type
тип транзакции, один из:
  • readonly – только чтение, по умолчанию.
  • readwrite – только чтение и запись данных, создание/удаление самих хранилищ объектов недоступно.

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

Почему существует несколько типов транзакций?

Производительность является причиной, почему транзакции необходимо помечать как readonly или readwrite.

Несколько readonly транзакций могут одновременно работать с одним и тем же хранилищем объектов, а readwrite транзакций – не могут. Транзакции типа readwrite «блокируют» хранилище для записи. Следующая такая транзакция должна дождаться выполнения предыдущей, перед тем как получит доступ к тому же самому хранилищу.

После того, как транзакция будет создана, мы можем добавить элемент в хранилище, вот так:

let transaction = db.transaction("books", "readwrite"); // (1)

// получить хранилище объектов для работы с ним
let books = transaction.objectStore("books"); // (2)

let book = {
  id: 'js',
  price: 10,
  created: new Date()
};

let request = books.add(book); // (3)

request.onsuccess = function() { // (4)
  console.log("Книга добавлена в хранилище", request.result);
};

request.onerror = function() {
  console.log("Ошибка", request.error);
};

Мы сделали четыре шага:

  1. Создать транзакцию и указать все хранилища, к которым необходим доступ, строка (1).
  2. Получить хранилище объектов, используя transaction.objectStore(name), строка (2).
  3. Выполнить запрос на добавление элемента в хранилище объектов books.add(book), строка (3).
  4. …Обработать результат запроса (4), затем мы можем выполнить другие запросы и так далее.

Хранилища объектов поддерживают два метода для добавления значений:

  1. put(value, [key]) Добавляет значение value в хранилище. Ключ key необходимо указать, если при создании хранилища объектов не было указано свойство keyPath или autoIncrement. Если уже есть значение с таким же ключом, то оно будет заменено.
  2. add(value, [key]) То же, что put, но если уже существует значение с таким ключом, то запрос не выполнится, будет сгенерирована ошибка с названием "ConstraintError".
  3. Аналогично открытию базы, мы отправляем запрос: books.add(book) и после ожидаем события success/error.

    Автоматическая фиксация транзакций

    В примере выше мы запустили транзакцию и выполнили запрос add. Но, как говорилось ранее, транзакция может включать в себя несколько запросов, которые все вместе должны либо успешно завершиться, либо нет. Как нам закончить транзакцию, обозначить, что больше запросов в ней не будет?

    Короткий ответ: этого не требуется.

    Когда все запросы завершены и очередь микрозадач пуста, тогда транзакция завершится автоматически.

    Как правило, это означает, что транзакция автоматически завершается, когда выполнились все её запросы и завершился текущий код.

    Таким образом, в приведённом выше примере не требуется никакого специального вызова, чтобы завершить транзакцию.

    Такое автозавершение транзакций имеет важный побочный эффект. Мы не можем вставить асинхронную операцию, такую как fetch или setTimeout в середину транзакции. IndexedDB никак не заставит транзакцию «висеть» и ждать их выполнения.

    В приведённом ниже коде в запросе request2 в строке с (*) будет ошибка, потому что транзакция уже завершена, больше нельзя выполнить в ней запрос:

    let request1 = books.add(book);
    
    request1.onsuccess = function() {
      fetch('/').then(response => {
        let request2 = books.add(anotherBook); // (*)
        request2.onerror = function() {
          console.log(request2.error.name); // TransactionInactiveError
        };
      });
    };

    Всё потому, что fetch является асинхронной операцией, макрозадачей. Транзакции завершаются раньше, чем браузер приступает к выполнению макрозадач.

    Авторы спецификации IndexedDB из соображений производительности считают, что транзакции должны завершаться быстро.

    В частности, readwrite транзакции «блокируют» хранилища от записи. Таким образом, если одна часть приложения инициирует readwrite транзакцию в хранилище объектов books, то другая часть приложения, которая хочет сделать то же самое, должна ждать: новая транзакция «зависает» до завершения первой. Это может привести к странным задержкам, если транзакции слишком долго выполняются.

    Что же делать?

    В приведённом выше примере мы могли бы запустить новую транзакцию db.transaction перед новым запросом (*).

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

    Сначала сделаем fetch, подготовим данные, если нужно, затем создадим транзакцию и выполним все запросы к базе данных.

    Чтобы поймать момент успешного выполнения, мы можем повесить обработчик на событие transaction.oncomplete:

    let transaction = db.transaction("books", "readwrite");
    
    // ...выполнить операции...
    
    transaction.oncomplete = function() {
      console.log("Транзакция выполнена");
    };

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

    Чтобы вручную отменить транзакцию, выполните:

    transaction.abort();

    Это отменит все изменения, сделанные запросами в транзакции, и сгенерирует событие transaction.onabort.

    Обработка ошибок

    Запросы на запись могут выполниться неудачно.

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

    При ошибке в запросе соответствующая транзакция отменяется полностью, включая изменения, сделанные другими её запросами.

    Если мы хотим продолжить транзакцию (например, попробовать другой запрос без отмены изменений), это также возможно. Для этого в обработчике request.onerror следует вызвать event.preventDefault().

    В примере ниже новая книга добавляется с тем же ключом (id), что и существующая. Метод store.add генерирует в этом случае ошибку "ConstraintError". Мы обрабатываем её без отмены транзакции:

    let transaction = db.transaction("books", "readwrite");
    
    let book = { id: 'js', price: 10 };
    
    let request = transaction.objectStore("books").add(book);
    
    request.onerror = function(event) {
      // ConstraintError возникает при попытке добавить объект с ключом, который уже существует
      if (request.error.name == "ConstraintError") {
        console.log("Книга с таким id уже существует"); // обрабатываем ошибку
        event.preventDefault(); // предотвращаем отмену транзакции
        // ...можно попробовать использовать другой ключ...
      } else {
        // неизвестная ошибка
        // транзакция будет отменена
      }
    };
    
    transaction.onabort = function() {
      console.log("Ошибка", transaction.error);
    };

    Делегирование событий

    Нужны ли обработчики onerror/onsuccess для каждого запроса? Не всегда. Мы можем использовать делегирование событий.

    События IndexedDB всплывают: запространзакциябаза данных.

    Все события являются DOM-событиями с фазами перехвата и всплытия, но обычно используется только всплытие.

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

    db.onerror = function(event) {
      let request = event.target; // запрос, в котором произошла ошибка
    
      console.log("Ошибка", request.error);
    };

    …А если мы полностью обработали ошибку? В этом случае мы не хотим сообщать об этом.

    Мы можем остановить всплытие и, следовательно, db.onerror, используя event.stopPropagation() в request.onerror.

    request.onerror = function(event) {
      if (request.error.name == "ConstraintError") {
        console.log("Книга с таким id уже существует"); // обрабатываем ошибку
        event.preventDefault(); // предотвращаем отмену транзакции
        event.stopPropagation(); // предотвращаем всплытие ошибки
      } else {
        // ничего не делаем
        // транзакция будет отменена
        // мы можем обработать ошибку в transaction.onabort
      }
    };

    Поиск по ключам

    Есть два основных вида поиска в хранилище объектов:

    1. По значению ключа или диапазону ключей. В нашем хранилище «books» это будет значение или диапазон значений book.id.
    2. С помощью другого поля объекта, например book.price. Для этого потребовалась дополнительная структура данных, получившая название «index».

    По ключу

    Сначала давайте разберёмся с первым типом поиска: по ключу.

    Методы поиска поддерживают либо точные ключи, либо так называемые «запросы с диапазоном» – IDBKeyRange объекты, которые задают «диапазон ключей».

    Диапазоны создаются с помощью следующих вызовов:

    Очень скоро мы увидим практические примеры их использования.

    Для выполнения фактического поиска существуют следующие методы. Они принимают аргумент query, который может быть либо точным ключом, либо диапазоном ключей:

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

    Примеры запросов:

    // получить одну книгу
    books.get('js')
    
    // получить книги с 'css' <= id <= 'html'
    books.getAll(IDBKeyRange.bound('css', 'html'))
    
    // получить книги с id < 'html'
    books.getAll(IDBKeyRange.upperBound('html', true))
    
    // получить все книги
    books.getAll()
    
    // получить все ключи, гдe id > 'js'
    books.getAllKeys(IDBKeyRange.lowerBound('js', true))

    Хранилище объектов всегда отсортировано

    Хранилище объектов внутренне сортирует значения по ключам.

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

    Поиск по индексированному полю

    Для поиска по другим полям объекта нам нужно создать дополнительную структуру данных, называемую «индекс» (index).

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

    Синтаксис:

    objectStore.createIndex(name, keyPath, [options]);
    name
    название индекса
    keyPath
    путь к полю объекта, которое индекс должен отслеживать (мы собираемся сделать поиск по этому полю),
    option
    необязательный объект со свойствами:
    • unique – если true, тогда в хранилище может быть только один объект с заданным значением в keyPath. Если мы попытаемся добавить дубликат, то индекс сгенерирует ошибку.
    • multiEntry – используется только, если keyPath является массивом. В этом случае, по умолчанию, индекс обрабатывает весь массив как ключ. Но если мы укажем true в multiEntry, тогда индекс будет хранить список объектов хранилища для каждого значения в этом массиве. Таким образом, элементы массива становятся ключами индекса.

    В нашем примере мы храним книги с ключом id.

    Допустим, мы хотим сделать поиск по полю price.

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

    openRequest.onupgradeneeded = function() {
      // мы должны создать индекс здесь, в versionchange транзакции
      let books = db.createObjectStore('books', {keyPath: 'id'});
      let index = books.createIndex('price_idx', 'price');
    };

    Представим, что в нашем books есть 4 книги. Вот картинка, которая показывает, что такое «индекс».

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

    Индексы автоматически обновляются, нам не нужно об этом заботиться.

    Сейчас, когда мы хотим найти объект по цене, мы просто применяем те же методы поиска к индексу:

    let transaction = db.transaction("books"); // readonly
    let books = transaction.objectStore("books");
    let priceIndex = books.index("price_idx");
    
    let request = priceIndex.getAll(10);
    
    request.onsuccess = function() {
      if (request.result !== undefined) {
        console.log("Книги", request.result); // массив книг с ценой 10
      } else {
        console.log("Нет таких книг");
      }
    };

    Мы также можем использовать IDBKeyRange, чтобы создать диапазон и найти дешёвые/дорогие книги:

    // найдём книги, где цена < 5
    let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

    Индексы внутренне отсортированы по полю отслеживаемого объекта, в нашем случае по price. Поэтому результат поиска будет уже отсортированный по полю price.

    Удаление из хранилища

    Метод delete удаляет значения по запросу, формат вызова такой же как в getAll:

    Например:

    // удалить книгу с id='js'
    books.delete('js');

    Если нам нужно удалить книги, основываясь на цене или на любом другом поле, сначала нам надо найти ключ в индексе, а затем выполнить delete:

    // найдём ключ, где цена = 5
    let request = priceIndex.getKey(5);
    
    request.onsuccess = function() {
      let id = request.result;
      let deleteRequest = books.delete(id);
    };

    Чтобы удалить всё:

    books.clear(); // очищаем хранилище.

    Курсоры

    Такие методы как getAll/getAllKeys возвращают массив ключей/значений.

    Но хранилище объектов может быть огромным, больше, чем доступно памяти. Тогда метод getAll вернёт ошибку при попытке получить все записи в массиве.

    Что делать?

    Курсоры предоставляют возможности для работы в таких ситуациях.

    Объект cursor идёт по хранилищу объектов с заданным запросом (query) и возвращает пары ключ/значение по очереди, а не все сразу. Это позволяет экономить память.

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

    Синтаксис:

    // как getAll, но с использованием курсора:
    let request = store.openCursor([query], [direction]);
    
    // чтобы получить ключи, не значения (как getAllKeys): store.openKeyCursor
    query
    ключ или диапазон ключей, как для getAll.
    direction
    необязательный аргумент, доступные значения:
    • "next" – по умолчанию, курсор будет проходить от самого маленького ключа к большему.
    • "prev" – обратный порядок: от самого большого ключа к меньшему.
    • "nextunique", "prevunique" – то же самое, но курсор пропускает записи с тем же ключом, что уже был (только для курсоров по индексам, например, для нескольких книг с price=5, будет возвращена только первая).

    Основным отличием курсора является то, что request.onsuccess генерируется многократно: один раз для каждого результата.

    Вот пример того, как использовать курсор:

    let transaction = db.transaction("books");
    let books = transaction.objectStore("books");
    
    let request = books.openCursor();
    
    // вызывается для каждой найденной курсором книги
    request.onsuccess = function() {
      let cursor = request.result;
      if (cursor) {
        let key = cursor.key; // ключ книги (поле id)
        let value = cursor.value; // объект книги
        console.log(key, value);
        cursor.continue();
      } else {
        console.log("Книг больше нет");
      }
    };

    Основные методы курсора:

    Независимо от того, есть ли ещё значения, соответствующие курсору или нет – вызывается onsuccess, затем вresult мы можем получить курсор, указывающий на следующую запись или равный undefined.

    В приведённом выше примере курсор был создан для хранилища объектов.

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

    Для курсоров по индексам cursor.key является ключом индекса (например price), нам следует использовать свойство cursor.primaryKey как ключ объекта:

    let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));
    
    // вызывается для каждой записи
    request.onsuccess = function() {
      let cursor = request.result;
      if (cursor) {
        let key = cursor.primaryKey; // следующий ключ в хранилище объектов (поле id)
        let value = cursor.value; // следующее значение в хранилище объектов (объект "книга")
        let keyIndex = cursor.key; // следующий ключ индекса (price)
        console.log(key, value);
        cursor.continue();
      } else {
        console.log("Книг больше нет");
      }
    };

    Обёртка для промисов

    Добавлять к каждому запросу onsuccess/onerror немного громоздко. Мы можем сделать нашу жизнь проще, используя делегирование событий, например, установить обработчики на все транзакции, но использовать async/await намного удобнее.

    Давайте далее в главе использовать небольшую обёртку над промисами https://github.com/jakearchibald/idb. Она создаёт глобальный idb объект с промисифицированными IndexedDB методами.

    Тогда вместо onsuccess/onerror мы можем писать примерно так:

    let db = await idb.openDb('store', 1, db => {
      if (db.oldVersion == 0) {
        // выполняем инициализацию
        db.createObjectStore('books', {keyPath: 'id'});
      }
    });
    
    let transaction = db.transaction('books', 'readwrite');
    let books = transaction.objectStore('books');
    
    try {
      await books.add(...);
      await books.add(...);
    
      await transaction.complete;
    
      console.log('сохранено');
    } catch(err) {
      console.log('ошибка', err.message);
    }

    Теперь у нас красивый «плоский асинхронный» код и, конечно, будет работать try..catch.

    Обработка ошибок

    Если мы не перехватим ошибку, то она «вывалится» наружу, вверх по стеку вызовов, до ближайшего внешнего try..catch.

    Необработанная ошибка становится событием «unhandled promise rejection» в объекте window.

    Мы можем обработать такие ошибки вот так:

    window.addEventListener('unhandledrejection', event => {
      let request = event.target; // объект запроса IndexedDB
      let error = event.reason; //  Необработанный объект ошибки, как request.error
      ...сообщить об ошибке...
    });

    Подводный камень: «Inactive transaction»

    Как мы уже знаем, транзакции автоматически завершаются, как только браузер завершает работу с текущим кодом и макрозадачу. Поэтому, если мы поместим макрозадачу наподобие fetch в середину транзакции, транзакция не будет ожидать её завершения. Произойдёт автозавершение транзакции. Поэтому при следующем запросе возникнет ошибка.

    Для промисифицирующей обёртки и async/await поведение такое же.

    Вот пример fetch в середине транзакции:

    let transaction = db.transaction("inventory", "readwrite");
    let inventory = transaction.objectStore("inventory");
    
    await inventory.add({ id: 'js', price: 10, created: new Date() });
    
    await fetch(...); // (*)
    
    await inventory.add({ id: 'js', price: 10, created: new Date() }); // Ошибка

    Следующий inventory.add после fetch (*) не сработает, сгенерируется ошибка «inactive transaction», потому что транзакция уже завершена и закрыта к этому времени.

    Решение такое же, как при работе с обычным IndexedDB: либо создать новую транзакцию, либо разделить задачу на части.

    1. Подготовить данные и получить всё, что необходимо.
    2. Затем сохранить в базу данных.

    Получение встроенных объектов

    Внутренне обёртка выполняет встроенные IndexedDB запросы, добавляя к ним onerror/onsuccess, и возвращает промисы, которые отклоняются или выполняются с переданным результатом.

    Это работает в большинстве случаев. Примеры можно увидеть на странице библиотеки https://github.com/jakearchibald/idb.

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

    let promise = books.add(book); // получаем промис (без await, не ждём результата)
    
    let request = promise.request; // встроенный объект запроса
    let transaction = request.transaction; // встроенный объект транзакции
    
    // ...работаем с IndexedDB...
    
    let result = await promise; // если ещё нужно

    Итого

    IndexedDB можно рассматривать как «localStorage на стероидах». Это простая база данных типа ключ-значение, достаточно мощная для оффлайн приложений, но простая в использовании.

    Лучшим руководством является спецификация.

    Использование можно описать в нескольких фразах:

    1. Подключить обёртку над промисами, например idb.
    2. Открыть базу данных: idb.openDb(name, version, onupgradeneeded)
      • Создайте хранилища объектов и индексы в обработчике onupgradeneeded или выполните обновление версии, если это необходимо
    3. Для запросов:
      • Создать транзакцию db.transaction('books') (можно указать readwrite, если надо).
      • Получить хранилище объектов transaction.objectStore('books').
    4. Затем для поиска по ключу вызываем методы непосредственно у хранилища объектов.
      • Для поиска по любому полю объекта создайте индекс.
    5. Если данные не помещаются в памяти, то используйте курсор.

    Демо-приложение:

    <!doctype html><html lang="ru"><head>
    <script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js"></script>
    </head>
    <body>
    <button onclick="addBook()">Добавить книгу</button>
    <button onclick="clearBooks()">Очистить хранилище</button>
    
    <p>Список книг:</p>
    
    <ul id="listElem"></ul>
    
    <script>
    let db;
    
    init();
    
    async function init() {
      db = await idb.openDb('booksDb', 1, db => {
        db.createObjectStore('books', {keyPath: 'name'});
      });
    
      list();
    }
    
    async function list() {
      let tx = db.transaction('books');
      let bookStore = tx.objectStore('books');
    
      let books = await bookStore.getAll();
    
      if (books.length) {
        listElem.innerHTML = books.map(book => `<li>
            название: ${book.name}, цена: ${book.price}
          </li>`).join('');
      } else {
        listElem.innerHTML = '<li>Книг пока нет. Пожалуйста, добавьте книги.</li>'
      }
    
    
    }
    
    async function clearBooks() {
      let tx = db.transaction('books', 'readwrite');
      await tx.objectStore('books').clear();
      await list();
    }
    
    async function addBook() {
      let name = prompt("Название книги");
      let price = +prompt("Цена книги");
    
      let tx = db.transaction('books', 'readwrite');
    
      try {
        await tx.objectStore('books').add({name, price});
        await list();
      } catch(err) {
        if (err.name == 'ConstraintError') {
          alert("Такая книга уже существует");
          await addBook();
        } else {
          throw err;
        }
      }
    }
    
    window.addEventListener('unhandledrejection', event => {
      alert("Ошибка: " + event.reason.message);
    });
    
    </script>
    </body></html>
    
Справочник JavaScript
×
Справочник JavaScript