Ceiling

IndexedDB 使用教程

Web

IndexedDB 是浏览器内置的本地数据库,可以存储大量结构化数据(包括对象、数组、二进制文件),容量远超 localStorage,且支持索引和事务。做离线应用、缓存视频/图片等大数据量场景时,IndexedDB 几乎是唯一选择。本文从核心概念讲起,逐步覆盖建库、增删改查、索引、游标等 API,最后给出一个 Promise 封装的实战示例。

一、浏览器存储方案选型

浏览器提供了多种本地存储方案,先用一张表搞清楚该用哪个:

存储方案容量数据类型生命周期典型场景
Cookie约 4KB字符串可设过期时间,随请求发给服务器登录态、会话标识
localStorage约 5MB字符串永久(需手动清除)用户偏好、轻量配置
sessionStorage约 5MB字符串标签页关闭即失效表单临时数据
IndexedDB数百 MB 至磁盘剩余空间对象、数组、Blob、File永久(需手动删除)离线数据、大文件缓存

简单总结:存少量字符串配置用 localStorage,存大量结构化数据或二进制文件用 IndexedDB

二、核心概念

IndexedDB 的数据模型类似一个「没有 SQL 的数据库」,层级关系如下:

graph TD A[数据库 database] --> B[对象仓库 object store 1] A --> C[对象仓库 object store 2] B --> D[索引 index] B --> E[索引 index] A -.所有读写都通过.-> F[事务 transaction]
概念类比说明
数据库(database)数据库文件按「源」(协议 + 域名 + 端口)隔离,同源才能互相访问
对象仓库(object store)存放记录的容器,每条记录有键(key)和值(value)
索引(index)表的索引列按某个字段建立检索,加速按该字段的查询
事务(transaction)数据库事务所有读写必须发生在事务中,要么全部成功要么全部回滚
游标(cursor)迭代器逐条遍历仓库中的记录,适合分页或批量处理

键的两种模式:

  • keyPath:用记录自身的某个字段作主键(如 id),插入的数据必须带该字段;
  • keyGenerator:仓库自动生成自增键,插入时可以不带键。

三、打开数据库

所有操作都从 indexedDB.open() 开始。它是异步的,通过事件回调通知结果:

const request = indexedDB.open('mydb', 1); // 参数:库名、版本号(正整数)

// 首次打开或版本号变大时触发 —— 只有在这里才能创建/修改仓库结构
request.onupgradeneeded = (event) => {
  const db = event.target.result;

  // 创建对象仓库,用记录自身的 id 字段作主键
  if (!db.objectStoreNames.contains('users')) {
    db.createObjectStore('users', { keyPath: 'id' });
  }
};

// 打开成功
request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('数据库打开成功', db.version);
};

// 打开失败
request.onerror = (event) => {
  console.error('数据库打开失败', event.target.error);
};

三个要点:

  1. 版本号只能升不能降:修改仓库结构(建仓库、建索引、删仓库)必须升版本号,在 onupgradeneeded 里完成;普通读写时打开的版本号必须 ≥ 已有版本,否则报错。
  2. onupgradeneeded 是唯一能改结构的地方:它在一个特殊的版本变更事务中执行,拿到的 db 可以直接 createObjectStore
  3. 事件顺序:首次打开时先触发 onupgradeneeded 再触发 onsuccess;结构没变化时直接触发 onsuccess

四、创建对象仓库与索引

onupgradeneeded 中常见的结构初始化写法:
request.onupgradeneeded = (event) => {
  const db = event.target.result;

  // 1. keyPath 模式:记录自带 id 字段
  const store = db.createObjectStore('articles', { keyPath: 'id' });

  // 2. 自增键模式:主键由仓库自动生成
  db.createObjectStore('logs', { autoIncrement: true });

  // 3. 在仓库上建索引:参数为 索引名、记录的字段名、配置
  store.createIndex('by_title', 'title', { unique: false });
  store.createIndex('by_createTime', 'createTime', { unique: false });
};

对已存在的仓库加索引或删除仓库,同样要在版本升级回调里做:

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const store = event.target.transaction.objectStore('articles');

  // 已有仓库补充索引
  if (!store.indexNames.contains('by_title')) {
    store.createIndex('by_title', 'title', { unique: false });
  }

  // 删除整个仓库
  // db.deleteObjectStore('logs');
};

五、事务:所有读写的入口

IndexedDB 的任何数据操作都必须发生在事务中。流程固定为三步:创建事务 → 拿到仓库 → 发起请求

