Capítulo 8 Almacenamiento local

El almacenamiento de datos es fundamental en cualquier aplicación web o de escritorio. Hasta ahora, el almacenamiento de datos en la web se realizaba en el servidor, y era necesario algún tipo de conexión con el cliente para trabajar con estos datos. Con HTML5 disponemos de tres tecnologías que permiten que las aplicaciones almacenen datos en los dispositivos cliente. Según las necesidades de la aplicación, la información puede sincronizarse también con el servidor o permanecer siempre en el cliente. Estas son las posibilidades que tenemos:

  • Web Storage: http://www.w3.org/TR/webstorage/. Es el sistema de almacenamiento más simple, ya que los datos se almacenan en parejas de clave/valor. Ampliamente soportado por todos los navegadores.
  • Web SQL Database: http://www.w3.org/TR/webdatabase/. Sistema de almacenamiento basado en SQL. La especificación indica que no va a ser mantenido en el futuro, pero actualmente su uso está muy extendido y es soportado por Chrome, Safari y Opera.
  • IndexedDB: http://www.w3.org/TR/Indexeddb/. Sistema de almacenamiento basado en objetos. Actualmente soportado por Chrome, Firefox e Internet Explorer.

8.1 Web Storage

Este API de almacenamiento ofrece dos posibilidades para guardar datos en el navegador: sessionStorage y localStorage. El primero mantiene los datos durante la sesión actual (mientras la ventana o pestaña se mantenga abierta), mientras que el segundo almacena los datos hasta que sean eliminados explícitamente por la aplicación o el usuario. Ambos modos de almacenamiento se encuentran relacionados con el dominio que los ha creado.

  • sessionStorage: objeto global que mantiene un área de almacenamiento disponible a lo largo de la duración de la sesión de la ventana o pestaña. La sesión persiste mientras que la ventana permanezca abierta y sobrevive a recargas de página. Si se abre una nueva página en una pestaña o ventana, una nueva sesión es inicializada, por lo que no es posible acceder a los datos de otra sesión.
  • localStorage: el almacenamiento local por su parte, funciona de la misma forma que el almacenamiento de sesión, con la excepción de que son capaces de almacenar los datos por dominio y persistir más allá de la sesión actual, aunque el navegador se cierre o el dispositivo se reinicie.

Nota

Cuando hacemos referencia la ventana o pestaña, nos estamos refiriendo al objeto window. Una nueva ventana abierta utilizando el método window.open(), pertenece a la misma sesión.

Tanto sessionStorage como localStorage forman parte del Web Storage, por lo que comparten el mismo API:

readonly attribute unsigned long length;
getter DOMString key(in unsigned long index);
getter DOMString getItem(in DOMString key);
setter creator void setItem(in DOMString key, in any data);
deleter void removeItem(in DOMString key);
void clear();

Este API hace que sea muy sencillo acceder a los datos. El método setItem almacena el valor, y el método getItem lo obtiene, como se muestra a continuación:

sessionStorage.setItem('twitter', '@starkyhach');
alert( sessionStorage.getItem('twitter') ); // muestra @starkyhach

Es importante darse cuenta, que tal y como se indica en el API, el método getItem siempre devuelve un String, por lo que si intentamos almacenar un objeto, el valor devuelto será "[Object object]". El mismo problema ocurre con los número, por lo que es importante tenerlo en cuenta para evitar posibles errores. Por ejemplo:

sessionStorage.setItem('total', 120);
function calcularCosteEnvio(envio) {
    return sessionStorage.getItem('total') + envio;
}
 
alert(calcularCosteEnvio(25));

En este caso, esperamos que el coste total (120) se almacene como número, y al añadir el coste del envío, el resultado sea 145. Pero como sessionStorage devuelve un String, el resultado no es el esperado sino 12025.

8.1.1 Eliminando datos

Disponemos de tres formas de eliminar datos del almacenamiento local: utilizando delete, removeItem y clear. El método removeItem toma como parámetro el nombre de la clave a eliminar (el mismo que utilizamos en getItem y setItem), para eliminar un ítem en particular. Por su parte, el método clear, elimina todas las entradas del objeto.

