用IndexedDB持久化你的前端数据
- 技术交流
- 2024-09-25 20:31:01
前端数据的存储方式有多种,如最简单的一个Object对象,Map,以及cookie,Web Storage API(localStorage/sessionStorage)等,还有这次谈到的IndexedDB。但是这些存储方式有一部分只是用于临时存储数据,只有cookie,localStorage和IndexedDB支持持久化存储(还有Web SQL,FileSystem API等,但不被广泛使用)。
这几种持久化数据的方式的差异常被提及,所以下面只简单列举一下它们的特点:
特征cookielocalStorageIndexedDB数据结构字符串键值对(存储的值只能是字符串)支持结构化克隆算法的对象同/异步同步同步异步最大容量Kb级别(一般约4kb/域名)Mb级别(一般约5Mb)可见MDN跨域同域共享(可精确到路径级别)同域共享同域共享Worker中的可用性否否是
为何使用IndexedDB
IndexedDB可以看作关系型数据库和NoSQL的结合体,它的存储结构类似NoSQL,以键值对方式存储,存储的内容是一个个对象,增删改查更像传统数据库,支持事务。
具体来讲,它的存储的内容如下图这样:
(在Chrome DevTools中Application面板-Storage-IndexedDB可以看到数据库的内容)
相信大家对localStorage都不陌生,一般来说会用它来存储一些量小的数据或者一些元数据,但由于容量的限制,很多时候它不能把一个应用所需的完整的数据保存下来,当然一般情况下我们也没有这么做必要,但是在开发一些特殊应用的时候IndexedDB就会显示出它的价值,如在实现PWA时,做数据可视化需要缓存大量数据的时候等等。
除了容量上的优势,IndexedDB另一个显著的优点是异步,以及能够存储多样化的数据,localStorage使用起来的一个麻烦之处就是只支持字符串存储,这使得很多时候我们需要将数据序列化/反序列化,同时这个过程也是同步的,这在麻烦的同时也增加了页面阻塞的可能。
虽然规避了localStorage的一些缺点,但IndexedDB也不是没有缺点,其中一个缺点就是使用起来复杂,在创建IndexedDB之前需要规划好数据库的索引,增删改查需要使用事务来管理,而完全异步的设计也使得代码编写变得复杂。另一个缺点是兼容性问题,它的兼容性稍弱于localStorage,但其实目前IndexedDB的兼容性已经很不错了,在移动端浏览器几乎全兼容,大部分桌面端浏览器也支持。
还有一个比较少人提到的问题是IndexedDB没有传统数据库的触发器,我们知道localStorage有一个'storage'事件,在一个页面中的数据被修改之后,同域下的其它页面会触发'storage'事件,但IndexedDB没有这个功能,如果需要的话要自己手动实现。
如何使用IndexedDB
创建/打开/升级/删除一个数据库
创建/打开/升级数据库的API都是indexedDB.open(dbName, version?),这个方法接受两个参数,一个是数据库(不存在则会创建)的名字,另一个是数据库的版本号(版本号需要是正整数,并且不低于已存在数据库的版本号)。
let request = indexedDB.open('myFirstDB', 1);
request.onupgradeneeded = function() {
// 初始化/升级数据库
let db = request.result;
};
request.onerror = function() {
console.error("Error", request.error);
};
request.onsuccess = function() {
let db = request.result;
};
复制代码
数据库名字很好理解,那么版本号是用来干嘛的呢,其实主要是用来修改数据库结构的,试想这样一个场景:
我们写好了一个版本的代码,其中使用了IndexedDB,版本号是1
但在需求变更之后IndexedDB的索引结构甚至数据库表名都发生了改变,数据库里面也产生了脏数据
这时候就可以把版本号修改(如设为2),如果用户曾经使用过旧版本的数据库,此时再打开页面就会触发数据库版本升级的事件,我们便可以在这个事件中处理旧数据以及修改数据库结构。
在open返回的request中有三个常用事件回调:
onupgradeneeded: 在初次创建数据库,或者升级数据库之后会触发
onsuccess: 只有在打开已存在相同版本数据库时触发
onerror: 打开数据库发生错误时触发
由于IndexedDB异步的设计,上面的open返回的是一个request而不是直接返回的数据库,这个request是一个类似EventEmitter的东西,我们需要订阅这个request上的事件才能得到最终结果,这种使用方式在IndexedDB的API设计中十分常见,所以实际上我们可以设计一个工具函数简化这个过程(省略外部包裹函数):
function unwrap(request) {
return new Promise((resolve, reject) => {
request.onerror = function () {
reject(request.error);
}
request.onsuccess = function () {
resolve(request.result);
}
});
}
let db = await unwrap(indexedDB.open('myFirstDB', 1));
复制代码
上面说到创建新的数据库和升级数据库都是触发的onupgradeneeded这个事件,那么怎么判断是创建新数据库还是升级已有数据库呢,实际上在onupgradeneeded事件中有一个oldVersion属性,如果为0代表数据库是从无到有的,这也是为什么我们在创建数据库时只能将版本号设为正整数,因为0代表没有数据库的状态:
request.onupgradeneeded = function(event) {
let db = request.result;
switch(event.oldVersion) { // existing db version
case 0:
// 创建新的数据库
default:
// 更新已有数据库
}
};
复制代码
那么如何删除一个数据库呢:
let result = await unwrap(indexedDB.deleteDatabase('myFirstDB'));
复制代码
数据库版本的一致性
同一个域名下的IndexedDB是共享的,那么就存在多个页面数据库版本不同的问题,例如一个页面中刷新了页面,刚好这时候数据库更新了,但另一个页面没有刷新,这时候用的还是旧的数据库,此时会发生什么呢,实际上这是新的数据库是无法打开的,并且会触发indexedDB.open('myFirstDB', 1).onblocked事件,一般可以为数据库增加一个onversionchange事件监听解决这个问题:
let db = await unwrap(indexedDB.open('myFirstDB', 1));
db.onversionchange = function() {
db.close();
// 重新加载页面等
};
复制代码
创建一个objectStore
objectStore类似关系型数据库中的表(table)和NoSQL中的集合(collection),这个objectStore可以有多个索引,而索引只能再数据库创建或者更新的时候创建,也就是在onupgradeneeded事件里面。
下面看看怎么创建一个objectStore,因为需要自定义onupgradeneeded事件,所以不能再使用上面的unwrap函数了:
let request = indexedDB.open('myFirstDB', 1);
request.onupgradeneeded = function(event) {
let db = request.result;
const st = db.createObjectStore('test', { keyPath: 'id' });
};
复制代码
可以看到用的是db.createObjectStore(storeName: string, options?)这个方法,其中第一个参数是objectStore的名字,第二个参数是关于主键的配置一般有以下几种(显然主键在objectStore必须唯一):
...
const st = db.createObjectStore('test', { keyPath: 'id' }); // 使用对象中的'id'作为主键
const st = db.createObjectStore('test', { keyPath: ['name', 'date'] }); // 使用对象中的多个属性作为主键
const st = db.createObjectStore('test', { autoIncrement: true }); // 创建一个自增的主键
...
复制代码
创建objectStore的索引
上面说到创建objectStore的时候可以定义主键,主键是索引的一种,索引是IndexedDB里面定位对象的依据。
假如我们把对象中的'id'这个属性设置为主键,这时候我们可以靠'id'来搜索对象,但很多时候我们可能要依靠一些其它属性来搜索,这时候就需要创建额外的索引:
request.onupgradeneeded = function(event) {
let db = request.result;
const st = db.createObjectStore('test', { keyPath: 'id' });
const indexOfDate = st.createIndex('indexOfDate', 'date');
const indexOfMultipleKeys = st.createIndex('indexOfMultipleKeys', ['name', 'date']);
};
复制代码
可以看到用createIndex(indexName: string, indexField: string | string[])可以与定义objectStore的keyPath一样,即可以定义由单一属性形成的索引,也可以创建复合索引。其中第一个索引是所以名字,在要用到索引的时候可以靠这个来辨识,第二个参数则是参与构建索引的属性。
删除一个objectStore
与创建类似,但要简单一些:
db.deleteObjectStore('test');
复制代码
objectStore的事务操作
objectStore中内容的增删改查都是用的transaction方法来创建事务,再进行其它操作:
db.transaction(storeNames: string | string[], mode?: string)用于创建一个事务,传入两个参数,第一个是需要操作的objectStore的名字(可以是多个),第二个是读写方式,有三个可选项,分别是'readonly','readwrite'和'versionchange',常用前两个进行读写操作,它们的区别主要在于性能:'readonly'支持多个来源同时读取数据,故性能较好,而'readwrite'由于存在写操作需要加锁,所以如果存在多个操作时不能同时执行。
添加对象
添加对象用的是add方法,添加的对象的主键不能与已存在的数据重复。
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let newObj = {
id: 'abc',
name: 'this is a new object',
date: Date.now()
};
let result = await unwrap(testStore.add(newObj));
复制代码
修改对象
修改对象用的是put方法,IndexedDB会根据传入对象的主键对相应对象进行修改,注意如果不存在相同主键的对象,那么会创建新的对象(与add相同)。
...
let result = await unwrap(testStore.put(newObj));
...
复制代码
删除对象
修改对象用的是delete方法,需要传入一个与objectStore匹配的主键。
...
// 假设主键是'id'
const id = 'abcdef';
let result = await unwrap(testStore.delete(id));
...
复制代码
从上面可以看到,删除对象需要用到主键,但很多时候我们的操作流程是:使用某个属性搜索一个对象 => 再删除这个对象,也就是说在这个流程中可能一开始并不知道主键是什么,这时候上面创建的索引就派上用场了,假设我们存储的对象中含有属性'name',主键是'id',其结构如下:
{
id: string; // primary key
name: string;
}
复制代码
假设已经创建了基于属性'name'的索引,现在需要删除'name'为'awesome_name'的对象,那么可以如下操作:
...
request.onupgradeneeded = function(event) {
let db = request.result;
const st = db.createObjectStore('test', { keyPath: 'id' });
const indexOfDate = st.createIndex('indexOfName', 'name');
};
...
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let indexOfName = testStore.index('indexOfName');
let id = await unwrap(indexOfName.getKey('awesome_name'));
let result = await unwrap(testStore.delete(id));
复制代码
从这里可以看到索引的重要功能就是搜索,下面就看看搜索是怎么基于索引操作的。
搜索对象
使用主键搜索
如果我们已经知道需要搜索对象的主键,那么可以直接使用主键索引来搜索:
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let result = await unwrap(testStore.get('id_of_obj'));
let Allresults = await unwrap(testStore.getAll('id_of_obj'));
复制代码
搜索使用的是get和getAll,两者区别在于get返回一个结果,getAll返回多个结果。
从上面来看,由于主键唯一,那getAll不也只能获取一个结果吗?
实际上,这两个方法除了传入key之外还可以传入一个IDBKeyRange(这是一个built-in对象)的实例,用于条件搜索:
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let exactKey = IDBKeyRange.only('id');
let upBound = IDBKeyRange.upperBound('max_id', false);
let lowBound = IDBKeyRange.lowerBound('min_id', false);
let closeBound = IDBKeyRange.bound('min_id', 'max_id', false, false);
let results = await unwrap(testStore.getAll(exactKey)); // 等同于testStore.getAll('id')
let lowerResults = await unwrap(testStore.getAll(upBound));
let upperResults = await unwrap(testStore.getAll(lowBound));
let innerResults = await unwrap(testStore.getAll(closeBound));
复制代码
IDBKeyRange中的upperBound,lowerBound,bound用于创建搜索区间的最大值和最小值,最后的参数(默认false)为true则不包含最值。
(getAll还可以传入第二个参数count,限制返回对象的最大数量)
使用索引搜索
使用主键索引搜索大部分时候都不能满足需求,那么就要用到额外的索引了,相比主键索引搜索,额外索引搜索只需要修改两行代码:
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let indexOfName = testStore.index('indexOfName'); // added
let result = await unwrap(indexOfName.get('id_of_obj')); // changed: testStore -> indexOfName
let Allresults = await unwrap(testStore.getAll('id_of_obj'));
复制代码
条件搜索也是一样的:
let upBound = IDBKeyRange.upperBound('max_name', false);
let lowBound = IDBKeyRange.lowerBound('min_name', false);
let closeBound = IDBKeyRange.bound('min_name', 'max_name', false, false);
let lowerResults = await unwrap(indexOfName.getAll(upBound));
let upperResults = await unwrap(indexOfName.getAll(lowBound));
let innerResults = await unwrap(indexOfName.getAll(closeBound));
复制代码
使用游标搜索
游标是一种更灵活的检索内容的方式,它既可以在objectStore上使用,也可以在额外的索引上使用,方法是使用openCursor([query, [direction]]),可传入两个可选参数,第一个参数是IDBKeyRange,第二个是游标遍历的顺序,可选值为"next" | "nextunique" | "prev" | "prevunique",分别对应升序和降序遍历,后缀unique表示跳过重复的值:
有一点需要注意,IndexedDB里面任何索引都是已排序的,并且是升序的,所以getAll以及cursor返回的值都是按照索引排序好的:
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
let cursorRequestOnPrimeryKey = testStore.openCursor();
let cursorRequestOnAddedIndex = testStore.index('indexOfName').openCursor();
cursorRequestOnPrimeryKey.onsuccess = function() {
let cursor = cursorRequestOnPrimeryKey.result;
if (cursor) {
let key = cursor.key;
let value = cursor.value;
console.log(key, value);
cursor.continue();
} else {
console.log("cursor end");
}
};
复制代码
上面代码有一点需要注意的是,onsuccess这个回调函数会被执行多次,每次游标会遍历一个值,内部需要使用cursor.continue()来保证onsuccess被持续调用。
transaction与事件循环
在IndexedDB中通过db.transaction(...)创建一个事务,然后通过这个事务做一些数据库操作,那么这些操作什么时候会被提交到数据库呢,其实是当前事件循环中微任务队列被清空以后,下一个宏任务之前,所以一个transaction所包含的操作必须要在下一个宏任务执行前完成,例如:
let transaction = db.transaction("test", "readwrite");
let testStore = transaction.objectStore("test");
setTimeout(() => testStore.get('id_of_obj'), 0)
复制代码
如果像上面一样将对transaction的操作放到下一个宏任务,那么就会得到一个错误:
Failed to execute 'get' on 'IDBObjectStore': The transaction has finished.
复制代码
好了,以上就是使用IndexedDB所需要的一些基本内容。
作者:hjylxmhzq
链接:https://juejin.cn/post/6971311132564979720
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
用IndexedDB持久化你的前端数据由讯客互联技术交流栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“用IndexedDB持久化你的前端数据”