// 第一个参数:事务涉及的仓库(可多个);第二个参数:模式
// readonly 只读 / readwrite 读写
const tx = db.transaction(['users'], 'readwrite');
const store = tx.objectStore('users');

// 事务完成/失败也要监听,便于统一处理
tx.oncomplete = () => console.log('事务完成');
tx.onerror = (e) => console.error('事务出错', e.target.error);
tx.onabort = () => console.log('事务中止(自动回滚)');

注意两点:

  • 事务是短生命周期的:当前事件循环内没有挂起的请求时,事务就会自动提交,之后再往同一个事务里发请求会报错;
  • 同一个事务内的所有请求,要么全部成功,要么任何一个失败整体回滚。

六、增删改查

users 仓库为例(keyPath: 'id'),完整的 CRUD 如下。IndexedDB 原生 API 全部基于事件回调,先看回调写法,后文第八节会给出 Promise 封装:

const store = db.transaction('users', 'readwrite').objectStore('users');

// —— 新增:add 遇到主键冲突会报错,put 则直接覆盖 ——
store.add({ id: 1, name: '林一', age: 20 });
store.put({ id: 1, name: '林一(改名)', age: 21 });

// —— 按主键读取 ——
const getReq = store.get(1);
getReq.onsuccess = () => {
  console.log(getReq.result); // 查不到时 result 为 undefined
};

// —— 读取全部 ——
const allReq = store.getAll();
allReq.onsuccess = () => console.log(allReq.result); // 数组

// —— 统计数量 ——
const countReq = store.count();
countReq.onsuccess = () => console.log('共', countReq.result, '条');

// —— 按主键删除 ——
store.delete(1);

// —— 清空整个仓库 ——
// store.clear();
addput 的区别是高频考点:add 主键已存在时报 ConstraintErrorput 无条件覆盖写入(相当于"有则更新、无则插入")。

七、索引查询

按主键以外的字段查询,就要靠索引。先在仓库上 createIndex(见第四节),查询时通过 store.index() 拿到索引再调用同样的查询方法:

const store = db.transaction('articles').objectStore('articles');
const titleIndex = store.index('by_title');

// 按索引精确匹配
const req1 = titleIndex.get('索引使用入门');          // 返回第一条匹配记录
const req2 = titleIndex.getAll('索引使用入门');        // 返回所有匹配记录
const req3 = titleIndex.getKey('索引使用入门');        // 只返回匹配记录的主键

// 范围查询:配合 IDBKeyRange
const timeIndex = store.index('by_createTime');
// 大于等于 2024-01-01 的记录
const range = IDBKeyRange.lowerBound(1704038400000);
const req4 = timeIndex.getAll(range);
IDBKeyRange 的常用构造:
方法含义
IDBKeyRange.only(v)等于 v
IDBKeyRange.lowerBound(v, open?)≥ v(open 为 true 时 > v)
IDBKeyRange.upperBound(v, open?)≤ v(open 为 true 时 < v)
IDBKeyRange.bound(lo, hi, loOpen?, hiOpen?)区间 [lo, hi]

八、游标遍历

数据量大时不宜用 getAll() 一次性全部载入,用游标逐条处理更省内存:

const store = db.transaction('articles').objectStore('articles');
const cursorReq = store.openCursor();

cursorReq.onsuccess = (event) => {
  const cursor = event.target.result;
  if (cursor) {
    console.log('主键:', cursor.key, '记录:', cursor.value);

    // 游标上也支持更新/删除当前记录
    // cursor.update({ ...cursor.value, views: cursor.value.views + 1 });
    // cursor.delete();

    cursor.continue(); // 移到下一条;不调用 continue 则遍历终止
  } else {
    console.log('遍历结束');
  }
};

游标支持按索引打开、指定方向和范围:

// 按时间索引倒序遍历最近 10 条(分页常用套路)
const index = store.index('by_createTime');
const cursorReq = index.openCursor(null, 'prev'); // direction: next / prev / nextunique / prevunique

let count = 0;
cursorReq.onsuccess = (event) => {
  const cursor = event.target.result;
  if (cursor && count < 10) {
    count++;
    // ...处理记录
    cursor.continue();
  }
};

列表页需要翻页时,用 advance(n) 跳过前面的记录,再收集一页数据即可:

