Bem-vindo à aula 76 do nosso curso de JavaScript! Hoje vamos explorar o IndexedDB, uma tecnologia poderosa que permite armazenar grandes volumes de dados estruturados no navegador. Se você já usou localStorage e sentiu suas limitações, esta aula vai te mostrar como dar o próximo passo.

O IndexedDB é um banco de dados NoSQL orientado a objetos, embutido nos navegadores modernos. Ele permite armazenar praticamente qualquer tipo de dado (objetos, arquivos, blobs, etc.) de forma persistente, com suporte a buscas por índices e transações. É a escolha certa quando você precisa de mais capacidade, estrutura e performance do que o localStorage oferece.

Quando localStorage não basta

O localStorage é uma solução simples para armazenar pares chave-valor, mas tem limitações claras: capacidade máxima de cerca de 5-10 MB, armazenamento síncrono (bloqueia o thread principal), e só aceita strings (você precisa serializar objetos com JSON). Para aplicações web progressivas (PWAs), aplicativos offline, ou qualquer coisa que precise armazenar muitos dados (como registros de usuários, imagens, documentos), essas limitações se tornam um gargalo.

Imagine que você está construindo um app de anotações que permite incluir imagens e áudios. Com localStorage, você rapidamente atingiria o limite de espaço e teria que lidar com conversões de dados custosas. Além disso, como o localStorage é síncrono, cada leitura/escrita pode travar a interface, especialmente com dados maiores. O IndexedDB resolve esses problemas: é assíncrono (não bloqueia a UI), tem capacidade muito maior (geralmente acima de 50 MB, dependendo do navegador e do espaço em disco), e suporta estruturas complexas com índices para buscas eficientes.

Outro ponto: o localStorage não é ideal para dados que precisam de consultas por campos específicos. Se você tem uma lista de produtos e quer filtrar por preço, teria que percorrer tudo manualmente. Com IndexedDB, você pode criar índices e usar cursores para buscar com eficiência.

Conceitos

O IndexedDB é baseado em alguns conceitos fundamentais que você precisa dominar:

  • Banco de dados (Database): é o contêiner principal. Você pode ter vários bancos, cada um identificado por um nome. Um banco pode ter várias object stores.
  • Object Store: semelhante a uma tabela em SQL, mas orientada a objetos. Cada store armazena objetos JavaScript. Você define uma chave primária (keyPath) ou um auto-incremento (autoIncrement).
  • Transações: toda operação de leitura/escrita acontece dentro de uma transação. Garante consistência: se algo falha, tudo é revertido. Transações podem ser 'readonly' ou 'readwrite'.
  • Índices: permitem buscar objetos por propriedades diferentes da chave primária. Aceleram consultas e permitem ordenação.
  • Cursores: usados para iterar sobre múltiplos registros em uma store ou índice.
  • Requisições (Requests): cada operação (como abrir banco, adicionar, obter) retorna um objeto Request que dispara eventos (onsuccess, onerror).

O fluxo básico é: abrir o banco, criar as stores (se necessário), iniciar uma transação, obter a store, executar a operação e lidar com os resultados via callbacks ou Promises (com wrappers).

Uma diferença importante: o IndexedDB é assíncrono por natureza. Isso significa que as operações não retornam valores diretamente, mas sim eventos. Trabalhar com Promises facilita muito o código moderno, e existem bibliotecas como idb que simplificam ainda mais.

Exemplo simples

Vamos colocar a mão na massa. Vou criar um exemplo completo que abre um banco, cria uma object store chamada 'pessoas', adiciona um registro e depois lê todos os registros. Vamos usar uma função que retorna uma Promise para lidar com a abertura do banco.

Primeiro, vamos abrir o banco:

// Abre o banco de dados 'MeuBanco' na versão 1
const request = indexedDB.open('MeuBanco', 1);

let db;

request.onupgradeneeded = (event) => {
  db = event.target.result;
  // Cria a object store 'pessoas' com chave primária 'id' (auto-incremento)
  const store = db.createObjectStore('pessoas', { keyPath: 'id', autoIncrement: true });
  // Cria um índice para a propriedade 'nome'
  store.createIndex('nome', 'nome', { unique: false });
  console.log('Banco criado/atualizado');
};

