IndexedDB básico
Introducción a bases de datos en el navegador
En aplicaciones web modernas, a menudo necesitamos almacenar y gestionar grandes cantidades de datos en el lado del cliente. Si bien ya hemos explorado opciones como localStorage y cookies, estas tecnologías tienen limitaciones importantes en cuanto a capacidad y estructura. Aquí es donde entra IndexedDB, una solución mucho más potente para almacenar datos en el navegador.
IndexedDB es una base de datos NoSQL orientada a objetos que se ejecuta en el navegador, permitiéndonos almacenar grandes volúmenes de datos estructurados, incluyendo archivos y blobs. A diferencia de localStorage, que solo puede almacenar cadenas de texto, IndexedDB puede manejar prácticamente cualquier tipo de dato JavaScript.
En este artículo, aprenderemos los conceptos fundamentales de IndexedDB y cómo implementar operaciones básicas para gestionar datos de manera eficiente en nuestras aplicaciones web.
Arquitectura de IndexedDB
Para entender IndexedDB, es importante familiarizarnos con su arquitectura y terminología:
Componentes principales
- Base de datos: El contenedor principal que alberga todos nuestros datos, similar a una base de datos tradicional.
- Almacén de objetos (Object Store): Equivalente a las tablas en bases de datos relacionales. Cada base de datos puede tener múltiples almacenes de objetos.
- Índices: Estructuras que nos permiten buscar registros rápidamente por campos diferentes a la clave principal.
- Transacciones: Operaciones que agrupan una o más acciones sobre la base de datos, garantizando su integridad.
- Cursores: Mecanismos para iterar sobre conjuntos de resultados.
- Solicitudes (Requests): Cada operación en IndexedDB devuelve un objeto request que nos informa del estado y resultado de la operación.
Características distintivas
- Orientada a objetos: Almacena objetos JavaScript directamente.
- Asíncrona: Todas las operaciones son asíncronas para no bloquear la interfaz de usuario.
- Basada en transacciones: Garantiza la integridad de los datos.
- Misma política de origen: Solo se puede acceder a bases de datos dentro del mismo origen (dominio).
- Almacenamiento persistente: Los datos persisten incluso después de cerrar el navegador.
Operaciones asíncronas con IndexedDB
Un aspecto fundamental de IndexedDB es que todas sus operaciones son asíncronas. Esto significa que el navegador no se bloqueará mientras se realizan operaciones en la base de datos, manteniendo la interfaz de usuario receptiva.
IndexedDB utiliza un sistema basado en eventos para manejar las operaciones asíncronas. Cada operación devuelve un objeto IDBRequest
al que podemos adjuntar manejadores de eventos como onsuccess
y onerror
para gestionar los resultados.
// Ejemplo de operación asíncrona básica
const request = objectStore.get(id);
// Manejador para operación exitosa
request.onsuccess = function(event) {
const resultado = event.target.result;
console.log("Datos obtenidos:", resultado);
};
// Manejador para error
request.onerror = function(event) {
console.error("Error al obtener datos:", event.target.error);
};
Esta naturaleza asíncrona también permite trabajar con IndexedDB utilizando Promesas y async/await, lo que facilita escribir código más limpio y mantenible:
// Envoltura básica con Promesas
function getDato(almacen, id) {
return new Promise((resolve, reject) => {
const transaccion = db.transaction(almacen, "readonly");
const store = transaccion.objectStore(almacen);
const solicitud = store.get(id);
solicitud.onsuccess = () => resolve(solicitud.result);
solicitud.onerror = () => reject(solicitud.error);
});
}
// Uso con async/await
async function mostrarDato(id) {
try {
const dato = await getDato("usuarios", id);
console.log("Usuario encontrado:", dato);
} catch (error) {
console.error("Error:", error);
}
}
Creación de bases de datos y almacenes
El primer paso para trabajar con IndexedDB es crear una base de datos y definir su estructura. Esto se hace mediante una solicitud de apertura:
// Abrir o crear una base de datos
const solicitud = indexedDB.open("miBaseDeDatos", 1);
// Este evento se dispara cuando es necesario crear o actualizar la estructura
solicitud.onupgradeneeded = function(event) {
const db = event.target.result;
// Crear un almacén de objetos si no existe
if (!db.objectStoreNames.contains("usuarios")) {
// El segundo parámetro establece la clave primaria
const usuariosStore = db.createObjectStore("usuarios", { keyPath: "id", autoIncrement: true });
// Crear índices para búsquedas eficientes
usuariosStore.createIndex("por_nombre", "nombre", { unique: false });
usuariosStore.createIndex("por_email", "email", { unique: true });
}
};
// Evento que se dispara cuando la conexión se establece correctamente
solicitud.onsuccess = function(event) {
const db = event.target.result;
console.log("Base de datos abierta con éxito");
// Aquí podemos empezar a trabajar con la base de datos
// Es importante guardar la referencia a 'db' para usarla después
};
// Manejar errores
solicitud.onerror = function(event) {
console.error("Error al abrir la base de datos:", event.target.error);
};
En el código anterior:
- Utilizamos
indexedDB.open()
para abrir o crear una base de datos llamada "miBaseDeDatos" con versión 1. - El evento
onupgradeneeded
se dispara cuando es necesario crear la base de datos por primera vez o cuando se cambia su versión. - Dentro de este evento, creamos un almacén de objetos llamado "usuarios" con una clave primaria auto-incremental.
- También creamos índices para realizar búsquedas eficientes por nombre y correo electrónico.
El parámetro de versión es importante: cuando queramos modificar la estructura de la base de datos (añadir o eliminar almacenes, crear nuevos índices), deberemos incrementar este número para que se dispare el evento onupgradeneeded
.
Transacciones CRUD básicas
Las operaciones CRUD (Crear, Leer, Actualizar y Eliminar) son fundamentales en cualquier sistema de base de datos. Veamos cómo implementarlas en IndexedDB:
Crear (Create)
function agregarUsuario(db, usuario) {
// Crear una transacción de escritura
const transaccion = db.transaction(["usuarios"], "readwrite");
// Obtener el almacén de objetos
const store = transaccion.objectStore("usuarios");
// Añadir el objeto
const solicitud = store.add(usuario);
// Manejar el resultado
solicitud.onsuccess = function() {
console.log("Usuario agregado con ID:", solicitud.result);
};
// Manejar errores
solicitud.onerror = function(event) {
console.error("Error al agregar usuario:", event.target.error);
};
// También podemos manejar eventos en la transacción
transaccion.oncomplete = function() {
console.log("Transacción completada");
};
}
// Ejemplo de uso
const nuevoUsuario = {
nombre: "Ana García",
email: "ana@ejemplo.com",
edad: 28
};
// Llamada a la función (asumiendo que 'db' es nuestra conexión activa)
agregarUsuario(db, nuevoUsuario);
Leer (Read)
// Obtener un registro por su clave primaria
function obtenerUsuario(db, id) {
const transaccion = db.transaction(["usuarios"], "readonly");
const store = transaccion.objectStore("usuarios");
const solicitud = store.get(id);
solicitud.onsuccess = function() {
if (solicitud.result) {
console.log("Usuario encontrado:", solicitud.result);
} else {
console.log("No se encontró ningún usuario con ID:", id);
}
};
}
// Obtener registros por un índice
function buscarPorEmail(db, email) {
const transaccion = db.transaction(["usuarios"], "readonly");
const store = transaccion.objectStore("usuarios");
const indice = store.index("por_email");
const solicitud = indice.get(email);
solicitud.onsuccess = function() {
if (solicitud.result) {
console.log("Usuario encontrado por email:", solicitud.result);
} else {
console.log("No se encontró ningún usuario con email:", email);
}
};
}
Actualizar (Update)
function actualizarUsuario(db, usuario) {
const transaccion = db.transaction(["usuarios"], "readwrite");
const store = transaccion.objectStore("usuarios");
// put reemplazará el objeto si existe o lo añadirá si no
const solicitud = store.put(usuario);
solicitud.onsuccess = function() {
console.log("Usuario actualizado correctamente");
};
solicitud.onerror = function(event) {
console.error("Error al actualizar:", event.target.error);
};
}
// Ejemplo: Actualizar la edad de un usuario
const usuarioActualizado = {
id: 1, // Debe tener el mismo ID que queremos actualizar
nombre: "Ana García",
email: "ana@ejemplo.com",
edad: 29 // Valor actualizado
};
actualizarUsuario(db, usuarioActualizado);
Eliminar (Delete)
function eliminarUsuario(db, id) {
const transaccion = db.transaction(["usuarios"], "readwrite");
const store = transaccion.objectStore("usuarios");
const solicitud = store.delete(id);
solicitud.onsuccess = function() {
console.log("Usuario eliminado correctamente");
};
solicitud.onerror = function(event) {
console.error("Error al eliminar:", event.target.error);
};
}
// Ejemplo: Eliminar el usuario con ID 1
eliminarUsuario(db, 1);
Manejo de índices y búsquedas
Los índices en IndexedDB funcionan de manera similar a los índices en bases de datos tradicionales: permiten buscar registros eficientemente por campos diferentes a la clave primaria.
Creación de índices
Los índices se crean normalmente durante el evento onupgradeneeded
:
solicitud.onupgradeneeded = function(event) {
const db = event.target.result;
const store = db.createObjectStore("usuarios", { keyPath: "id" });
// Crear índice simple
store.createIndex("por_nombre", "nombre", { unique: false });
// Crear índice único
store.createIndex("por_email", "email", { unique: true });
// Crear índice compuesto
store.createIndex("por_ciudad_edad", ["ciudad", "edad"], { unique: false });
};
Uso de índices para búsquedas
// Buscar todos los usuarios de una ciudad específica
function buscarPorCiudad(db, ciudad) {
return new Promise((resolve, reject) => {
const transaccion = db.transaction(["usuarios"], "readonly");
const store = transaccion.objectStore("usuarios");
const indice = store.index("por_ciudad_edad");
// IDBKeyRange.only busca coincidencias exactas
const rango = IDBKeyRange.bound([ciudad, 0], [ciudad, 100]);
const solicitud = indice.openCursor(rango);
const resultados = [];
solicitud.onsuccess = function(event) {
const cursor = event.target.result;
if (cursor) {
resultados.push(cursor.value);
cursor.continue();
} else {
// Ya no hay más resultados
resolve(resultados);
}
};
solicitud.onerror = function(event) {
reject(event.target.error);
};
});
}
// Ejemplo de uso con async/await
async function mostrarUsuariosMadrid() {
try {
const usuarios = await buscarPorCiudad(db, "Madrid");
console.log("Usuarios en Madrid:", usuarios);
} catch (error) {
console.error("Error:", error);
}
}
Ventajas sobre otras tecnologías de almacenamiento
IndexedDB ofrece varias ventajas significativas sobre otras opciones de almacenamiento en el navegador:
-
Mayor capacidad de almacenamiento:
- localStorage: Limitado a ~5-10 MB por origen
- IndexedDB: Puede almacenar cientos de MB o incluso GB (varía según el navegador)
-
Estructuras de datos complejas:
- localStorage: Solo almacena cadenas de texto
- IndexedDB: Almacena objetos JavaScript completos, arrays, blobs, etc.
-
Rendimiento en grandes conjuntos de datos:
- Con localStorage, buscar en grandes conjuntos de datos puede ser lento
- IndexedDB utiliza índices para búsquedas rápidas y eficientes
-
Transacciones:
- IndexedDB garantiza la integridad de los datos mediante transacciones
- Si una operación falla, se revierten todos los cambios de esa transacción
-
API asíncrona:
- IndexedDB no bloquea la interfaz de usuario durante operaciones
- localStorage realiza operaciones síncronas que pueden congelar la interfaz
Casos de uso prácticos
IndexedDB es ideal para una variedad de escenarios en aplicaciones web modernas:
Aplicaciones offline-first
// Almacenar datos cuando el usuario está offline
window.addEventListener('offline', function() {
guardarDatosLocalmente(formulario.getData());
});
function guardarDatosLocalmente(datos) {
const transaccion = db.transaction(["pendientes"], "readwrite");
const store = transaccion.objectStore("pendientes");
store.add({
datos: datos,
timestamp: Date.now()
});
}
// Sincronizar cuando vuelve a estar online
window.addEventListener('online', async function() {
await sincronizarDatos();
});
Caché de contenido
async function obtenerArticulo(id) {
// Primero intentamos obtener del caché local
const transaccion = db.transaction(["articulos"], "readonly");
const store = transaccion.objectStore("articulos");
const cacheado = await new Promise(resolve => {
const req = store.get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
// Si existe en caché y no está caducado, lo usamos
if (cacheado && Date.now() - cacheado.timestamp < 3600000) {
return cacheado.contenido;
}
// Si no, lo obtenemos de la API
const respuesta = await fetch(`/api/articulos/${id}`);
const articulo = await respuesta.json();
// Guardamos en caché
const txn = db.transaction(["articulos"], "readwrite");
const st = txn.objectStore("articulos");
st.put({
id: id,
contenido: articulo,
timestamp: Date.now()
});
return articulo;
}
Almacenamiento de archivos
// Guardar un archivo subido por el usuario
fileInput.addEventListener('change', function(e) {
const archivo = e.target.files[0];
if (!archivo) return;
const lector = new FileReader();
lector.onload = function() {
const transaccion = db.transaction(["archivos"], "readwrite");
const store = transaccion.objectStore("archivos");
store.put({
nombre: archivo.name,
tipo: archivo.type,
tamaño: archivo.size,
datos: lector.result,
fecha: new Date()
});
};
// Leer como ArrayBuffer para archivos binarios
lector.readAsArrayBuffer(archivo);
});
Resumen
IndexedDB representa una solución potente y flexible para almacenar datos estructurados en el navegador. Su arquitectura orientada a objetos, soporte para transacciones y operaciones asíncronas la convierten en la opción ideal para aplicaciones web modernas que necesitan manejar grandes volúmenes de datos o funcionar offline.
Aunque su API puede resultar más compleja que alternativas como localStorage o sessionStorage, los beneficios en términos de capacidad, rendimiento y flexibilidad compensan ampliamente esta curva de aprendizaje inicial.
En los próximos artículos, exploraremos tecnologías para la comunicación con servidores, como la Fetch API, que a menudo se combinan con IndexedDB para crear aplicaciones web robustas y resistentes a la desconexión.