// 按页码查询:跳过 (page - 1) * pageSize 条,收集 pageSize 条
function queryPage(db, storeName, page, pageSize) {
  return new Promise((resolve, reject) => {
    const store = db.transaction(storeName).objectStore(storeName);
    const cursorReq = store.openCursor();
    const rows = [];
    let skipped = false;

    cursorReq.onsuccess = (event) => {
      let cursor = event.target.result;
      // 首次定位时先跳过前面的记录
      if (cursor && !skipped) {
        skipped = true;
        const offset = (page - 1) * pageSize;
        if (offset > 0) {
          cursor.advance(offset);
          return; // advance 后会再次触发 onsuccess
        }
      }
      if (cursor && rows.length < pageSize) {
        rows.push(cursor.value);
        cursor.continue();
      } else {
        resolve(rows); // 收集满一页或数据耗尽
      }
    };
    cursorReq.onerror = (e) => reject(e.target.error);
  });
}

const rows = await queryPage(db, 'articles', 2, 10); // 第 2 页,每页 10 条

两个实践建议:

  • 总页数配合 count() 计算const total = await promisify(store.count()); 总页数 = Math.ceil(total / pageSize)
  • 深翻页用游标续翻代替 offset:数据量大时 advance(offset) 每次都要从头跳过 offset 条,越往后越慢。更好的做法是记住上一页最后一条记录的主键,下一页用 openCursor(IDBKeyRange.lowerBound(lastKey, true)) 从断点处继续,这也是移动端「下拉加载更多」的标准实现。

九、Promise 封装

原生事件回调写多了会层层嵌套,实际项目中通常封装成 Promise。下面是覆盖常用操作的最小实现:

// 打开数据库,返回 Promise<IDBDatabase>
function openDB(name, version, onUpgrade) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onupgradeneeded = (e) => onUpgrade?.(e.target.result, e.target.transaction);
    req.onsuccess = (e) => resolve(e.target.result);
    req.onerror = (e) => reject(e.target.error);
  });
}