sessionStorage.setItem('twitter', '@starkyhach');
sessionStorage.setItem('flickr', 'starky.hach');
alert( sessionStorage.length );                     // Muestra 2
sessionStorage.removeItem('twitter');
alert( sessionStorage.length );                     // Muestra 1
sessionStorage.clear();
alert( sessionStorage.length );                     // Muestra 0

8.1.2 Almacenando algo más que strings

Una manera de almacenar objetos es utilizando JSON. Como la representación de los objetos en JSON puede realizarse a través de texto, podemos almacenar estas cadenas de texto y recuperarlas posteriormente para convertirlas en objetos.

var videoDetails = {
        title : 'Matrix',
        author : ['Andy Wachowski', 'Larry Wachowski'],
        description : 'Wake up Neo, the Matrix has you...',
        rating: '-2'
};
 
sessionStorage.setItem('videoDetails', JSON.stringify(videoDetails) );
var videoDetails = JSON.parse(sessionStorage.getItem('videoDetails');

8.1.3 Eventos de almacenamiento

Una de las funcionalidades más interesantes de Web Storage es que incluye una serie de eventos que nos indican cuándo se ha producido un cambio en los datos almacenados. Estos eventos no se lanzan en la ventana actual donde se han producido los cambios, sino en el resto de ventadas donde los datos pueden verse afectados.

Esto quiere decir que los eventos para sessionStorage son lanzados en los iframe dentro de la misma página, o en las ventanas abiertas con window.open(). Para localStorage, todas las ventanas abiertas con el mismo origen (protocolo + host + puerto) reciben los eventos.

Cuando se lanzan los eventos, éstos contienen toda la información asociada con el cambio de datos:

StorageEvent {
    readonly DOMString key;
    readonly any oldValue;
    readonly any newValue;
    readonly DOMString url;
    readonly Storage storageArea;
};

storageArea hace referencia al objeto sessionStorage o localStorage. Estos eventos se lanzan dentro del objeto window:

function handleStorage(event) {
    event = event || window.event; // support IE8
    if (event.newValue === null) { // it was removed
        // Do somthing
    } else {
        // Do somthing else
    }
}
window.addEventListener('storage', handleStorage, false);
window.attachEvent('storage', handleStorage);

Ejercicio 8

Ver enunciado

8.2 Web SQL

Web SQL es otra manera de almacenar y acceder a datos en el dispositivo. Realmente, no forma parte de la especificación de HTML5, pero es ampliamente utilizado para el desarrollo de aplicaciones web. Como su nombre indica, es una base de datos basada en SQL, más concretamente en SQLite. La utilización del API se resume en tres simples métodos:

  • openDatabase: abrir (o crear y abrir) una base de datos en el navegador del cliente.
  • transaction: iniciar una transacción.
  • executeSql: ejecutar una sentencia SQL.

Como en la mayoría de librearías de JavaScript, el API de Web SQL realiza llamadas diferidas a funciones, una vez completadas las operaciones.

transaction.executeSql(sql, [], function () {
    // my executed code lives here
});

Debido a la naturaleza de estas llamadas, significa que el API de Web SQL es asíncrono, por lo que es necesario tener cuidado con el orden en el que se ejecutan las sentencias SQL. Sin embargo, las sentencias SQL se encolan y son ejecutadas en orden, por lo que podemos estar tranquilos en ese sentido: podemos crear tablas y tener la seguridad que van a ser creadas antes de acceder a los datos.

8.2.1 Creando y abriendo la BD

El uso clásico de la API implica abrir (o crear) la base de datos y ejecutar algunas sentencias SQL. Al abrir la base de datos por primera vez, ésta es creada automáticamente. Es necesario especificar el número de versión de base de datos con el que se desea trabajar, y si no especificamos correctamente éste número de versión, es posible que provoquemos un error del tipo INVALID_STATE_ERROR.

var db = openDatabase('mydb', '1.0', 'My first database', 2 * 1024 * 1024);

La última versión de la especificación incluye un quinto argumento en la función openDatabase, pero no es soportado por muchos navegadores. Los valores que pasamos a esta función son los siguientes:

  1. El nombre de la base de datos.
  2. El número de versión de base de datos con el que deseamos trabajar.
  3. Un texto descriptivo de la base de datos.
  4. Tamaño estimado de la base de datos, en bytes.

El valor de retorno de esta función es un objeto que dispone de un método transaction, a través del cual vamos a ejecutar las sentencias SQL.

8.2.2 Transacciones

Ahora que tenemos la base de datos abierta, podemos crear transacciones para ejecutar nuestras sentencias SQL. La idea de utilizar transacciones, en lugar de ejecutar las sentencias directamente, es la posibilidad de realizar rollback. Esto quiere decir, que si la transacción falla por algún motivo, se vuelve al estado inicial, como si nada hubiese pasado.

db.transaction(function (tx) {
    tx.executeSql('DROP TABLE foo');
 
    // known to fail - so should rollback the DROP statement
    tx.executeSql('INSERT INTO foo (id, text) VALUES (1, "foobar")');
}, function (err) {
    alert(err.message);
});

Una vez listo el objeto transacción (tx en el ejemplo), podemos ejecutar sentencias SQL.

8.2.3 Ejecutar sentencias SQL

El método executeSql es utilizado tanto para sentencias de escritura como de lectura. Incluye protección contra ataques de inyección SQL y proporciona llamadas a métodos (callback) para procesar los resultados devueltos por una consulta SQL.

Su sintaxis es la siguiente:

db.transaction(function (tx) {
    tx.executeSql(sqlStatement, arguments, callback, errorCallback);
});

Donde:

  1. sqlStatement: indica la sentencia SQL a ejecutar. Como hemos dicho, puede ser cualquier tipo de sentencia; creación de tabla, insertar un registro, realizar una consulta, etc.
  2. arguments: corresponde con un array de argumentos que pasamos a la sentencia SQL. Es recomendable pasar los argumentos a la sentencia de esta manera, ya que el propio método se ocupa de prevenir inyecciones SQL.
  3. callback: función a ejecutar cuando la transacción se ha realizado de manera correcta. Toma como parámetros la propia transacción y el resultado de la transacción.
  4. erroCallback: función a ejecutar cuando la transacción se ha producido un error en la sentencia SQL. Toma como parámetros la propia transacción y el error producido.

Un ejemplo de selección de registros sería el siguiente:

db.transaction(function (tx) {
    tx.executeSql('SELECT * FROM foo WHERE id = ?', [5],
                  function callback(tx, results) {
                      var len = results.rows.length, i;
                      for (i = 0; i < len; i++) {
                        alert(results.rows.item(i).text);
                      }
                    },
                  function errorCallback(tx, error) {
                    alert(error.message);
                  }
                  );
});

La función de callback recibe como argumentos la transacción (de nuevo) y un objeto que contiene los resultados. Este objeto contiene una propiedad rows, donde rows.item(i) contiene la representación de la fila concreta. Si nuestra tabla contiene un campo que se llama nombre, podemos acceder a dicho campo de la siguiente manera:

results.rows.item(i).nombre

8.2.4 Crear tablas

La primera tarea a realizar cuando trabajamos con una base de datos es crear las tablas necesarias para almacenar los datos. Como hemos comentado antes, este proceso se realiza a través de una sentencia SQL, dentro de una transacción. Vamos a crear una base de datos para almacenar tweets que posteriormente obtendremos de internet:

db = openDatabase('tweetdb', '1.0', 'All my tweets', 2 * 1024 * 1024);
db.transaction(function (tx) {
    tx.executeSql('CREATE TABLE IF NOT EXISTS tweets(id, user, date, text)', [], getTweets);
});

8.2.5 Insertar datos

Una vez creada la tabla, el siguiente paso es insertar los datos correspondientes. Vamos a suponer que hemos realizado una petición AJAX a la API de Twitter, que nos ha devuelto una serie de tweets que queremos almacenar en nuestra base de datos. La función getTweets tendría este aspecto:

function getTweets() {
    var tweets = $.ajax({...});
    $.each(tweets, function(tweet) {
        db.transaction(function (tx) {
            var time = (new Date(Date.parse(tweet.created_at))).getTime();
            tx.executeSql('INSERT INTO tweets (id, user, date, text) VALUES (?, ?, ?, ?)',
                          [tweet.id, tweet.from_user, time / 1000, tweet.text]);
        });
    });
}

Nota

Insertando cada tweet en una nueva transacción, nos aseguramos que si se produce un error en alguna de ellas (ya existía el tweet), se van a seguir ejecutando el resto transacciones.

8.2.6 Obtener datos

Finalmente, una vez que los datos se encuentran almacenados en la base de datos, sólo nos queda consultarlos. Como siempre, ejecutamos las sentencia SQL dentro de una transacción y proveemos la función que procesará los datos obtenidos:

db.transaction(function (tx) {
    tx.executeSql('SELECT * FROM tweets WHERE date > ?', [time],
                    function(tx, results) {
                        var html = [], len = results.rows.length;
                        for (var i = 0; i < len; i++) {
                            html.push('<li>' + results.rows.item(i).text + '</li>');
                        }
                        tweetEl.innerHTML = html.join('');
                    });
                });

Ejercicio 9

Ver enunciado

8.3 IndexedDB

IndexedDB no es una base de datos relacional, sino que se podría llamar un almacén de objetos ya que en la base de datos que creemos, existen almacenes y en su interior añadimos objetos (como el siguiente):

{
    id:21992,
    nombre: "Memoria RAM"
}

En IndexedDB, al igual que en Web SQL, al abrir una base de datos debemos indicar su nombre y la versión concreta. Posteriormente debemos crear los almacenes de objetos, que es muy parecido a un archivador con índices, que nos permite encontrar de una manera muy rápida el objeto que buscamos. Una vez el almacén está listo, podemos almacenar cualquier tipo de objeto con el índice que definamos. No importa el tipo de objeto que almacenemos, ni tienen que tener las mismas propiedades.

8.3.1 Creando y abriendo la BD

De forma similar a como hacíamos en Web SQL, vamos a abrir una base de datos, y trabajar directamente con el objeto que nos devuelve. De nuevo, al igual que en Web SQL, las peticiones a la base de datos se realizan de manera asíncrona.

window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB;
 
if ('webkitIndexedDB' in window) {
    window.IDBTransaction = window.webkitIDBTransaction;
    window.IDBKeyRange = window.webkitIDBKeyRange;
}
 
var request = indexedDB.open('videos');
request.onerror = function () {
    console.log('failed to open indexedDB');
};
request.onsuccess = function (event) {
    // handle version control
    // then create a new object store
};

Ahora que la base de datos esta abierta (y asumiendo que no hay errores), se ejecutará el evento onsuccess. Antes de poder crear almacenes de objetos, tenemos que tener en cuenta los siguiente:

  • Necesitamos un manejador para poder realizar transacciones de inserción y obtención de datos.
  • Hay que especificar la versión de la base de datos. Si no existe una versión definida, significa que la base de datos no está aún creada.

El método onsuccess recibe como parámetro un evento, al igual que lo hace cualquier otro método escuchador de eventos. Dentro de este objeto, encontramos una propiedad llamada target, y dentro de ésta otra propiedad llamada result, que contiene el resultado de la operación. En este caso específico, event.target.result contiene la base de datos que acabamos de abrir.

var db = null;
 
var request = indexedDB.open('videos');
request.onsuccess = function (event) {
    // cache a copy of the database handle for the future
    db = event.target.result;
    // handle version control
    // then create a new object store
};
request.onerror = function (event) {
    alert('Something failed: ' + event.target.message);
};

Es importante indicar que en IndexedDB, los errores que se producen escalan hasta el objeto request. Esto quiere decir, que si un error ocurre en cualquier petición (por ejemplo una consulta de datos), en este caso mostraría una alerta con el error.

8.3.2 Control de versiones

El primer paso tras abrir la base de datos es realizar un control de versiones de la base de datos. Podemos utilizar cualquier cadena de caracteres como identificador de la versión, pero lo lógico es seguir un patrón típico de software, como '0.1', '0.2', etc. Al abrir la base de datos, debemos comprobar si la versión actual de la base de datos coincide con la última versión de la aplicación. Si son diferentes, tendremos que realizar la actualización.

var db = null, version = '0.1';
 
request.onsuccess = function (event) {
    // cache a copy of the database handle for the future
    db = event.target.result;
 
    // handle version control
    if (version != db.version) {
        // set the version to 0.1
        var verRequest = db.setVersion(version);
        verRequest.onsuccess = function (event) {
            // now we're ready to create the object store!
        };
        verRequest.onerror = function () {
            alert('unable to set the version :' + version);
        };
    }
};

8.3.3 Crear almacenes de objetos

Tanto la primera vez que creamos nuestra base de datos, como a la hora de actualizarla, debemos crear los almacenes de objetos correspondientes.

var verRequest = db.setVersion(version);
verRequest.onsuccess = function (event) {
    var store = db.createObjectStore('blockbusters', {
        keyPath: 'title',
        autoIncrement: false
    });
 
    // at this point we would notify our code
    // that the object store is ready
};

Para este ejemplo, hemos creado un único almacén de objetos, pero lo normal es disponer de varios y que puedan relacionarse entre ellos. El método createObjectStore admite dos parámetros:

  • name: indica el nombre del almacén de objetos.
  • optionalParameters: este parámetro permite definir cual va a ser el índice de los objetos, a través del cual se van a realizar las búsquedas y que por tanto debe ser único. Si no deseamos que este índice se incremente automáticamente al añadir nuevos objetos, también lo podemos indicar aquí. Al añadir un nuevo objeto, es importante asegurarnos que disponga de la propiedad definida como índice, y que su valor sea único.

Es posible que deseemos añadir nuevos índices a nuestros objetos, con el fin de poder realizar búsquedas posteriormente. Podemos realizarlo de la siguiente manera:

store.createIndex('director', 'director', { unique: false });

Hemos añadido un nuevo índice al almacén, llamado director (primer argumento), y hemos indicado que el nombre de la propiedad del objeto es director (segundo argumento), a través del cual vamos a realizar las búsquedas. Evidentemente, varias películas pueden tener el mismo director, por lo que este valor no puede ser único. De esta manera, podemos almacenar objetos de este tipo:

{
    title: "Belly Dance Bruce - Final Strike",
    date: (new Date).getTime(), // released TODAY!
    director: "Bruce Awesome",
    length: 169, // in minutes
    rating: 10,
    cover: "/images/wobble.jpg"
}

8.3.4 Añadir objetos al almacén

Disponemos de dos métodos para añadir objetos al almacén: add y put.

  • add: añade un nuevo objeto al almacén. Es obligatorio que los nuevos datos no existan en el almacén, de otro modo esto provocaría un error de tipo ConstrainError.
  • put: en cambio, éste método actualiza el valor del objeto si existe, o lo añade si no existe en el almacén.
var video = {
    title: "Belly Dance Bruce - Final Strike",
    date: (new Date).getTime(), // released TODAY!
    director: "Bruce Awesome",
    length: 169, // in minutes
    rating: 10,
    cover: "/images/wobble.jpg"
};
 
var myIDBTransaction =
    window.IDBTransaction
    || window.webkitIDBTransaction
    || { READ_WRITE: 'readwrite' };
 
var transaction =
    db.transaction(['blockbusters'], myIDBTransaction.READ_WRITE);
var store = transaction.objectStore('blockbusters');
// var request = store.add(video);
var request = store.put(video);

Analizemos las tres últimas líneas, utilizadas para añadir un nuevo objeto al almacén:

  1. transaction = db.transaction(['blockbusters'], READ_WRITE): creamos una nueva transacción de lectura/escritura, sobre los almacenes indicados (en este caso sólo 'blockbusters'), pero podrían ser varios si es necesario. Si no necesitamos que la transacción sea de escritura, podemos indicarlo con la propiedad IDBTransaction.READ_ONLY..
  2. store = transaction.objectStore('blockbusters'): obtenemos el almacén de objetos sobre el que queremos realizar las operaciones, que debe ser uno de los indicados en la transacción. Con la referencia a este objeto, podemos ejecutar las operaciones de add, put, get, delete, etc.
  3. request = store.put(video): insertamos el objeto en el almacén. Si la transacción se ha realizado correctamente, se llamará al evento onsuccess, mientras que si ha ocurrido un error, se llamará a onerror.

8.3.5 Obtener objetos del almacén

El proceso para acceder a los objetos almacenados es muy similar a lo realizado para insertarlos. Seguimos necesitando una transacción, pero en este caso de sólo lectura. El proceso es el siguiente:

var myIDBTransaction =
    window.IDBTransaction
    || window.webkitIDBTransaction
    || { READ: 'read' };
 
var key = "Belly Dance Bruce - Final Strike";
 
var transaction =
    db.transaction(['blockbusters'], myIDBTransaction.READ);
var store = transaction.objectStore('blockbusters');
// var request = store.add(video);
var request = store.get(key);

En este caso, lo importante es que la clave (la variable key) que hemos pasado al método get, buscará el valor que contiene en la propiedad que hemos definido como keyPath al crear el almacén de objetos.

** Nota **

El método get produce el mismo resultado tanto si el objeto existe en el almacén como si no, pero con un valor undefined. Para evitar esta situación, es recomendable utilizar el método openCursor() con la misma clave. Si el objeto no existe, el valor del resultado es null.

Para obtener todos los objetos de un almacén, en lugar de un único objeto, debemos hacer uso del método openCursor. Este método acepta como parámetro un objeto de tipo IDBKeyRange, que podemos utilizar para acotar la búsqueda.

var transaction =
    db.transaction(['blockbusters'], myIDBTransaction.READ);
var store = transaction.objectStore('blockbusters');
var data = [];
 
var request = store.openCursor();
request.onsuccess = function (event) {
    var cursor = event.target.result;
    if (cursor) {
        // value is the stored object
        data.push(cursor.value);
        // get the next object
        cursor.continue();
    } else {
        // we’ve got all the data now, call
        // a success callback and pass the
        // data object in.
    }
};

En ese ejemplo, abrimos el almacén de objetos al igual que lo hemos venido haciendo, pero en lugar de obtener un único objeto con el método get, abrimos un cursor. Esto nos permite iterar por los objetos devueltos por el cursor, todos como es este caso, o los que cumplan una condición concreta. El objeto en concreto al que apunta el cursor en la iteración actual se encuentra almacenado en su propiedad cursor.value. Avanzar en el cursor, para obtener el siguiente objeto, es tan sencillo como llamar al método continue() del cursor, lo que provocará una nueva llamada al evento onsuccess, siendo nosotros los que tenemos que controlar si el cursor ya no apunta a ningún objeto (cursor === false).

8.3.6 Eliminar objetos del almacén

La última operación a realizar sobre el almacén de objetos, es eliminar datos existentes. De nuevo, el proceso es prácticamente el mismo que para obtener datos:

var myIDBTransaction =
    window.IDBTransaction
    || window.webkitIDBTransaction
    || { READ_WRITE: 'readwrite' };
 
var transaction =
    db.transaction(['blockbusters'], myIDBTransaction.READ_WRITE);
var store = transaction.objectStore('blockbusters');
var request = store.delete(key);

Si queremos eliminar todos los objetos de un almacén, podemos utilizar el método clear(), siguiente el mismo proceso visto anteriormente.

var request = store.clear();

Ejercicio 10

Ver enunciado

Índice de contenidos