IndexedDB: Bases de datos en el lado del cliente con HTML 5

Una de las materias que tenía pendiente era investigar más a fondo otra de las nuevas características de HTML 5: IndexedDB. Lo cierto es que puedo decir que no es una de mis preferidas, ya que no siento que sea muy cómodo trabajar con ella y la curva de aprendizaje es algo más elevadas que el resto de las APIs, pero no deja de ser una opción más para el almacenamiento persistente desde el lado del cliente (hace no mucho tiempo estuvimos viendo Web Storage como alternativa a la que veremos en este post).

Antes de comenzar cabe mencionar que, a día de hoy, ni Opera ni Safari soportan esta característica ya que apostaron por Web SQL Database, la cual ya no será candidata para la release y será reemplazada además por IndexedDB 😉

Para poder comprender parte del funcionamiento, he utilizado como recurso principal HTML 5 Rocks y más en concreto el siguiente artículo, aunque no ha sido suficiente por motivos que veremos después 🙁

¿Qué es IndexedDB?

Se trata de una API para el almacenamiento de información estructurada en el lado del cliente. Cada registro está compuesto de una clave y objeto. Es posible el uso de índices con el fin de mejorar el rendimiento. Para llevar a cabo operaciones sobre la base de datos es necesario el uso de transacciones como veremos en el código de ejemplo.
Es importante saber que la base de datos está asociada al dominio, por lo que pueden existir varias bases de datos con el mismo nombre siempre y cuando estén en un dominio (origen) distinto.

Para poder asentar las bases, vamos a centrarnos en el siguiente ejemplo:

En él podemos añadir distintos valores y agregarlos a la base de datos wishes a través del botón Keep it. Además podemos eliminar los registros que hayamos añadido a través de la X que aparece en cada uno de los elementos.
Para ver el código JavaScript involucrado podemos hacerlo a través de la pestaña de JavaScript de JSFiddle, pero lo iremos desmembrando para comprender cuáles son los pasos a seguir.

Para estructurar el código he utilizado el patrón Revealing Module Pattern, el cual me permite simular una clase con elementos públicos y privados.

Si utilizas la última versión de Firefox (16.02 a fecha de hoy) este ejemplo no funcionará en tu navegador, ya que existe un bug conocido el cual no permite el uso de IndexedDB en las etiquetas frames (utilizado para mostrar el ejemplo con JSFiddle) (Más información).

Paso 1: Inicialización de variables

Toda base de datos en IndexedDB necesita principalmente 3 componentes: un nombre, una versión y object stores. La necesidad de un nombre para la base de datos es obvio, ya que es posible tener más de una base de datos y es necesario identificarlas de alguna manera. La versión de la base de datos se utilizará para tener conocimiento de cuándo la misma ha sufrido modificaciones y es momento de actualizar la estructura que la compone. Por último los object stores es el mecanismo utilizado por la API para guardar la información en la base de datos.

Volviendo a la inicialización de las variables, en el primer apartado de mi código lo primero que hago es crear todas aquellas que son necesarias, además de un par de funciones:

