首页 > 互联资讯 > 技术交流  > 

用IndexedDB持久化你的前端数据

前端数据的存储方式有多种,如最简单的一个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持久化你的前端数据