IndexedDB 完整教程:从基础 API 到离线应用实战(附源码)

当 localStorage 的 5MB 上限成为瓶颈,当同步读写拖慢页面响应,IndexedDB 就是那个你该认真对待的方案。

前言

在前端开发中,本地存储是一个绕不开的话题。从最早的 Cookie,到后来广泛使用的 localStorage,再到 Session Storage,浏览器为我们提供了多种选择。但在面对大容量数据存储离线应用复杂查询等场景时,这些方案往往力不从心。

IndexedDB 正是为此而生。它是浏览器内置的一个完整的 NoSQL 数据库系统,支持结构化数据存储、索引查询、事务操作,容量可达数 GB 级别。本文将从核心概念、基础用法到实战场景,帮你系统掌握 IndexedDB。


一、IndexedDB 是什么

IndexedDB 是浏览器提供的一种本地非关系型(NoSQL)数据库,允许在用户浏览器中存储大量结构化数据,包括对象、数组甚至二进制文件(Blob、ArrayBuffer)。它的核心特性包括:

  • 大容量存储:没有统一的硬性上限,由浏览器根据设备磁盘空间动态分配,通常可达数 GB,远超 localStorage 的 5MB
  • 异步操作:所有读写操作都是异步的,不会阻塞主线程
  • 事务支持:所有数据操作必须在事务中执行,保证原子性
  • 索引机制:可对指定字段创建索引,支持高效查询
  • 结构化数据:直接存储 JavaScript 对象,无需手动序列化

与其他存储方案的对比

特性 Cookie localStorage IndexedDB
容量 约 4KB 约 5MB 数 GB 级别
数据类型 仅字符串 仅字符串 对象、数组、Blob 等
操作方式 同步 同步 异步
事务支持 不支持 不支持 支持
索引查询 不支持 不支持 支持
主线程阻塞 不会

从表格可以看出,IndexedDB 在容量、数据类型、性能方面都有明显优势。代价是 API 相对复杂------不过通过合理的封装,这个问题可以很好地解决。


二、核心概念

在动手写代码之前,需要先理解 IndexedDB 的几个核心概念。它们与传统数据库的术语有对应关系:

数据库(Database):每个域名下可以创建多个独立的数据库,每个数据库有唯一的名称和版本号。

对象仓库(Object Store):相当于关系型数据库中的"表",是存储数据的基本容器。数据以键值对(key-value)形式存储,每条记录必须有一个主键。

索引(Index) :建立在对象仓库之上的辅助数据结构,用于加速基于非主键字段的查询。可以创建唯一索引(unique: true)或普通索引。

事务(Transaction):IndexedDB 中所有数据操作都必须在事务中执行。事务有三种模式:

  • readonly:只读事务,查询数据时使用,多个 readonly 事务可以并行
  • readwrite:读写事务,增删改数据时使用,同一对象仓库上同时只能有一个 readwrite 事务
  • versionchange:版本变更事务,仅在数据库升级时触发,用于创建或删除对象仓库和索引

版本号:数据库的版本号是一个正整数,只能递增不能回退。修改数据库结构(增删对象仓库、创建索引)只能在版本升级时完成。


三、基础 API 与 Promise 封装

原生 IndexedDB API 基于事件回调,代码嵌套较深。在实际项目中,我们通常会封装一层 Promise 版本,用 async/await 来简化异步流程。

3.1 打开数据库

javascript 复制代码
function openDB(name, version, upgradeCallback) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);

    // 版本升级或首次创建时触发
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      upgradeCallback(db);
    };

    request.onsuccess = (event) => {
      resolve(event.target.result);
    };

    request.onerror = (event) => {
      reject(event.target.error);
    };
  });
}

onupgradeneeded 回调中创建对象仓库和索引:

javascript 复制代码
const db = await openDB('appDB', 1, (db) => {
  if (!db.objectStoreNames.contains('users')) {
    const store = db.createObjectStore('users', {
      keyPath: 'id',
      autoIncrement: true
    });
    // 创建索引
    store.createIndex('name', 'name', { unique: false });
    store.createIndex('email', 'email', { unique: true });
  }
});

3.2 CRUD 操作封装

将事务创建和请求处理统一封装,减少重复代码:

javascript 复制代码
function withTransaction(db, storeName, mode, callback) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, mode);
    const store = tx.objectStore(storeName);
    const result = callback(store);

    if (result instanceof IDBRequest) {
      result.onsuccess = () => resolve(result.result);
      result.onerror = () => reject(result.error);
    } else {
      tx.oncomplete = () => resolve(result);
      tx.onerror = () => reject(tx.error);
    }
  });
}

// 新增数据
function add(db, storeName, data) {
  return withTransaction(db, storeName, 'readwrite', (store) => {
    return store.add(data);
  });
}

// 根据主键查询
function get(db, storeName, key) {
  return withTransaction(db, storeName, 'readonly', (store) => {
    return store.get(key);
  });
}

// 更新数据(put 方法:主键存在则更新,不存在则新增)
function put(db, storeName, data) {
  return withTransaction(db, storeName, 'readwrite', (store) => {
    return store.put(data);
  });
}