var WishesStore = function() {
    //private members
    var db = null,
        name = null,
        version = null,
        trace = function(msg) {
            //Traces
            console.log(msg);
        },
        init = function(dbname, dbversion) {
            //1.Initialize variables
            name = dbname;
            version = dbversion;
            //2. Make indexedDB compatible
            if (compatibility()) {
                //2.1 Delete database
                //deletedb("wishes");
                //3.Open database
                open();
            }
        },

db será la variable que almacene la referencia a la base de datos para poder trabajar con ella, name el nombre de la base de datos y version la versión que vamos a asociar con la base de datos. Por otro lado, me he creado una función llamada trace para ir mostrando cuál es la secuencia que sigue el código y poder comprender cuáles son los pasos que sigue. Por último utilizo una función llamada init en la que voy a inicializar las variables que me indican el nombre y la versión, comprobar la compatibilidad y abrir la base de datos.

Paso 2: Compatibilidad

Como comentaba al inicio del post, Safari y Opera no soportan aún esta característica pero Internet Explorer 10, Firefox y Chrome no han consolidado la forma de hacerlo :(. Por ello, antes de crear una conexión con la base de datos es necesario llamar a una función que compruebe dónde están almacenadas cada una de las interfaces involucradas y asignar la necesaria directamente en el lugar donde deberían estar según la especificación:

compatibility = function() {
    trace("window.indexedDB: " + window.indexedDB);
    trace("window.mozIndexedDB: " + window.mozIndexedDB);
    trace("window.webkitIndexedDB: " + window.webkitIndexedDB);
    trace("window.msIndexedDB: " + window.msIndexedDB);
    window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB;
    trace("window.IDBTransaction: " + window.IDBTransaction);
    trace("window.webkitIDBTransaction: " + window.webkitIDBTransaction);
    trace("window.msIDBTransaction: " + window.msIDBTransaction);
    window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction;
    trace("window.IDBKeyRange: " + window.IDBKeyRange);
    trace("window.webkitIDBKeyRange: " + window.webkitIDBKeyRange);
    trace("window.msIDBKeyRange: " + window.msIDBKeyRange);
    window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange;
    if (window.indexedDB) {
        var span = document.querySelector("header h1 span");
        span.textContent = "Yes";
        span.style.color = "green";
        return true;
    }
    trace("Your browser does not support a stable version of IndexedDB.");
    return false;
},

window.indexedDB debe ser del tipo IDBFactory y se trata de la interfaz principal para el uso de esta característica. Nos permite hacer una petición para abrir una conexión a una base de datos, eliminar bases de datos existentes y comparar claves. Chrome utiliza en su lugar window.webKitIndexedDB, Internet Explorer hace uso tanto de window.indexedDB y window.msIndexedDB y Firefox window.indexedDB y window.mozIndexedDB (Estos dos navegadores siguen manteniendo ambas opciones por temas de compatibilidades con aplicaciones pasadas).

window.IDBTransaction nos proporciona distintas propiedades como IDBDatabase db la cual almacena la conexión con la base de datos, error donde queda almacenado el error que se ha producido y ha cancelado la transacción así como los distintos eventos que podemos capturar. Más información.

Por último, window.IDBKeyRange se trata de una interfaz que nos permite indicar cuáles son los registros que queremos recuperar usando claves o rangos de claves. Es nuestra forma de realizar queries en IndexedDB 🙂 Más información.

Para poder comprobar de una forma más visual, he dejado las trazas con el único objetivo de poder visualizar a través de la consola cuáles son las claves que contienen las interfaces y cuales no en función del navegador que estamos utilizando para su ejecución.

Una vez que hemos asignado las interfaces a la clave genérica, comprobamos si window.indexedDB está definido con el objetivo de avisar al usuario si su navegador tiene soporte para esta característica, y retornar true para permitir la llamada a la función open, o finalizar en este punto la aplicación.

Paso 3: Abrir conexión con la base de datos

Siendo optimistas :), nuestro navegador cumple las condiciones y somos capaces de ejecutar la función open:

open = function() {
    //3.1. Open a database async
    var request = window.indexedDB.open("wishes", version);
    //3.2 The database has changed its version (For IE 10 and Firefox)
    request.onupgradeneeded = function(event) {
        trace("Upgrade needed!");
        db = event.target.result;
        modifydb(); //Here we can modify the database
    };
    request.onsuccess = function(event) {
        trace("Database opened");
        db = event.target.result;
        //3.2 The database has changed its version (For Chrome)
        if (version != db.version && window.webkitIndexedDB) {
            trace("version is different");
            var setVersionreq = db.setVersion(version);
            setVersionreq.onsuccess = modifydb; //Here we can modify the database
        }
        trace("Let's paint");
        items(); //4. Read our previous objects in the store (If there are any)
    };
    request.onerror = function(event) {
        trace("Database error: " + event);
    };
},

Normalmente, en otras APIs primero declaramos qué es lo que puede ocurrir y acto seguido lanzamos la petición (como en FileReader por ejemplo). En el caso de IndexedDB estamos solicitando primeramente la conexión con la base de datos a través de la función open y acto seguido tenemos en cuenta las posibles consecuencias:
Si es la primera vez que se accede a la base de datos (es nueva) o la versión ha cambiado se lanzará el evento onupgradeneeded. Una vez manejado el mismo podremos modificar o establecer la estructura de la base de datos a través de la función modifydb, que veremos a continuación. Sin embargo, si bien este evento es a día de hoy el oficial, sólo lo están teniendo en cuenta IE 10 y Firefox. Chrome sigue implementando la vía obsoleta setVersion, indicada más abajo. Para mantener la compatibilidad entre navegadores, he incluido esta opción para Chrome que finalmente desemboca en el mismo resultado: en caso de éxito la función encargada de la modificación de la base de datos sigue siendo modifydb 🙂

En cualquiera de los dos supuestos, debemos almacenar el contenido de event.target.result para almacenar en un lugar común el enlace a la conexión con base de datos. En este caso en la variable db.

¿Qué contiene modifydb?

modifydb = function() {
    //3.3 Create / Modify object stores in our database 
    //2.Delete previous object store
    if (db.objectStoreNames.contains("mywishes")) {
        db.deleteObjectStore("mywishes");
        trace("db.deleteObjectStore('mywishes');");
    }
    //3.Create object store
    var store = db.createObjectStore("mywishes", {
        keyPath: "timeStamp"
    });

},

Dentro de esta función vamos a ser capaces de eliminar object Stores, crearlos, modificarlos, etcétera.

Cada Object Store tiene un nombre (en este caso mywishes), el cual debe ser único dentro de la base de datos. Cada uno de ellos tiene una clave (keyPath) necesarias para identificar cada registro. En este caso, se utiliza un timeStamp pero podría ser un Array, float o Date. Este ejemplo es bastante básico, debido a todos los conceptos a los que nos debemos enfrentar. Para más información podemos ver la especificación oficial: Object Store Concept.

Como vemos en el fragmento de código, lo primero que comprobamos es si el object store mywishes ya existe. De ser así, lo eliminaremos a través de db.deleteObjectStore. Acto seguido utilizamos de nuevo db pero en esta ocasión para la creación, pasándo como parámetros el nombre del Object store y, a través de un object literal, el keyPath.

Llegados a este punto, deberíamos tener una base de datos con un object store llamado mywishes. Para comprobarlo, si utilizamos Chrome podemos visualizarlo a través de la herramienta para desarrolladores (F12):

A partir de este momento ya estamos listos para manipular nuestra nueva base de datos con IndexedDB 🙂

Eliminación de bases de datos

Otro de los casos que se nos puede presentar es que necesitemos eliminar alguna de las bases de datos que anteriormente fue creada para un dominio concreto. Básicamente a mi me surgió después de numerosas pruebas y numerosas bases de datos creadas 🙂 por lo que tuve que revisar cuál era la implementación necesaria para ello. En el código de ejemplo está comentada su llamada ya que he creído oportuno que el lector o, para otro desarrollo, quisiera eliminar una base de datos (como por ejemplo la generada para este post :))

