在当今的网页应用开发中,高效、安全地管理和存储大量数据变得尤为重要。随着用户对响应速度、数据持久化和隐私保护的要求提升,开发者需要找到一个既能支持高效存储大规模数据,又具备易操作性的解决方案。IndexedDB 作为浏览器内置的低级API,正好满足了这些需求。IndexedDB 能够高效地管理和存取大批量数据,避免了性能瓶颈。
然而,由于 IndexedDB 的 API 比较复杂,许多开发者在初次使用时可能会感到困惑。如何掌握基本操作,避免回调地狱,并封装出易用的存储库?这些问题可能让开发者望而却步。
本文将带你一步步理解 IndexedDB,从基础到进阶,帮助你快速构建高效、易用的数据存储类库,使得前端数据管理,特别是大数据量存储和处理,变得更加轻松。
我们将从以下几个方面进行探讨:
- IndexedDB 介绍:了解 IndexedDB 的工作原理及其优势。
- 构建数据存储类库:逐步引导您创建一个简单的 IndexedDB 封装库。
- Vue中使用示例:展示如何在实际 Vue 项目中利用这个类库进行数据操作。
1 IndexedDB 介绍
1.1 概述
随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据。
现有的浏览器数据储存方案,都不适合储存大量数据:Cookie 的大小不超过 4KB,且每次请求都会发送回服务器;LocalStorage 在 2.5MB 到 10MB 之间(各家浏览器不同),而且不提供搜索功能,不能建立自定义的索引。所以,需要一种新的解决方案,这就是 IndexedDB 诞生的背景。
通俗地说,IndexedDB 就是浏览器提供的本地数据库,它可以被网页脚本创建和操作。IndexedDB 允许储存大量数据,提供查找接口,还能建立索引。这些都是 LocalStorage 所不具备的。就数据库类型而言,IndexedDB 不属于关系型数据库(不支持 SQL 查询语句),更接近 NoSQL 数据库。
IndexedDB 具有以下特点。
(1)键值对储存。 IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以“键值对”的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。
(2)异步。 IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。
(3)支持事务。 IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。
(4)同源限制。 IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。
(5)储存空间大。 IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。
(6)支持二进制储存。 IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。
1.2 基本概念
IndexedDB 是一个比较复杂的 API,涉及不少概念。它把不同的实体,抽象成一个个对象接口。学习这个 API,就是学习它的各种对象接口。下面是对相关概念的简单介绍:
-
数据库:IDBDatabase 对象
数据库是一系列相关数据的容器。每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库。
IndexedDB 数据库有版本的概念。同一个时刻,只能有一个版本的数据库存在。如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据 库版本完成。
-
对象仓库:IDBObjectStore 对象
每个数据库包含若干个对象仓库(object store)。它类似于关系型数据库的表格。
IDBDatabase
对象的transaction()
返回一个事务对象,事务对象的objectStore()
方法返回IDBObjectStore
对象,可通过对象仓库对数据记录进行增删改查,如IDBObjectStore.add()
可以给指定对象仓库新增一条数据记录。这里先有个印象即可。 -
数据记录
对象仓库保存的是数据记录。每条记录类似于关系型数据库的行,但是只有主键和数据体两部分。主键用来建立默认的索引,必须是不同的,否则会报错。主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号。数据体可以是任意数据类型,不限于对象。
-
索引: IDBIndex 对象
IDBIndex
对象是 IndexedDB 中非常强大的工具,使得对数据的检索和操作更加高效和灵活。通过创建索引,开发者可以根据特定的字段快速找到所需的对象,而无需遍历整个对象仓库(IDBObjectStore
),从而优化应用的性能。对象仓库
IDBObjectStore.index(name)
返回指定名称的索引对象IDBIndex
。 -
事务: IDBTransaction 对象
数据记录的读写和删改,都要通过事务完成。事务对象提供
error
、abort
和complete
三个事件,用来监听操作结果。IDBDatabase.transaction()
方法返回的就是一个IDBTransaction
对象。 -
操作请求:IDBRequest 对象
IDBRequest
对象表示打开的数据库连接,indexedDB.open()
方法和indexedDB.deleteDatabase()
方法会返回这个对象。数据库的操作都是通过这个对象完成的。 -
游标: IDBCursor 对象
IDBCursor
对象代表游标对象,用来遍历数据仓库(IDBObjectStore)
或索引(IDBIndex)
的记录。IDBCursor
对象一般通过IDBObjectStore.openCursor()
或IDBIndex.openCursor()
方法获得。 -
主键集合:IDBKeyRange 对象
简单来说,
IDBKeyRange
对象的作用就是在IDBObjectStore 对象仓库
或IDBIndex 索引对象
在查询数据时,限制其查询范围,以达到更加精确的查询效果,并提高查询效率。具体查询范围的方法有如下四个:-
IDBKeyRange.lowerBound()
:指定下限。IDBKeyRange.lowerBound(y); // 查询大于等于 y 的记录 IDBKeyRange.lowerBound(y, true); // 查询大于 y 的记录
-
IDBKeyRange.upperBound()
:指定上限。IDBKeyRange.upperBound(y); // 查询小于等于 y 的记录 IDBKeyRange.upperBound(y, true); // 查询小于 y 的记录
-
IDBKeyRange.bound()
:同时指定上下限。IDBKeyRange.bound(x, y); // 查询大于等于 x 且小于等于 y 的记录 IDBKeyRange.bound(x, y, true, true); // 查询大于 x 且小于 y 的记录
-
IDBKeyRange.only()
:指定只包含一个值。IDBKeyRange.only(y); // 查询等于 y 的记录 IDBKeyRange.only(null); // 查询所有记录
IDBObjectStore 对象仓库
结合IDBKeyRange
查询示例:const request = window.indexedDB.open(databaseName, version); let db; request.onsuccess = function (event) { db = request.result; // 打开名为 test 的仓库对象 const objectStore = db.transaction('test', 'readwrite').objectStore('test') // 指定查询主键值为1-10的数据记录 const keyRange = IDBKeyRange.bound(1, 10) const request = objectStore.getAll(keyRange) // 通过 onsuccess 事件返回结果 request.onsuccess = function (event) { console.log(event.target.result); } };
IDBIndex 索引对象
结合IDBKeyRange
查询示例:const request = window.indexedDB.open(databaseName, version); let db; request.onsuccess = function (event) { db = request.result; let list = [] // 打开名为 test 的仓库对象 const objectStore = db.transaction('test', 'readwrite').objectStore('test') // 假定索引为年龄age, 指定查询年龄为10-20岁的数据记录 const keyRange = IDBKeyRange.bound(10, 20) const request = objectStore .index('age') // 通过 age 获取索引对象 .openCursor(keyRange) // 指针对象 request.onsuccess = function (event) { const cursor = event.target.result if (cursor) { // 这里可对遍历的数据进行相关操作 list.push(cursor.value) // continue() 方法将游标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向null cursor.continue() } else { console.log(list); } } };
-
1.3 操作流程
下面介绍下 IndexedDB 数据库的操作流程,熟悉后可进一步封装成类库来使用,类库的封装在下一节介绍。
1.3.1 打开或创建数据库
使用 indexedDB.open()
方法打开一个数据库。如果指定的数据库不存在,它将创建一个新的数据库。
const request = indexedDB.open('myDatabase', 1); // 'myDatabase' 是数据库名,1 是版本号
request.onsuccess = (event) => {
const db = event.target.result;
console.log('数据库打开成功', db);
};
request.onerror = (event) => {
console.error('数据库打开失败', event);
};
indexedDB.open()
这个方法接受两个参数,第一个参数是字符串,表示数据库的名字。如果指定的数据库不存在,就会新建数据库。第二个参数是整数,表示数据库的版本。如果省略,打开已有数据库时,默认为当前版本;新建数据库时,默认为1
。
indexedDB.open()
方法返回一个 IDBRequest 对象。这个对象通过三种事件error
、success
、upgradeneeded
,处理打开数据库的操作结果。
和 localStorage 类似,IndexedDB 新建完成后可在浏览器开发者工具中的
Application
左侧的IndexedDB
中查看详细内容。
1.3.2 创建对象仓库(IDBObjectStore)
在首次打开数据库或版本升级时,会触发 onupgradeneeded
事件,此时可以创建对象仓库。对象仓库类似于数据库中的表。
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 先判断一下,对象仓库是否存在,如果不存在再新建。
if (!db.objectStoreNames.contains(this.storeName)) {
// 'myObjectStore' 是对象仓库名,keyPath 指定主键名,主键自增设置 autoIncrement: true
const objectStore = db.createObjectStore('myObjectStore', { keyPath: 'id',autoIncrement: true});
objectStore.createIndex('name', 'name', { unique: false }); // 创建索引
}
};
1.3.3 新增数据
使用 add()
方法将数据添加到对象仓库中。
// 'myObjectStore' 是对象仓库名,readwrite 表示可读写,默认readonly只读
// transaction: 在对对象仓库进行读取或写入操作时,通常会使用事务。事务确保操作的原子性。
const transaction = db.transaction('myObjectStore', 'readwrite');
const objectStore = transaction.objectStore('myObjectStore');
const requestAdd = objectStore.add({ id: 1, name: 'Alice' });
requestAdd.onsuccess = () => {
console.log('数据添加成功');
};
requestAdd.onerror = (event) => {
console.error('数据添加失败', event);
};
1.3.4 读取数据
可以通过 get()
方法读取数据。
...
const requestGet = objectStore.get(1); // 通过主键获取数据
requestGet.onsuccess = (event) => {
const data = event.target.result;
console.log('读取到的数据:', data);
};
requestGet.onerror = (event) => {
console.error('读取数据失败', event);
};
1.3.5 更新数据
更新数据可以使用 put()
方法。
...
const requestUpdate = objectStore.put({ id: 1, name: 'Bob' }); // 更新 id 为 1 的数据
requestUpdate.onsuccess = () => {
console.log('数据更新成功');
};
requestUpdate.onerror = (event) => {
console.error('数据更新失败', event);
};
1.3.6 删除数据
使用 delete()
方法删除数据。
...
const requestDelete = objectStore.delete(1); // 删除 id 为 1 的数据
requestDelete.onsuccess = () => {
console.log('数据删除成功');
};
requestDelete.onerror = (event) => {
console.error('数据删除失败', event);
};
1.3.7 遍历数据
遍历对象仓库的所有记录,要使用游标对象 IDBCursor。
...
const request = objectStore.openCursor()
request.onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
console.log('id: ' + cursor.key); // 返回当前记录的主键
console.log('value: ' + cursor.value); // 返回当前记录的数据值
cursor.continue(); // continue()方法将游标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则游标指向null
} else {
console.log('没有更多数据了!');
}
};
1.3.8 使用索引
索引的意义在于,可以让你搜索任意字段,也就是说从任意字段拿到数据记录。如果不建立索引,默认只能搜索主键(即从主键取值)。
假定新建对象仓库的时候,对name
字段建立了索引。现在,就可以从name
找到对应的数据记录了。
...
const indexObj = objectStore.index('name'); // 通过 name 获取索引对象
const request = indexObj.get('李四'); // 查询name为李四的数据记录
request.onsuccess = function (e) {
var result = e.target.result;
if (result) {
// ...
} else {
// ...
}
}
1.3.9 关闭数据库
操作完成后,可以选择关闭数据库连接,会等所有事务完成后再关闭。
db.close();
2 构建数据存储类库
2.1 初始化
初始化数据库、对象仓库(表)、版本号,方便后续的操作。在首次打开数据库或版本升级(版本升级将 version 加1即可)时,会触发 onupgradeneeded
事件,此时可以创建对象仓库及索引。对象仓库类似于数据库中的表。
// 记录每个对象仓库的索引
const indexList = {
test: [
{
field: 'datetime', // 查询时间:number
unique: false,
},
{
field: 'name', // 姓名:string
unique: false,
},
{
field: 'institution', // 机构:array
unique: false,
},
],
test2: [
{
field: 'userId', // 用户id:array
unique: false,
},
]
}
export default class IndexedDB {
/**
* 构造函数
* @param {string} dbName 数据库名
* @param {string} storeName 对象仓库名
* @param {number} version 版本号
*/
constructor(dbName, storeName, version = 1) {
this.dbName = dbName
this.storeName = storeName
this.version = version
this.db = null
}
/**
* 打开或创建数据仓库
* @returns
*/
openDB() {
return new Promise((resolve, reject) => {
// request 对象表示打开的数据库连接,indexedDB.open()方法和indexedDB.deleteDatabase()方法会返回这个对象。数据库的操作都是通过这个对象完成的。
const request = indexedDB.open(this.dbName, this.version)
request.onsuccess = (event) => {
this.db = event.target.result
console.log('Database opened successfully:')
resolve(this.db)
}
request.onerror = (event) => {
console.error('Error opening database:', event.target.error)
reject(event.target.error)
}
// 在首次打开数据库或版本升级时,会触发onupgradeneeded事件
request.onupgradeneeded = (event) => {
const db = event.target.result
console.log('Database upgrade needed')
// 这里可以创建对象存储和索引
if (!db.objectStoreNames.contains(this.storeName)) {
const objectStore = db.createObjectStore(this.storeName, {
keyPath: 'id', // 这是主键
autoIncrement: true, // 实现自增
})
indexList[this.storeName].forEach((item) => {
objectStore.createIndex(item.field, item.field, { unique: item.unique })
})
} else {
// 如果对象存储已存在,首先获取现有的对象存储
const objectStore = event.target.transaction.objectStore(this.storeName)
// 删除现有索引(如果需要)
for (const indexName of objectStore.indexNames) {
objectStore.deleteIndex(indexName)
}
// 重新创建索引
indexList[this.storeName].forEach((item) => {
objectStore.createIndex(item.field, item.field, { unique: item.unique })
})
}
}
})
}
}
2.2 新增数据
/**
* 新增数据
* @param {string} storeName 仓库名称
* @param {string} data 数据
* @returns promise对象,fulfilled状态时的结果为主键值,rejected状态时结果为错误信息
*/
addData(storeName, data) {
return new Promise((resolve, reject) => {
// 获取事务对象,所有读写操作都要通过此对象进行
const transaction = this.db.transaction([storeName], 'readwrite')
// 获取指定对象仓库
const objectStore = transaction.objectStore(storeName)
// IDBRequest 对象可以监听该操作是否成功
const request = objectStore.add(data)
request.onsuccess = () => {
console.log('Data added successfully')
resolve(request.result)
}
request.onerror = (event) => {
console.error('Error adding data:', event.target.error)
reject(event.target.error)
}
})
}
2.3 读取数据
/**
* 通过主键读取数据
* @param {string} storeName 仓库名称
* @param {string} key 主键值
* @returns promise对象,fulfilled状态时的结果为数据记录,rejected状态时结果为错误信息
*/
getData(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName])
const objectStore = transaction.objectStore(storeName)
const request = objectStore.get(key)
request.onsuccess = () => {
console.log('Data retrieved successfully')
resolve(request.result)
}
request.onerror = (event) => {
console.error('Error retrieving data:', event.target.error)
reject(event.target.error)
}
})
}
2.4 更新数据
/**
* 通过主键更新数据
* @param {string} storeName 仓库名称
* @param {object} data 要更新的数据
*/
updateData(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite')
const objectStore = transaction.objectStore(storeName)
const request = objectStore.put(data) // 使用put方法更新数据
request.onsuccess = () => {
console.log('Data updated successfully')
resolve(request.result)
}
request.onerror = (event) => {
console.error('Error updating data:', event.target.error)
reject(event.target.error)
}
})
}
2.5 删除数据
/**
* 通过主键删除数据
* @param {string} storeName 仓库名称
* @param {number} key 主键值
*/
deleteData(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite')
const objectStore = transaction.objectStore(storeName)
const request = objectStore.delete(key)
request.onsuccess = () => {
console.log('Data deleted successfully')
resolve()
}
request.onerror = (event) => {
console.error('Error deleting data:', event.target.error)
reject(event.target.error)
}
})
}
2.6 通过索引读取数据
/**
* 通过索引读取数据,会一次性读取出该索引值的所有记录。如果只查单条,将getAll替换成get即可
* @param {string} storeName 仓库名称
* @param {string} indexName 索引名称
* @param {string} indexValue 索引值
*/
getDataByIndex(storeName, indexName, indexValue) {
return new Promise((resolve, reject) => {
var store = this.db.transaction(storeName, 'readwrite').objectStore(storeName)
var request = store.index(indexName).getAll(indexValue)
request.onsuccess = function () {
console.log('Data retrieved successfully', request.result)
resolve(request.result)
}
request.onerror = function (event) {
console.error('Error retrieving data:', event.target.error)
reject(event.target.error)
}
})
}
2.7 多条件分页查询(重点)
实际项目中,往往不是单一的查询条件,而是多条件查询,且还要进行分页排序等操作,这里结合我实际项目中遇到的问题进行探讨,前提条件是首先要建立索引(参考2.1节中创建索引的方法)。思路是先通过时间索引查询(因为一般起始时间查询后的条数不会特别多,这样后续的遍历次数会大大减少),代码及注释如下:
/**
* 多条件检索
* @param {string} storeName 仓库名称
* @param {object} queryObj 查询条件, 格式: { name:"", // 姓名
institution:[], // 机构
"startDatetime":"2024-09-23", // 开始时间
"endDatetime":"2024-09-23", // 结束时间
"page":1, // 查询页码
"rows":20, // 查询条数
"field":"datetime", // 排序字段
"sort":"desc" // 升降序
}
* @returns {object} 查询结果, 格式:{
data: [],// 查询数据
total: 0,// 总条数
totalPages: 0,// 总页数
currentPage: 1,// 当前页码
}
*/
async findByFields(storeName, queryObj) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName])
const store = transaction.objectStore(storeName)
let keyRange = null
if (queryObj.startDatetime && queryObj.endDatetime) {
const startDate = new Date(startDatetime).getTime()
const endDate = new Date(endDatetime).getTime()
keyRange = IDBKeyRange.bound(startDate, endDate)
}
// 获取仓库中所有记录
// 根据时间参数查询记录,为空时查所有
const request = store.index('datetime').getAll(keyRange)
// 查询成功的处理
request.onsuccess = function (event) {
let results = event.target.result
// 查询姓名(有更多类似条件继续筛就行)
if (queryObj.name) {
results = results.filter(item => item.name === queryObj.name)
}
// 查询机构(有更多类似条件继续筛就行)
if (queryObj.institution.length) {
results = results.filter(item => {
const res = queryObj.institution.includes(item.institution)
if (res) {
return true
}
return false
})
}
// 升降序排序
results = this._sortByField({ data: results, field: queryObj.field, sort: queryObj.sort })
// 分页查询
const page = queryObj.page
const rows = queryObj.rows
const totalResults = results.length
const totalPages = Math.ceil(totalResults / rows)
const offset = (page - 1) * rows
const paginatedResults = results.slice(offset, offset + rows)
const finalRes = {
data: paginatedResults, // 查询数据
total: totalResults, // 总条数
totalPages, // 总页数
currentPage: page, // 当前页码
}
resolve(finalRes)
}
// 查询失败的处理
request.onerror = function (event) {
reject(event.target.error)
}
})
}
/**
* 根据传入的字段排序
* @param {object} {data:查询的结果,field:排序字段,sort:asc升序,desc降序}
* @returns 排序后的结果
*/
_sortByField({ data, field, sort }) {
// 根据 field 排序
if (field === 'datetime') {
data.sort((a, b) => {
if (a[field]) {
a = new Date(a[field]).getTime()
} else {
a = 0
}
if (b[field]) {
b = new Date(b[field]).getTime()
} else {
b = 0
}
if (sort === 'asc') {
return a - b
} else {
return b - a
}
})
}
return data
}
经项目中测试,采用上面的写法,查询2万条数据的时间为 0.2 秒左右
3 Vue中使用示例
将第2节中的类库在 vue 项目中实际应用
初始化
在合适的条件下进行初始化,如进入某个tab页时:
新建或打开 test_db
数据库的 test
对象仓库:
async initDB() {
// 新建或打开的数据库名为 test_db ,仓库(表)名为 test,版本号为1。此时会新建一个 test 仓库
this.dbManager = new IndexedDB('test_db', 'test', 1)
await this.dbManager.openDB()
// openDB()成功后再进行增删改查
}
新建或打开 test_db
数据库的 test2
对象仓库:
async initDB() {
// 新建或打开的数据库名为 test_db ,仓库(表)名为 test2,版本号为2。此时会新建一个 test2 仓库
this.dbManager = new IndexedDB('test_db', 'test2', 2)
await this.dbManager.openDB()
// openDB()成功后再进行增删改查
}
新增数据
const id = await this.dbManager.addData('test', data) // 返回主键id
读取数据
const res = await this.dbManager.getData('test', id) // 返回数据记录
更新数据
const id = await this.dbManager.updateData('test', data) // 返回主键id
删除数据
const id = await this.dbManager.deleteData('test', id) // 返回主键id
多条件分页检索
const res = await this.dbManager.findByFields('test', { name:"", // 姓名
institution:[], // 机构
"startDatetime":"2024-09-23", // 开始时间
"endDatetime":"2024-09-23", // 结束时间
"page":1, // 查询页码
"rows":20, // 查询条数
"field":"datetime", // 排序字段
"sort":"desc" // 升降序
})
//返回结果res: {
// data: paginatedResults, // 查询数据
// total: totalResults, // 总条数
// totalPages, // 总页数
// currentPage: page, // 当前页码
// }
关闭数据库
使用完应释放资源占用
beforeDestroy(){
this.dbManager.close()
}