Готовим IndexedDB



    На Хабре уже рассказывали про IndexedDB — стандарт хранения больших структурированных данных на клиенте. Но это было давно и API сильно изменился. Несмотря на это в поиске статья всплывает одной из первых и вводит в заблуждение многих, кто начинает пытатся работать с этой технологией. Поэтому я решил написать новую статью с информацией об актуальном API.

    Что такое IndexedDB


    IndexedDB — это объектная база данных, которая намного мощнее, эффективнее и надежней, чем веб-хранилище пар ключ/значение, доступное посредством прикладного интерфейса Web Storage. Как и в случае прикладных интерфейсов к веб-хранилищам и файловой системе, доступность базы данных определяется происхождением создавшего ее документа.

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

    Помимо возможности извлекать объекты из хранилища по значению первичного ключа существует также возможность выполнять поиск по значениям других свойств объекта. Чтобы обеспечить эту возможность, в хранилище объектов можно определить любое количество индексов. Каждый индекс определяет вторичный ключ хранимых объектов. Эти индексы в целом могут быть неуникальными, и одному и тому же ключу может соответствовать множество объектов. Поэтому в операциях обращения к хранилищу объектов с использованием индекса обычно используется курсор, определяющий прикладной интерфейс для извлечения объектов из потока результата по одному.

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

    Приступим


    IE > 9, Firefox > 15 и Chrome > 23 поддерживают работу без префиксов, но все-таки лучше проверять все варианты:

    var indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
    var IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction;
    


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


    Работа с базой данных начинается с запроса на открытие:

    var request = indexedDB.open("myBase", 1);
    


    Метод open возвращает объект IDBOpenDBRequest, в котором нас интересует три обработчика событий:
    • onerror;
    • onsuccess;
    • onupgradeneeded.


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

    Onsuccess будет вызван если все прошло успешно, но экземпляр открытой базы данных в качестве параметра метод не получит. Открытая БД доступна из объекта запроса: request.result.

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

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

    function connectDB(f){
    	var request = indexedDB.open("myBase", 1);
    	request.onerror = function(err){
    		console.log(err);
    	};
    	request.onsuccess = function(){
    		// При успешном открытии вызвали коллбэк передав ему объект БД
    		f(request.result);
    	}
    	request.onupgradeneeded = function(e){
    		// Если БД еще не существует, то создаем хранилище объектов.
    		e.currentTarget.result.createObjectStore("myObjectStore", { keyPath: "key" });
    		connectDB(f);
    	}
    }
    

    где f — это функция, которой будет передана открытая база данных.

    Структура базы данных


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

    Относительно ключевого поля существует 4 стратегии поведения:
    • Ключевое поле не указано, и атогенерация ключа не включена — тогда вы должны вручную указывать ключ при каждом добавлении новой записи;
    • Ключевое поле указано, автогенерация выключена — ключевое поле является ключом;
    • Ключевое поле не указано, автогенерация включена — IndexedDB сам генерирует значение ключа, но можно указать свое значение ключа при добавлении новой записи;
    • Ключевое поле указано, автогенерация включена — если у нового элемента отсутствует ключевое свойство, то IndexedDB сгенерирует новое значение.

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

    Создавать ObjectStore можно с помощью метода createObjectStore. При создании ObjectStore можно указать его имя и параметры, например, ключевое поле. Индекс базы данных можно создавать с помощью метода createIndex. При создании индекса можно указать его имя, поле по которому его необходимо построить, и параметры, например, уникальность ключа:

    objectStore.createIndex("name", "name", { unique: false });
    


    Работа с записями


    Как уже говорилось во введении, любые операции с записями в IndexedDB происходят в рамках транзакции. Транзакция открывается методом transaction. В методе необходимо указать какие ObjectStore вам нужны и режим доступа: чтение, чтение и запись, смена версии. Режим смены версии по сути аналогичен методу onupgradeneeded.

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

    db.transaction(["myObjectStore"], "readonly");
    


    Теперь, когда у нас есть открытая транзакция, мы можем получить наш ObjectStore у которого есть следующие методы для работы с записями:
    • add — добавляет строго новую запись, если попытаться добавить запись с уже существующим ключом, то получим ошибку;
    • put — перезаписывает или создает новую запись по указанному ключу;
    • get — возвращает запись по ключу;
    • delete — удаляет запись по указанному ключу.


    Курсор

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

    var customers = [];
    
    objectStore.openCursor().onsuccess = function(event) {
      var cursor = event.target.result;
      if (cursor) {
        customers.push(cursor.value);
        cursor.continue();
      }
      else {
        alert("Got all customers: " + customers);
      }
    };
    


    Но ребятам из Mozilla, как и мне, такой способ получения всех записей показался неудобным и они сделали метод который сразу возвращает все содержимое ObjectStore: mozGetAll. Надеюсь, в будущем и остальные браузеры его реализуют.

    Индекс

    Если вы хотите получить значение используя индекс, то все тоже довольно просто:

    var index = objectStore.index("name");
    index.get("Donna").onsuccess = function(event) {
      alert("Donna's SSN is " + event.target.result.ssn);
    };
    


    Ограничения



    Размер

    По размеру ограничений почти что нет. Firefox ограничивает только размерами жесткого диска, но при условии, что на каждые дополнительные 50 мегабайт потребуется подтверждение пользователя. Chrome может занять под базы данных всех веб-страниц, которые их создали, половину жесткого диска, при этом ограничивая каждую базу данных 20% от этой половины.

    Поддержка браузерами


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

    Пример


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

    var indexedDB 	  = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB,
    	IDBTransaction  = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction,
    	baseName 	  = "filesBase",
    	storeName 	  = "filesStore";
    
    function logerr(err){
    	console.log(err);
    }
    
    function connectDB(f){
    	var request = indexedDB.open(baseName, 1);
    	request.onerror = logerr;
    	request.onsuccess = function(){
    		f(request.result);
    	}
    	request.onupgradeneeded = function(e){
    		e.currentTarget.result.createObjectStore(storeName, { keyPath: "path" });
    		connectDB(f);
    	}
    }
    
    function getFile(file, f){
    	connectDB(function(db){
    		var request = db.transaction([storeName], "readonly").objectStore(storeName).get(file);
    		request.onerror = logerr;
    		request.onsuccess = function(){
    			f(request.result ? request.result : -1);
    		}
    	});
    }
    
    function getStorage(f){
    	connectDB(function(db){
    		var rows = [],
    			store = db.transaction([storeName], "readonly").objectStore(storeName);
    
    		if(store.mozGetAll)
    			store.mozGetAll().onsuccess = function(e){
    				f(e.target.result);
    			};
    		else
    			store.openCursor().onsuccess = function(e) {
    				var cursor = e.target.result;
    				if(cursor){
    					rows.push(cursor.value);
    					cursor.continue();
    				}
    				else {
    					f(rows);
    				}
    			};
    	});
    }
    
    function setFile(file){
    	connectDB(function(db){
    		var request = db.transaction([storeName], "readwrite").objectStore(storeName).put(file);
    		request.onerror = logerr;
    		request.onsuccess = function(){
    			return request.result;
    		}
    	});
    }
    
    function delFile(file){
    	connectDB(function(db){
    		var request = db.transaction([storeName], "readwrite").objectStore(storeName).delete(file);
    		request.onerror = logerr;
    		request.onsuccess = function(){
    			console.log("File delete from DB:", file);
    		}
    	});
    }
    


    Заключение


    IndexedDB уже в полной мере поддерживается браузерами и готово к употреблению. Это прекрасный инструмент для создания автономных веб-приложений, но использовать его нужно все-таки с умом. Где можно обойтись WebStorage — лучше обойтись WebStorage. Где можно ничего не хранить на клиенте, лучше ничего не хранить на клиенте.

    Сейчас становится все больше библиотек, которые инкапсулируют внутри себя работу с WebStorage, FileSystem API, IndexedDB и WebSQL, но, по-моему, лучше написать хотя бы раз свой код, чтобы потом не тащить, когда не нужно, кучу чужого кода без понимания его работы.

    Больше информации на MDN.
    • +26
    • 51.2k
    • 8
    Share post

    Comments 8

      +1
      Самое интересное в поддержке браузерами IndexedDB это отсутствие поддержки в iOS даже 7 версии.
      Я вот хотел написать мобильное веб-приложение, а теперь ломаю голову, как обойти это ограничение не переписывая дважды логику локального репозитория объектов
        +1
        Использовать полифилл или обертку типа PouchDB.
          0
          О, кажется неплохая библиотека. Спасибо
            0
            Она такая и не одна, поищите еще (сходу названий не вспомню).
          +1
          Можно, к примеру, использовать IndexedDBShim. iOS поддерживает WebSQL.
          +2
          Спасибо, добрый человек!
          А то мне уже с десяток писем написали, что статья устарела, а самим написать — руки отвалятся :)
            0
            Думаю, ни о какой скорости речи даже быть не может, по сравнению с тем же sqlite.

            Ведь данные идут по циклу и цикл снаружи, и применяется фильтрация на стороне клиента.

            О ужас.

            Чем им sqlite то не понравился.

            То есть, получается, что это какая-то помесь localstorage + индексы. И ничего более.

            Читаем:

            18 ноября 2010 г. консорциум W3C объявило прекращении поддержки СУБД Web SQL. В связи с этим разработчикам более не рекомендуется использовать эту технологию, так как выпуск обновлений для нее прекращен, а поставщики браузеров не заинтересованы в ее дальнейшем развитии.
            Заменой для Web SQL являются индексированные базы данных (которым, собственно, и посвящено данное руководство), предлагаемые разработчикам для хранения и обработки данных в клиентских приложениях.


            По сути, они приговорили к смерти будущие клиентские приложения.
            Ибо они будут медленными и/или ограниченными в чём то (из-за низкой скорости работы по поиску данных).

            Хорошо, если sqlite не выкинут из браузеров. Ибо я не вижу вообще смысла использовать indexeddb, если конечно, это не простой список дел на 100 элементов =))

            Например, для серьёзного проекта может понадобиться поиск по слову для 1 миллион записей. Представляю, как это будет тормозить при этом самом внешнем цикле и проверке даже строковыми функциями =))

            Конечно, кто-то скажет, что можно сделать индекс по словам в отдельную таблицу, приводить-нормализировать, выбирать сначала оттуда, потом по индексам из основной таблицы (ведь обычно на странице бывает не более 10...20...50 элементов, поэтому тормозить не будет так сильно). По идее, правильно, в стиле сфинска для mysql.
            Но основной фокус в том, что страничник показать будет возможно, только узнав полное количество записей по данному ключу, а это можно будет сделать, только перебрав все *миллионы* записей =))
            Ибо какого-то возвращения количества записей в этих курсорах нет.

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

            В общем, ребята тут что-то намудрили. Неужели не проще использовать уже готовый продукт в виде sqlite и мощных универсальных языков запросов SQL?

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

            У кого-нибудь есть реальные замеры скорости на реальном серьёзном приложении, которое использует indexeddb и fulltext search на > 10000 записей?
              –1
              То есть, получается, что это какая-то помесь localstorage + индексы. И ничего более.

              localstorage + таблицы + индексы + большой объем + курсор

              Чем им sqlite то не понравился.

              Думаю можно найти статью где подробно описываются недостатки sqlite. Скорее всего где-то на MDN, т.к. основными противниками были ребята из Mozilla и MS. В первую очередь в голову приходит сложный механизм блокировок, относительно низкая скорость записи и возможно какие-то нюансы кросс-платформенности. Но скорее всего проблема чисто идеологическая. Вы уверены, что хранить миллион записей на клиенте это нормально? Приложение обязательно должно работать автономно и без этой фичи полностью теряет свой смысл?

              Обертка поддерживающая язык запросов не проблема. Использую либу поддерживающую операторы сравнения, логические связки, сортировку, contains, distinct, различные агрегаторы (max, min, sum) и прочее счастье. Но все это счастье на массивах. Вот думаю расширять ли ее на IndexedDB, но операции с таблицами не очень в синтаксис вписываются.

              У кого-нибудь есть реальные замеры скорости на реальном серьёзном приложении, которое использует indexeddb и fulltext search на > 10000 записей?

              Сейчас проверил простейшим performance.now() — у меня like по сто тысячной коллекции проходит примерно за 400мс. Но это инструмент абстрактных запросов, заточки под текстовый поиск в нем нет. На Хабре проскакивали различные JS-фильтры, возможно, они еще быстрее. Не знаю сколько к этому добавит выборка из базы. С выборкой малого количества больших записей (текстовые файлы) проблем по скорости не замечал. В принципе полная выборка и не нужна, есть же курсор, чтобы итерироватся по таблице. Тогда будет один цикл, не знаю где вы три штуки насчитали. Но повторюсь: работа с таким объемом данных на клиенте выглядит странно.

            Only users with full accounts can post comments. Log in, please.