deletedb = function(dbname) {
    trace("Delete " + dbname);
    var request = window.indexedDB.deleteDatabase(dbname);
    request.onsuccess = function() {
        trace("Database " + dbname + " deleted!");
    };
    request.onerror = function(event) {
        trace("deletedb(); error: " + event);
    };
},

Para ello, basta con hacer uso de window.indexedDB.deleteDatabase y el nombre de la base de datos que queremos eliminar. Al ser una petición asíncrona, podemos recuperar los eventos success y error para conocer el resultado de la operación.

Paso 4: Añadir registros

Para añadir registros a nuestra recien creada base de datos podemos hacerlo a través de los siguientes pasos:

add = function () {
    //6. Add objects
    trace("add();");
    var trans = db.transaction(["mywishes"], "readwrite"),
        store = trans.objectStore("mywishes"),
        wish = document.getElementById("wish").value;
    var data = {
        text: wish,
        "timeStamp": new Date().getTime()
    };
    var request = store.add(data);
    request.onsuccess = function (event) {
        trace("wish added!");
        items(); //6.1 Read items after adding
    };
};

En primer lugar es necesario abrir una transacción indicando el object store involucrado y el tipo de transacción que necesitamos. Si nos fijamos en la llamada, en el primer parámetro estamos pasando un array con un sólo valor, pero podemos indicar distintos object stores dentro de una misma transacción. Como vamos a manipular el contenido, este debe ser de lectura y escritura («readwrite»).
Una vez que la transacción se ha abierto con éxito, recuperaremos el store como tal y, en este caso, el valor que queremos incluir para montar nuestro objeto. En este caso, por simplicidad, el objeto es bastante sencillo y sólo se almacena el texto del input que tenemos en nuestro código y el timeStamp necesario para que nuestro registro sea único.
Una vez que ya tenemos toda la información que queremos almacenar, la añadiremos a través de store.add(). En este ejemplo estamos utilizando peticiones asíncronas por lo que es recomendable capturar al menos los eventos onsuccess y onerror para saber si la petición ha finalizado con éxito o no y saber cómo proceder.

Paso 5: Recuperación de registros

Una vez que ya hemos creado el lugar donde vamos a almacenar nuestra información y hemos añadido algunos registros, estamos listos para recuperar datos.

Esta función está basada en la misma que se utiliza en html 5 rocks, la cual utiliza a su vez una función llamada render para plasmar el resultado en el documento HTML:

