IndexedDB 是浏览器内置的本地数据库,可以存储大量结构化数据(包括对象、数组、二进制文件),容量远超 localStorage,且支持索引和事务。做离线应用、缓存视频/图片等大数据量场景时,IndexedDB 几乎是唯一选择。本文从核心概念讲起,逐步覆盖建库、增删改查、索引、游标等 API,最后给出一个 Promise 封装的实战示例。
一、浏览器存储方案选型
浏览器提供了多种本地存储方案,先用一张表搞清楚该用哪个:
| 存储方案 | 容量 | 数据类型 | 生命周期 | 典型场景 |
|---|---|---|---|---|
| Cookie | 约 4KB | 字符串 | 可设过期时间,随请求发给服务器 | 登录态、会话标识 |
| localStorage | 约 5MB | 字符串 | 永久(需手动清除) | 用户偏好、轻量配置 |
| sessionStorage | 约 5MB | 字符串 | 标签页关闭即失效 | 表单临时数据 |
| IndexedDB | 数百 MB 至磁盘剩余空间 | 对象、数组、Blob、File | 永久(需手动删除) | 离线数据、大文件缓存 |
简单总结:存少量字符串配置用 localStorage,存大量结构化数据或二进制文件用 IndexedDB。
二、核心概念
IndexedDB 的数据模型类似一个「没有 SQL 的数据库」,层级关系如下:
| 概念 | 类比 | 说明 |
|---|---|---|
| 数据库(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);
};
三个要点:
- 版本号只能升不能降:修改仓库结构(建仓库、建索引、删仓库)必须升版本号,在
onupgradeneeded里完成;普通读写时打开的版本号必须 ≥ 已有版本,否则报错。 onupgradeneeded是唯一能改结构的地方:它在一个特殊的版本变更事务中执行,拿到的db可以直接createObjectStore。- 事件顺序:首次打开时先触发
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();
add 与 put 的区别是高频考点:add 主键已存在时报 ConstraintError,put 无条件覆盖写入(相当于"有则更新、无则插入")。
七、索引查询
按主键以外的字段查询,就要靠索引。先在仓库上 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();
}
}
十二、常见坑与注意事项
- 同源限制:
http://a.com的页面访问不了https://a.com或子域名的 IndexedDB,协议、域名、端口三者任一不同即为跨源。 - 事务生命周期短:在
await其他异步操作后再使用旧事务会报TransactionInactiveError。每次异步操作后重新db.transaction()即可。 - 结构变更必须升版本:直接调用
createObjectStore而不在onupgradeneeded中会抛InvalidStateError。 add与put混淆:重复主键场景想要"覆盖更新"就用put,不要先get再决定调哪个(既慢又有竞态)。- 版本冲突
blocked:旧标签页还持有旧版本连接时,新版本升级会被阻塞。监听blocked事件提示用户,并在旧页面监听versionchange事件主动关闭连接:
const req = indexedDB.open('mydb', 2);
req.onblocked = () => console.warn('请关闭本应用的其他标签页以完成升级');
// 在其他已打开的旧版本连接上:
// db.onversionchange = () => db.close();
- 存储配额:单源默认配额约为磁盘剩余空间的 60%,写入超限会触发
QuotaExceededError;做缓存类应用时要实现淘汰策略(如 LRU 清理最旧数据)。 - 不要存不能序列化的东西:函数、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 可为字符串或数组,mode 为 readonly / 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() | 清空仓库 |
| 方法 | 说明 |
|---|---|
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?) | 打开只含主键的游标(更轻量) |
常用属性:name、keyPath、autoIncrement、indexNames、transaction。
5. IDBIndex(索引)
方法与仓库的读取类基本一一对应,只是查询键变成了「索引字段值」:
| 方法 | 说明 |
|---|---|
get(key) | 按索引值读取第一条匹配记录 |
getKey(value) | 按索引值反查主键 |
getAll(query?, count?) | 按索引值/范围读取所有匹配记录 |
getAllKeys(query?, count?) | 读取所有匹配记录的主键 |
count(query?) | 统计匹配条数 |
openCursor(query?, direction?) | 按索引顺序打开游标 |
openKeyCursor(query?, direction?) | 打开只含主键的游标 |
常用属性:name、keyPath、unique(是否唯一)、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 是否落在范围内 |
常用属性:lower、upper、lowerOpen、upperOpen。
8. IDBRequest(请求)与 IDBOpenDBRequest
所有异步操作都返回 IDBRequest(open / deleteDatabase 返回其子类 IDBOpenDBRequest):
| 成员 | 类型 | 说明 |
|---|---|---|
result | 属性 | 成功后的返回值(成功后读取) |
error | 属性 | 失败时的错误对象 |
source | 属性 | 发起请求的仓库 / 索引 / 游标 |
transaction | 属性 | 请求所属事务 |
readyState | 属性 | pending 或 done |
onsuccess / onerror | 事件 | 成功 / 失败回调 |
onblocked | 事件 | 仅 IDBOpenDBRequest:升版本时被旧连接阻塞 |
onupgradeneeded | 事件 | 仅 IDBOpenDBRequest:需要升级结构时触发 |
9. IDBVersionChangeEvent
onupgradeneeded 回调的事件对象,额外提供两个属性:
| 属性 | 说明 |
|---|---|
oldVersion | 升级前的旧版本号 |
newVersion | 正在升级到的新版本号 |
利用两者可以写逐级迁移逻辑:if (oldVersion < 2) { ... } if (oldVersion < 3) { ... }。
10. options 参数详解
带 options 参数的方法一共三处,把每处可选的键值整理如下:
createObjectStore(name, options) —— 创建仓库:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
keyPath | string 或 string[] | null | 主键字段路径;为 null 时插入记录需手动传键或配合 autoIncrement;数组形式可组合复合主键 |
autoIncrement | boolean | false | 为 true 时自动生成自增主键(从 1 开始) |
// 三种典型组合
db.createObjectStore('users', { keyPath: 'id' }); // 记录自带 id
db.createObjectStore('logs', { autoIncrement: true }); // 自增键
db.createObjectStore('orders', { keyPath: ['userId', 'createTime'] }); // 复合主键
createIndex(name, keyPath, options) —— 创建索引:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
unique | boolean | false | 为 true 时索引值不允许重复,写入重复值会报 ConstraintError |
multiEntry | boolean | false | 仅对数组字段有意义:为 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的旧引擎同样会忽略该选项,使用前可按需做特性检测。