// 删除数据
function remove(db, storeName, key) {
  return withTransaction(db, storeName, 'readwrite', (store) => {
    return store.delete(key);
  });
}

// 查询所有数据
function getAll(db, storeName) {
  return withTransaction(db, storeName, 'readonly', (store) => {
    return store.getAll();
  });
}

// 通过索引查询
function getByIndex(db, storeName, indexName, value) {
  return withTransaction(db, storeName, 'readonly', (store) => {
    const index = store.index(indexName);
    return index.get(value);
  });
}

使用起来非常简洁:

javascript 复制代码
// 新增用户
const id = await add(db, 'users', {
  name: '张三',
  email: 'zhangsan@example.com',
  age: 28
});

// 通过主键查询
const user = await get(db, 'users', id);

// 通过索引查询
const userByEmail = await getByIndex(db, 'users', 'email', 'zhangsan@example.com');

// 更新
await put(db, 'users', { ...user, age: 29 });

// 删除
await remove(db, 'users', id);

3.3 版本升级

当需要新增对象仓库或索引时,递增版本号即可:

javascript 复制代码
const dbV2 = await openDB('appDB', 2, (db) => {
  // 版本 2:新增 orders 仓库
  if (!db.objectStoreNames.contains('orders')) {
    const store = db.createObjectStore('orders', { keyPath: 'orderId' });
    store.createIndex('userId', 'userId', { unique: false });
    store.createIndex('status', 'status', { unique: false });
  }
  // 为已有的 users 仓库新增索引
  if (db.objectStoreNames.contains('users')) {
    const userStore = db.transaction.objectStore('users');
    if (!userStore.indexNames.contains('age')) {
      userStore.createIndex('age', 'age', { unique: false });
    }
  }
});

四、实战场景

4.1 离线应用数据缓存

在 PWA 或需要离线能力的应用中,IndexedDB 承担着核心的数据持久化角色。结合 Service Worker,可以实现"离线可用、上线同步"的完整体验。

核心思路是:应用启动时优先从 IndexedDB 加载数据渲染页面,同时后台发起网络请求更新数据并写入 IndexedDB。离线时直接使用本地数据,用户操作产生的变更记录在同步队列中,恢复网络后批量提交到服务端。

javascript 复制代码
// 离线数据管理器
class OfflineDataManager {
  constructor(db) {
    this.db = db;
    this.syncQueue = [];
    this.isOnline = navigator.onLine;

    window.addEventListener('online', () => {
      this.isOnline = true;
      this.flushSyncQueue();
    });
    window.addEventListener('offline', () => {
      this.isOnline = false;
    });
  }

  // 加载数据:优先本地,其次网络
  async loadData(storeName, fetchUrl) {
    // 先从本地读取
    const localData = await getAll(this.db, storeName);
    if (localData.length > 0) {
      // 有本地数据,先渲染,后台静默更新
      this.backgroundSync(storeName, fetchUrl);
      return localData;
    }

    // 无本地数据,走网络
    if (this.isOnline) {
      const response = await fetch(fetchUrl);
      const data = await response.json();
      // 批量写入 IndexedDB
      const tx = this.db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      for (const item of data) {
        store.put(item);
      }
      return data;
    }

    return [];
  }

  // 后台静默同步
  async backgroundSync(storeName, fetchUrl) {
    if (!this.isOnline) return;
    try {
      const response = await fetch(fetchUrl);
      const data = await response.json();
      const tx = this.db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      store.clear();
      for (const item of data) {
        store.put(item);
      }
    } catch (e) {
      console.warn('后台同步失败,下次重试', e);
    }
  }

  // 离线操作入队
  enqueueSync(storeName, operation, data) {
    this.syncQueue.push({ storeName, operation, data, timestamp: Date.now() });
    if (this.isOnline) this.flushSyncQueue();
  }

  // 恢复网络后批量同步
  async flushSyncQueue() {
    if (this.syncQueue.length === 0) return;
    const tasks = [...this.syncQueue];
    this.syncQueue = [];
    for (const task of tasks) {
      try {
        await fetch('/api/sync', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(task)
        });
      } catch (e) {
        // 同步失败,重新入队
        this.syncQueue.push(task);
      }
    }
  }
}

4.2 大数据量本地缓存

在聊天应用、日志系统、数据看板等场景中,前端可能需要处理数千甚至数万条数据。直接存储在内存中会导致页面性能下降,而 IndexedDB 可以很好地解决这个问题。

关键实践:合理设计索引 + 游标分页查询

javascript 复制代码
// 游标分页查询
function getPage(db, storeName, indexName, pageSize, offset) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readonly');
    const store = tx.objectStore(storeName);
    const index = indexName ? store.index(indexName) : store;
    const request = index.openCursor(null, 'prev'); // 按时间倒序
    const results = [];
    let skipped = 0;

    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (!cursor) {
        resolve(results);
        return;
      }
      if (skipped < offset) {
        skipped++;
        cursor.continue();
      } else if (results.length < pageSize) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };

    request.onerror = () => reject(request.error);
  });
}