items = function() {
    //Read
    trace("items(); called");
    var list = document.getElementById("list"),
        trans = db.transaction(["mywishes"], "readonly"),
        store = trans.objectStore("mywishes");
    list.innerHTML = "";
    var keyRange = IDBKeyRange.lowerBound(0);
    var cursorRequest = store.openCursor(keyRange);
    cursorRequest.onsuccess = function(event) {
        trace("Cursor opened!");
        var result = event.target.result;
        if (result === false || result === null){
            return;
        }
                
        render(result.value); //4.1 Create HTML elements for this object
        result.continue ();
    };
},
render = function(item) {
    //4.1 Create DOM elements
    trace("Render items");
    var list = document.getElementById("list"),
        li = document.createElement("li"),
        a = document.createElement("a"),
        text = document.createTextNode(item.text);
    a.textContent = " X";
    //5. Delete elements
    a.addEventListener("click", function() {
        del(item.timeStamp);
    });
    li.appendChild(text);
    li.appendChild(a);
    list.appendChild(li);
},

Siempre que vayamos a hacer uso de la base de datos, es necesario obtener una transacción sobre la que operar. En este caso, a diferencia que en HTML 5 Rocks, he utilizado readonly ya que sólo necesitamos leer información sin alterar su resultado. Por otro lado, recuperaremos un enlace al store ya que es necesario abrir un cursor a través de él con el que poder iterar en cada uno de los resultados. Para abrir este último es necesario indicar el límite del valor que deben cumplir todos los registros devueltos, a través de IDBKeyRange:

  • lowerBound(x): Todas las claves tienen como mínimo a partir el valor indicado.
  • lowerBound(x,true): Todas las claves son menores o iguales que el límite indicado.
  • upperBound(x): Todas las claves tienen como máximo el límite indicado.
  • upperBound(x,true): Todas las claves son mayores o iguales que el límite indicado.
  • bound(x,y,true | false, true |false): Todas las claves son mayores (o iguales) que el primer límite y mejores (o iguales) que el segundo límite.
  • only: La clave es igual que el valor indicado.

En este ejemplo, se utiliza el valor cero porque sabemos que todos van a sobre pasar ese límite al ser un timeStamp, por lo que obtendremos todos los valores almacenados.
Una vez que la consulta se haya lanzado capturaremos el evento onsuccess donde podremos recorrer cada uno de los resultados. Este evento se lanzará por cada uno de los registros que obtengamos, por lo que podemos utilizarlo a modo de bucle.
En este ejemplo se almacena el resultado localizado en event.target.result y se comprueba si es false o null y llamamos al método render abajo mostrado donde únicamente se creará el código HTML necesario para mostrarlo al usuario y, una vez finalizado el proceso, llamamos a la función continue para que el evento vuelva a lanzarse con el próximo registro.

En la función render, además de generar el código HTML se crea un enlace al que asociamos el evento click. En él llamaremos a la función del donde podremos eliminar el objeto si el usuario pulsa sobre la X que muestra dicho enlace.

Paso 6: Eliminación de registros

Cuando queremos eliminar alguno de los objetos que hemos almacenado, el proceso es bastante sencillo y similar que los anteriores:

del = function(timeStamp) {
    //5. Delete items
    var transaction = db.transaction(["mywishes"], "readwrite");
    var store = transaction.objectStore("mywishes");
    var request = store.delete(timeStamp);
    request.onsuccess = function(event) {
        trace("Item deleted!");
        items(); //5.1 Read items after deleting
    };
    request.onerror = function(event) {
        trace("Error deleting: " + e);
    };
},

Como en todos aquellos casos donde necesitamos manipular la información, establecemos una transacción donde indicamos en esta ocasión que sea de lectura y escritura a través de «readwrite». Una vez creada, recuperamos el store sobre el que queremos operar y llamamos a la función store.delete con el timeStamp (KeyPath) del objeto que queremos eliminar. Al ser una petición asíncrona, como el resto que ya hemos visto ;), capturamos los eventos onsuccess (y leemos de nuevo la información) y onerror (mostramos el error ocurrido) para saber cómo proceder después.

Obviamente no pretendo mostrar toda la funcionalidad de la API pero si la suficiente para poder ver un ejemplo real de lo que podemos llegar a hacer con ella. Al principio del post comentaba que no es una de mis APIs preferidas y básicamente es porque considero que son necesarias demasiadas líneas para poder hacer uso de ella y estamos expuestos a mayor número de errores. Por otro lado no todos los navegadores están al día en la misma y esto puede ser un inconveniente si queremos hacer uso de esta característica a día de hoy.
Por supuesto tiene puntos fuertes como la creación de índices para la mejora del rendimiento (no vistos en este post) y el uso de transacciones, dos puntos que no están contemplados en Web Storage y pueden ser motivo más que suficiente para su implementación en algunos casos.

Para poder jugar con el ejemplo de este post podéis hacerlo a través del siguiente enlace.

Espero que os haya servido de utilidad 🙂

¡Saludos!