request.onsuccess = (event) => {
  db = event.target.result;
  console.log('Banco aberto com sucesso');
  // Agora podemos usar o banco
  adicionarPessoa();
};

request.onerror = (event) => {
  console.error('Erro ao abrir o banco:', event.target.error);
};

Note que onupgradeneeded é chamado quando o banco é criado pela primeira vez ou quando a versão muda. É ali que criamos as stores e índices. Depois que o banco está aberto, podemos executar operações.

Vamos adicionar uma pessoa:

function adicionarPessoa() {
  const transaction = db.transaction('pessoas', 'readwrite');
  const store = transaction.objectStore('pessoas');
  
  const pessoa = { nome: 'Maria', idade: 30 };
  const request = store.add(pessoa);
  
  request.onsuccess = () => {
    console.log('Pessoa adicionada com id:', request.result);
    lerPessoas();
  };
  
  request.onerror = () => {
    console.error('Erro ao adicionar pessoa:', request.error);
  };
}

E para ler todas as pessoas, usamos um cursor:

function lerPessoas() {
  const transaction = db.transaction('pessoas', 'readonly');
  const store = transaction.objectStore('pessoas');
  
  const request = store.openCursor();
  
  request.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      console.log('Pessoa:', cursor.value);
      cursor.continue();
    } else {
      console.log('Fim da listagem');
    }
  };
  
  request.onerror = () => {
    console.error('Erro ao ler pessoas:', request.error);
  };
}

Esse exemplo mostra o ciclo básico: abrir banco, criar store, adicionar e ler dados. Em aplicações reais, você provavelmente vai querer encapsular essas operações em funções que retornam Promises, para usar com async/await. Uma abordagem comum é criar um wrapper:

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MeuBanco', 1);
    request.onupgradeneeded = ...;
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Assim, você pode usar const db = await openDB(); e facilitar a leitura do código.

Boas práticas e observações

Algumas dicas importantes ao trabalhar com IndexedDB:

  • Feche o banco quando não precisar mais: db.close() é importante para liberar recursos, especialmente em SPAs.
  • Trate erros sempre: operações podem falhar por falta de espaço, permissões, etc.
  • Use transações curtas: não mantenha transações abertas por muito tempo, pois podem bloquear outras operações.
  • Considere usar uma biblioteca como idb (https://github.com/jakearchibald/idb) para simplificar a API e usar Promises nativamente.
  • Teste em diferentes navegadores: embora o suporte seja amplo, há pequenas diferenças de implementação.

Referências

Exercícios

  1. Qual a diferença principal entre localStorage e IndexedDB em relação ao armazenamento de objetos complexos?

    ✓ Resposta: O localStorage aceita apenas strings, exigindo serialização com JSON.stringify e JSON.parse para objetos. IndexedDB armazena objetos diretamente, sem necessidade de serialização, mantendo a estrutura original.
  2. Explique o papel do evento onupgradeneeded ao abrir um banco IndexedDB.

    ✓ Resposta: O evento onupgradeneeded é disparado quando o banco é criado pela primeira vez ou quando a versão é incrementada. É o local correto para criar ou atualizar object stores e índices, pois é executado antes de qualquer transação.
  3. Escreva um código que abra um banco chamado 'teste' e crie uma object store chamada 'produtos' com chave primária 'id' (auto incremento) e um índice para o campo 'nome'.

    ✓ Resposta:
    const request = indexedDB.open('teste', 1);
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      const store = db.createObjectStore('produtos', { keyPath: 'id', autoIncrement: true });
      store.createIndex('nome', 'nome', { unique: false });
    };
    request.onsuccess = (event) => console.log('Banco aberto');
    request.onerror = (event) => console.error('Erro', event.target.error);
    
  4. Liste as três principais limitações do localStorage que motivam o uso do IndexedDB.

    ✓ Resposta: 1) Capacidade limitada (cerca de 5-10 MB); 2) Armazenamento síncrono, que bloqueia a thread principal; 3) Aceita apenas strings, exigindo serialização de objetos.
  5. O que acontece se você tentar adicionar um objeto com uma chave primária que já existe? Como evitar esse erro?

    ✓ Resposta: A operação add lançará um erro (ConstraintError). Para evitar, você pode usar put (que sobrescreve) ou verificar se a chave existe antes de adicionar com get.