// 使用示例:加载第 3 页,每页 20 条
const messages = await getPage(db, 'messages', 'timestamp', 20, 40);

4.3 图片与文件缓存

IndexedDB 支持直接存储 Blob 和 ArrayBuffer,适合做图片、文档等文件的本地缓存,减少重复请求,提升用户体验。

javascript 复制代码
// 缓存图片到 IndexedDB
async function cacheImage(db, url) {
  const response = await fetch(url);
  const blob = await response.blob();
  await put(db, 'imageCache', {
    url,
    blob,
    timestamp: Date.now()
  });
}

// 从 IndexedDB 读取缓存的图片
async function getCachedImage(db, url) {
  const record = await getByIndex(db, 'imageCache', 'url', url);
  if (record) {
    return URL.createObjectURL(record.blob);
  }
  return null;
}

// 使用
const imageUrl = await getCachedImage(db, 'https://example.com/photo.jpg');
if (imageUrl) {
  img.src = imageUrl; // 直接使用本地缓存
} else {
  img.src = 'https://example.com/photo.jpg';
  cacheImage(db, 'https://example.com/photo.jpg'); // 后台缓存
}

五、常见踩坑与解决方案

坑 1:版本升级被阻塞

当数据库版本发生变化时,如果旧版本的连接尚未关闭(比如其他标签页还持有连接),onupgradeneeded 事件会被阻塞,onblocked 事件触发。

解决方案 :在关闭数据库或页面卸载时,主动关闭数据库连接;升级前监听 onblocked 事件并提示用户关闭其他标签页。

javascript 复制代码
request.onblocked = () => {
  console.warn('数据库升级被阻塞,请关闭其他使用该数据库的标签页');
};

坑 2:事务自动提交

IndexedDB 的事务会在微任务队列清空后自动提交。如果你在事件回调之外使用 await 等异步操作,事务可能在你完成操作之前就已经提交了。

解决方案 :在事务创建后,立即同步地发起所有请求,不要在事务中间插入 await

javascript 复制代码
// 错误写法:await 导致事务提前提交
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
const user = await fetch('/api/user'); // 事务可能在这里已提交
store.put(user); // 抛出 TransactionInactiveError

// 正确写法:同步发起请求
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.put(data1);
store.put(data2);
store.put(data3);
await new Promise((resolve) => {
  tx.oncomplete = resolve;
  tx.onerror = () => reject(tx.error);
});

坑 3:并发写入冲突

同一对象仓库上同时只能有一个 readwrite 事务。如果多个异步操作同时尝试写入,后续事务会排队等待,甚至可能因超时而失败。

解决方案:将批量操作合并到同一个事务中执行,避免创建过多并发写事务。

javascript 复制代码
// 批量写入:在同一个事务中完成
function batchPut(db, storeName, items) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(storeName, 'readwrite');
    const store = tx.objectStore(storeName);
    for (const item of items) {
      store.put(item);
    }
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

坑 4:存储配额超限

虽然 IndexedDB 容量很大,但并非无限。当存储接近浏览器分配的上限时,写入操作会失败,触发 QuotaExceededError

解决方案:主动监控存储用量,定期清理过期数据;在写入时捕获配额错误并给用户提示。

javascript 复制代码
// 检查存储用量
async function checkStorageUsage() {
  if (navigator.storage && navigator.storage.estimate) {
    const { usage, quota } = await navigator.storage.estimate();
    const percentage = ((usage / quota) * 100).toFixed(2);
    console.log(`已使用 ${percentage}%(${(usage / 1024 / 1024).toFixed(1)}MB / ${(quota / 1024 / 1024).toFixed(1)}MB)`);
    return percentage;
  }
  return null;
}

// 写入时捕获配额错误
try {
  await put(db, 'largeData', hugeObject);
} catch (e) {
  if (e.name === 'QuotaExceededError') {
    console.warn('存储空间不足,请清理过期数据');
  }
}

坑 5:浏览器兼容性差异

主流现代浏览器(Chrome、Firefox、Edge、Safari)均已支持 IndexedDB,但在一些细节上存在差异。例如 Safari 在隐私模式下会禁用 IndexedDB,部分移动端浏览器对存储配额的限制更严格。

解决方案:使用前做特性检测,对不支持的环境提供降级方案。

javascript 复制代码
if (!window.indexedDB) {
  // 降级到 localStorage 或提示用户升级浏览器
  console.warn('当前浏览器不支持 IndexedDB');
}

六、总结

IndexedDB 是前端本地存储体系中能力最强的方案。当你的应用需要存储大量结构化数据、支持离线访问、或者进行复杂查询时,它几乎是唯一的选择。

选型建议

  • 简单的键值配置、用户偏好设置 → localStorage 足够
  • 需要携带到服务端的少量标识信息 → Cookie
  • 大容量数据存储、离线应用、复杂查询 → IndexedDB
  • 网络请求缓存、静态资源缓存 → Cache API(可与 IndexedDB 配合使用)

掌握 IndexedDB 的核心概念和封装技巧,是每个有经验的前端开发者都值得投入的事情。希望这篇文章能帮助你在实际项目中更自信地使用它。