// 把任意 IDBRequest 转成 Promise
function promisify(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

// 单条操作统一入口:storeName 仓库名,mode 事务模式,fn 接收 store 返回请求
function tx(db, storeName, mode, fn) {
  const store = db.transaction(storeName, mode).objectStore(storeName);
  return promisify(fn(store));
}

// 使用示例
const db = await openDB('mydb', 1, (database) => {
  database.createObjectStore('users', { keyPath: 'id' });
});

await tx(db, 'users', 'readwrite', (s) => s.put({ id: 1, name: '林一' }));
const user = await tx(db, 'users', 'readonly', (s) => s.get(1));
const list = await tx(db, 'users', 'readonly', (s) => s.getAll());
await tx(db, 'users', 'readwrite', (s) => s.delete(1));

如果不想自己维护封装,社区库 idb 是事实标准,API 几乎一一对应原生方法,只是全部变成了 Promise:

import { openDB } from 'idb';

const db = await openDB('mydb', 1, {
  upgrade(database) {
    database.createObjectStore('users', { keyPath: 'id' });
  },
});

await db.put('users', { id: 1, name: '林一' });
const user = await db.get('users', 1);
const all = await db.getAll('users');

十、存储二进制数据(Blob / File)

IndexedDB 是结构化克隆算法存储数据,原生支持 Blob、ArrayBuffer、File,这也是它做离线缓存(图片、音频、视频分片)的基础:

const db = await openDB('media', 1, (database) => {
  database.createObjectStore('files', { keyPath: 'url' });
});

// 下载一张图片并把二进制存入 IndexedDB
const res = await fetch('/api/cover.jpg');
if (!res.ok) throw new Error('下载失败');
const blob = await res.blob();

await db.put('files', { url: '/api/cover.jpg', blob, cachedAt: Date.now() });

// 展示时读出来生成 ObjectURL
const record = await db.get('files', '/api/cover.jpg');
const img = new Image();
img.src = URL.createObjectURL(record.blob);

注意 ObjectURL 是运行时的内存地址,无法持久化,持久化的必须是 Blob 本身,每次展示时重新 createObjectURL

十一、删除数据库与清理

// 删除整个数据库(也是异步事件)
const req = indexedDB.deleteDatabase('mydb');
req.onsuccess = () => console.log('已删除');
req.onerror = () => console.error('删除失败');

// 用户手动清理时,浏览器也可能触发存储清除;关键数据要有兜底策略
if (navigator.storage) {
  const { persisted } = await navigator.storage.persisted();
  if (!persisted) {
    // 申请持久化存储,降低被浏览器自动清理的概率
    await navigator.storage.persist();
  }
}

十二、常见坑与注意事项

  1. 同源限制http://a.com 的页面访问不了 https://a.com 或子域名的 IndexedDB,协议、域名、端口三者任一不同即为跨源。
  2. 事务生命周期短:在 await 其他异步操作后再使用旧事务会报 TransactionInactiveError。每次异步操作后重新 db.transaction() 即可。
  3. 结构变更必须升版本:直接调用 createObjectStore 而不在 onupgradeneeded 中会抛 InvalidStateError
  4. addput 混淆:重复主键场景想要"覆盖更新"就用 put,不要先 get 再决定调哪个(既慢又有竞态)。
  5. 版本冲突 blocked:旧标签页还持有旧版本连接时,新版本升级会被阻塞。监听 blocked 事件提示用户,并在旧页面监听 versionchange 事件主动关闭连接:

const req = indexedDB.open('mydb', 2);
   req.onblocked = () => console.warn('请关闭本应用的其他标签页以完成升级');

   // 在其他已打开的旧版本连接上:
   // db.onversionchange = () => db.close();

  1. 存储配额:单源默认配额约为磁盘剩余空间的 60%,写入超限会触发 QuotaExceededError;做缓存类应用时要实现淘汰策略(如 LRU 清理最旧数据)。
  2. 不要存不能序列化的东西:函数、DOM 节点无法被结构化克隆,写入会抛 DataCloneError

十三、小结

  • IndexedDB 是浏览器里唯一适合存大数据量和二进制文件的本地存储,按源隔离、基于事件回调异步操作;
  • 核心链路是 open → 版本升级中建仓库/索引 → transaction 开事务 → objectStore 增删改查
  • add 冲突报错、put 直接覆盖;非主键查询靠索引,大数据量遍历用游标;
  • 实际项目务必把 API 封装成 Promise,或直接用 idb 库;
  • 记住三个高频坑:结构变更要升版本、事务生命周期很短、ObjectURL 不能持久化(要存 Blob 本体)。

掌握以上内容,无论是做表单数据离线保存,还是音视频分片的本地缓存,都能游刃有余。

附录:API 速查表

前面各节的用法都来自下面这几个核心类,这里把每个类的常用方法、属性和事件整理成表,记不清时直接查表即可。

1. IDBFactory(indexedDB 全局对象)

方法说明
open(name, version?)打开数据库,返回 IDBOpenDBRequest
deleteDatabase(name)删除整个数据库,返回 IDBOpenDBRequest
cmp(a, b)比较两个键的大小,返回 -1 / 0 / 1
databases()列出当前源下所有数据库(返回 [{name, version}]

2. IDBDatabase(数据库实例)

成员类型说明
name属性数据库名
version属性当前版本号
objectStoreNames属性所有仓库名的 DOMStringList
createObjectStore(name, options)方法创建仓库,仅可在 onupgradeneeded 中调用
deleteObjectStore(name)方法删除仓库,仅可在 onupgradeneeded 中调用
transaction(storeNames, mode)方法创建事务,storeNames 可为字符串或数组,modereadonly / readwrite
close()方法关闭连接(不会立即生效,等当前事务结束后关闭)
onerror / onabort事件数据库出错 / 意外关闭
onversionchange事件其他页面请求升级版本时触发,应在此主动 close() 让出连接
onclose事件连接被关闭时触发

3. IDBTransaction(事务)

成员类型说明
objectStore(name)方法从事务中获取指定仓库
abort()方法手动中止事务并回滚
commit()方法手动提前提交事务(一般无需调用,会自动提交)
db属性所属数据库
mode属性事务模式
objectStoreNames属性事务涉及的仓库列表
error属性失败原因(事务结束时可读)
oncomplete事件事务成功提交
onerror事件事务中某个请求失败且未被捕获
onabort事件事务被中止

4. IDBObjectStore(对象仓库)

写入类(仅 readwrite 事务):
方法说明
add(value, key?)新增,主键已存在则报 ConstraintError
put(value, key?)新增或覆盖更新
delete(key)按主键删除,key 也可为 IDBKeyRange
clear()清空仓库
读取类(readonly 即可):
方法说明
get(key)按主键读取单条,不存在返回 undefined
getKey(indexKey)按二级索引键查主键
getAll(query?, count?)读取全部或范围内的记录,返回数组
getAllKeys(query?, count?)只读取主键数组
count(query?)统计条数
索引与游标
方法说明
createIndex(name, keyPath, options)创建索引,仅可在 onupgradeneeded 中调用
deleteIndex(name)删除索引,仅可在 onupgradeneeded 中调用
index(name)获取索引对象(IDBIndex)
openCursor(query?, direction?)打开游标遍历记录
openKeyCursor(query?, direction?)打开只含主键的游标(更轻量)

常用属性:namekeyPathautoIncrementindexNamestransaction

5. IDBIndex(索引)

方法与仓库的读取类基本一一对应,只是查询键变成了「索引字段值」:

方法说明
get(key)按索引值读取第一条匹配记录
getKey(value)按索引值反查主键
getAll(query?, count?)按索引值/范围读取所有匹配记录
getAllKeys(query?, count?)读取所有匹配记录的主键
count(query?)统计匹配条数
openCursor(query?, direction?)按索引顺序打开游标
openKeyCursor(query?, direction?)打开只含主键的游标

常用属性:namekeyPathunique(是否唯一)、multiEntry(数组字段是否每个元素单独建索引)、objectStore

6. IDBCursor(游标)

成员类型说明
continue(key?)方法移到下一条(或指定键之后的第一条),不调用则遍历终止
continuePrimaryKey(key, primaryKey)方法索引游标专用,跳到指定索引键 + 主键位置
advance(n)方法向前跳 n 条
update(value)方法更新游标当前记录(需 readwrite)
delete()方法删除游标当前记录(需 readwrite)
request属性产生该游标的 IDBRequest
key属性当前键(仓库游标为主键,索引游标为索引值)
primaryKey属性当前记录的主键
value属性当前记录内容(openKeyCursor 无此项)
direction属性遍历方向:next / prev / nextunique / prevunique

7. IDBKeyRange(键范围)

方法说明
IDBKeyRange.only(v)静态方法,匹配等于 v
IDBKeyRange.lowerBound(v, open?)静态方法,匹配 ≥ v(open 为 true 时 > v)
IDBKeyRange.upperBound(v, open?)静态方法,匹配 ≤ v(open 为 true 时 < v)
IDBKeyRange.bound(lo, hi, loOpen?, hiOpen?)静态方法,匹配 [lo, hi] 区间
range.includes(key)实例方法,判断 key 是否落在范围内

常用属性:lowerupperlowerOpenupperOpen

8. IDBRequest(请求)与 IDBOpenDBRequest

所有异步操作都返回 IDBRequest(open / deleteDatabase 返回其子类 IDBOpenDBRequest):

成员类型说明
result属性成功后的返回值(成功后读取)
error属性失败时的错误对象
source属性发起请求的仓库 / 索引 / 游标
transaction属性请求所属事务
readyState属性pendingdone
onsuccess / onerror事件成功 / 失败回调
onblocked事件仅 IDBOpenDBRequest:升版本时被旧连接阻塞
onupgradeneeded事件仅 IDBOpenDBRequest:需要升级结构时触发

9. IDBVersionChangeEvent

onupgradeneeded 回调的事件对象,额外提供两个属性:
属性说明
oldVersion升级前的旧版本号
newVersion正在升级到的新版本号

利用两者可以写逐级迁移逻辑:if (oldVersion < 2) { ... } if (oldVersion < 3) { ... }

10. options 参数详解

options 参数的方法一共三处,把每处可选的键值整理如下:

createObjectStore(name, options) —— 创建仓库:
参数类型默认值说明
keyPathstring 或 string[]null主键字段路径;为 null 时插入记录需手动传键或配合 autoIncrement;数组形式可组合复合主键
autoIncrementbooleanfalse为 true 时自动生成自增主键(从 1 开始)
// 三种典型组合
db.createObjectStore('users', { keyPath: 'id' });                          // 记录自带 id
db.createObjectStore('logs', { autoIncrement: true });                     // 自增键
db.createObjectStore('orders', { keyPath: ['userId', 'createTime'] });     // 复合主键
createIndex(name, keyPath, options) —— 创建索引:
参数类型默认值说明
uniquebooleanfalse为 true 时索引值不允许重复,写入重复值会报 ConstraintError
multiEntrybooleanfalse仅对数组字段有意义:为 true 时数组内每个元素各建一条索引,为 false 时整个数组作为一条索引
// email 必须唯一
store.createIndex('by_email', 'email', { unique: true });

// tags 是数组字段(如 ['前端', '数据库']),每个标签都能被单独检索
store.createIndex('by_tags', 'tags', { multiEntry: true });
transaction(storeNames, mode, options) —— 创建事务:
参数类型默认值说明
durability'default'、'strict' 或 'relaxed''default'控制写盘时机:strict 立即刷盘,最安全但最慢;relaxed 由浏览器自行批量刷盘,写入最快,但操作系统崩溃(注意不是浏览器崩溃)时可能丢失最近几秒的数据
// 大文件缓存等可重建的数据用 relaxed 提升写入速度
db.transaction('files', 'readwrite', { durability: 'relaxed' });
兼容性提示:durability 选项较新,旧浏览器会忽略第三个参数,不会报错;不支持 multiEntry 的旧引擎同样会忽略该选项,使用前可按需做特性检测。