indexedDB中文教程

本文通过对IndexedDB的概念、API进行详解,讲清楚IndexedDB中的事务机制,以及objectStore这个核心概念。和一般的教程不同,这篇文章除了一步一步搭建起一个IndexedDB数据库操作的流程,连接、选择、读取、更新等,而且还把最常用的API接口列举出来,方便开发者在一个地方就可以完成所有查询。

indexedDB是html5标准引入的web数据持久化方案之一,现代浏览器大多按照标准对其进行了实现,我在新的项目中用到它来作为持久化数据存储,于是详细研究了一番,MDN上的解释虽然权威,但是由于知识不成体系,细节也讲的不透,所以我打算自己写一篇教程,对indexedDB的各个细节进行详解。

结构体系

这里说的结构体系,是指在indexedDB中,几个重要的概念之间的关系。这些概念包括:

简单的说,indexedDB是非关系型数据库,数据组织不像SQL数据库一样,有表、记录,indexedDB里面没有表和记录的概念,它的组织单位(unit)是js对象(object),object在indexedDB里的地位就跟SQL数据库里面的记录一样,是数据的最终体现形式。我们通过下面的图来阐述indexedDB和SQL数据库结构体系的不同,以帮助理解。

IndexedDB概念结构示意图

在level 1,两者都有database的概念,要存储数据,首先要创建一个数据库。在level 2,两者就有了区别,SQLDB有表的概念,而indexedDB对应的是objectStore。简单的说就是,在数据库中开辟一块store用来存储object,同样,一个数据库中可以有多个(甚至无限个)objectStore。在level 3则差别更大,SQLDB有记录的概念,而indexedDB直接存放js的object数据对象。

indexedDB存储的object是结构化数据。简单理解就是,不能存function等非结构化的数据,object必须是以键值对组成的字面对象。并且支持嵌套结构,也就是说object里面嵌套了object,和js实现无缝对接。(而同样是本地化存储的localStorage却需要对数据格式化为字符串后才能保存。)

打开/创建数据库

level 1层面的操作,indexedDB对象被挂载在window上,直接调用open方法进行打开,如果该数据库不存在,则创建一个新的数据打开:

let request = window.indexedDB.open('mydb', 1)

open有两个参数:

数据库版本

上面的version参数对于初学者而言不是很好理解。如果你用过docker的话,知道有一个东西叫镜像(image),在镜像的基础上构建自己的容器,可以再创建一个新的镜像,那么这个新的镜像其实就包含了老的镜像。这里的version也有这个意思。version为1时,创建了一些objectStore,当你需要添加新的objectStore或者修改某些objectStore的时候,就需要升级version。这时你可能有两个不同的version,1和2. 当你在用open方法打开一个version的时候,你要知道,你得到的db容器对应的objectStore是不同的,如果你还要打开version=1,那么你在version=2中创建的objectStore和新增的object都是不存在的。所以说,新的version一般包含了老的version。

这时你会想,如果我从新的version切换为老的version,还可以在老的version里面添加数据吗?这个问题我们暂时保留。

补充:有同学留言想对这个问题进行深入探讨。我这里说一下我的想法。从项目的开发上讲,我们只会在发布代码时去升级version,而不会在程序运行过程中通过程序去更改version。我们升级version,是为了对数据库结构进行修改,触发onupgradeneeded方法。如果你硬是要降级version,实际上你可以这样想,version只是一个代号,它对最终效果是触发onupgradeneeded,通过程序去更改version来降级,会发现你在onupgradeneeded中的某些操作会导致数据库报错。因此,不建议做降级操作。

在MDN里面有这样一句话:When you call open() with a greater version than the actual version of the database, all other open databases must explicitly acknowledge the request before you can start making changes to the database (an onblocked event is fired until they are closed or reloaded). 也就是说,1.你要传入一个更大当版本来触发onupgradeneeded,2.在数据库处于open状态时,对数据库对修改是被block住对,直到它被close或reload之后,这些修改才会生效。从这种解释来讲,降级version会引起报错。

现在你需要记住的是,在后面的代码操作中,你要时刻保证你使用对了version,它的使用场景只有两种:

从代码的层面来看,并非这两个事情发生才触发了version的改变,恰恰相反,如果你要修改或添加objectStore,你必须通过传递新的version参数到open方法中,触发onupgradeneeded,在onupgradeneeded的回调函数中才能实现目的。

获得db容器

indexedDB中非常重要的概念是transaction(事务),不过我们会在下一节讲什么是事务。这里我们需要快速了解,怎么从上面的open方法之后,获得可以用来进行操作的数据库对象容器:

let request = window.indexedDB.open('mydb', 1)
request.onsuccess = e => {
  let db = e.target.result
}

这样就获得了db的容器,利用该容器,我们就可以进行数据库的进一步操作。

当然,作为jser,你早就发现,这是一个回调操作。request.onsuccess在open方法中被调用,假如在调用之前你没有用一个函数去覆盖onsuccess方法,那么你是得不到db容器的,因此,onsuccess要在仅接着open之后写。

而所有后续的操作,必须在得到db容器之后才能进行,因此,这又是一个异步的操作,你不能在上面代码之后直接使用db变量,而是因为在onsuccess的函数体内继续写代码。而且indexedDB的所有编程都是这样,这会导致嵌套层层加深,因此,我们这篇文章后面还会提供使用Promise进行封装的类,使用该类你无需做复杂的操作。

创建objectStore及其索引

objectStore是indexedDB中非常核心的概念,它是数据的存储仓库,一个objectStore类似于SQL数据库中的表,存放着相关的所有数据。所谓的“相关”是指,这些object必须具备相同的一个属性名,也就是“主键”,在indexedDB中被成为keyPath。这还有点像SQL数据库中的primaryKey,不过SQL数据库中不必一定有primaryKey,而objectStore中的keyPath必须有。如果你存入的某个object不存在那个属性,而该属性在indexedDB中又不是autoIncrement,那么就会报错,如果autoIncrement被设置为true,在没有该key的情况下,存入数据库的时候,会被自动添加上,这个效果跟SQL数据的自增字段是一样的。

创建objectStore

在我们使用事务对objectStore进行操作前,我们需要创建对应的objectStore,否则都没有,怎么操作呢?创建objectStore和修改objectStore都只能在db的onupgradeneeded事件中进行,因此,要创建objectStore,必须在前面的open操作那个时候来进行。

let request = window.indexedDB.open('mydb', 1)
request.onupgradeneeded = e => {
  let db = e.target.result
  db.createObjectStore('mystore', {keyPath: 'id'})
}
request.onsuccess = e => {
  let db = e.target.result
  // ...
}

上面的红色代码中,我们使用createObjectStore方法来实现objectStore的创建。但是,需要注意的是,一个database中,只允许存在一个同名的objectStore,因此,如果你第二次createObjectStore相同名的objectStore,程序会报错。比如你的程序如果像上面这样写,必然会遇到一个问题,就是当你更新version的时候,会再次执行createObjectStore,那么就会报错,程序就会中断。另一个注意点是,一旦一个objectStore被创建,它的name和keyPath是不能修改的。那么有什么办法来控制呢?通过一个判断,判断是否已经存在同名的objectStore即可。

let request = window.indexedDB.open('mydb', 1)
request.onupgradeneeded = e => {
  let db = e.target.result
  if (db.objectStoreNames.includes(storeName)) {
    db.createObjectStore('mystore', {keyPath: 'id'})
  }
}
request.onsuccess = e => {
  let db = e.target.result
  // ...
}

后面我们还会讲到,如何修改objectStore的索引时,应该怎么处理。

objectStore的索引

在indexedDB中也存在索引,和SQL中索引的作用不同,SQL中的索引是对指定字段进行特殊记录,以方便在检索时提高检索性能。indexedDB中的索引,是指在除了设置的keyPath之外,提供其他的检索方式。在indexedDB中,后面会讲到,objectStore有get方法,但是它的参数是keyPath对应的值。而如果要用其他的字段来检索某个object,那就麻烦了,所以indexedDB提供了索引的方式,通过一个index方法来实现索引检索。所以,实际上,objectStore的索引,等效于SQL表的字段。

什么是keyPath?

前面反复提到keyPath这个概念。在前面的代码里面可以发现在createObjectStore的时候,可以规定一个keyPath。
实际上,keyPath的概念非常简单,它规定了你把object的那个属性作为检索的入口。比如你有一堆object:

{
  id: 1,
  name: 'tome'
}
{
  id: 2,
  name: 'sue'
}
...

你会发现,如果你想通过一个get方法来获取第一个object,你必须传入一个参数,而indexedDB怎么处理的呢?如下:

objectStore.get(1)

上面的1,就是id=1。当keyPath=id的时候,get方法去找的就是id=1的那个object,因此,id对于所有object而言,应该是唯一的。

但是怎么确保唯一呢?需要在建立索引的时候,传入一个unique参数,所以,实际上,createObjectStore的时候传入的keyPath是一个特殊的索引。

keyPath, indexName, key, primaryKey的区别是什么?

在学习indexedDB的索引时,你会碰到题目中的这几个概念,经常容易被搞懵。那么它们的区别到底是什么呢?

keyPath:它是指你访问一个object属性的路径,为什么要用“路径”这个词呢?因为你访问一个属性可以传入一个链式表达式,即形如“body.head.eyes”这样的形式。也就是说,它会去找类似 { body: { head: { eyes: [] } } } 这种形式的真实属性位置。所以,你现在知道为什么叫path了吧。

indexName:如果你用过SQL,应该可以理解,索引,其实是一个基于当前表的另外一张附属表,该表内保存了字段名和值之间的对应关系。indexName是这个索引的名字,而这个索引里面传入的keyPath代表这个索引要对应object的哪个属性。例如,indexName为"vioce",而keyPath为"value.vioce"。在利用索引进行查询的时候,查询要的key是indexName的值,但数据内部会默认找到indexName对应但keyPath,然后去找keyPath值对应的object。更高级的用法是,一个indexName可以对应多个keyPath,例如:{ indexName: 'color', keyPath: ['red', 'blue', 'yellow'] } 在查询的时候,你可以这样:query('color', '121'),这个时候,数据库会去找出red, blue, yellow字段中任何一个为121的object。

key:其实这里的key是指值,而非键,这是比较坑的地方。比如用idb.get()方法的时候,传入的不是一个属性的名字,而是传入要查的属性的值。比如,idb.get('123'),这里的'123'根本不是键名,而是键值。

primaryKey:本文不是说过primaryKey吗?但是我们现在回过来,专门看索引。在indexedDB中,一个索引它在存储时,它一定会把对应的primaryKey存进来。primaryKey实际上就是这个objectStore的keyPath的值,因为索引本身也有keyPath,本身也有key(也就是索引的keyPath的值),所以在设计上,indexedDB给索引加上了一个primaryKey属性,这样可以快速定位对象的具体位置。

上面这几个概念在使用indexedDB时,一定要好好理解,因为一不小心,就会由于概念模糊犯错。

如何创建索引?

创建索引这个动作,实际上是对objectStore进行修改,因此,只能在db的onupgradeneeded事件中处理。

let request = window.indexedDB.open('mydb', 1)
request.onupgradeneeded = e => {
  let db = e.target.result
  let objectStore = db.createObjectStore('mystore', {keyPath: 'id'}) //注意这里应该进行判断是否已经存在这个objectStore,我是为了省事
  objectStore.createIndex('id', 'id', {unique: true})
}

objectStore对象有一个createIndex方法,它可以创建索引。它有三个参数,第一个参数是这个索引的name;第二个参数是key,这个key对应的就是object的属性名,name是可以自己定的,它会用在后面的index方法中进行检索,也会被记录在objectStore的indexNames属性里面,但是key必须和object的属性对应;第三个参数是options,其中unique选项被放在这里面。

如何修改索引?

虽然objectStore本身的信息是不能修改的,比如name和keyPath都是不能修改的,但是它所拥有的索引可以被修改,修改其实就是删除+添加操作。用到的就是deleteIndex这个方法,所以,如果你想修改一个索引,要做的就是先删除掉原来的同名索引,然后添加新的索引。举个例子,如果你知道数据库中已经存在mystore这个objectStore了:

request.onupgradeneeded = e => {
  let objectStore = e.target.transaction.objectStore('mystore')
  let indexNames = objectStore.indexNames
  if (indexNames.includes('name')) {
    objectStore.deleteIndex('name')
  }
  objectStore.createIndex('name', 'name', {unique: false})
}

上面这段代码通过对已有的objectStore的index进行操作,如果存在某个index时,就删除它再添加,如果没有就直接添加。
你可能看代码看到这里会有点晕,这是因为我只提取了代码片段在这里,后文会有完整的代码,根据完整的逻辑去看你会比较清晰。

事务-transaction

所有数据库中都有这个概念,它是为了确保当并非执行某些操作时,不致混乱。举个简单的例子,当你在像好友打钱的时候,发起了一个请求,这个请求发起后,就建立了你打钱的事务,后面一大堆数据库写入的操作,但是,假如中间突然机房停电,银行系统发生故障,你又发起了第二个打钱的请求,那么会不会有这样一种可能,你第一次打出去的钱你朋友根本没收到?数据库系统为了避免这种情况,采用事务机制,如果出错,那就回滚,把你打出去但对方没收到的钱回到你账上,重新再执行一次打钱的操作,执行完这个操作之后,再执行你第二次打钱的操作。这样就保证了数据库增删改有序不混乱。

indexedDB里面的事务也是一样,保证了所有操作(特别是写入操作)是按照一定的顺序进行,不会导致同时写入的问题。另外,indexedDB里面,强制规定了,任何object读写的操作,都必须在一个事务中进行。从前面的代码里面你也看到了,对objectStore的修改,其实也是在一个事务中进行。

在代码层面,我们必须通过transaction方法,向数据库容器提出事务要求,才能往具体的objectStore进行数据处理:

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.get('111')

上面这段代码的操作,我们得到了具体要进行操作的objectStore,这和我们预期直接通过db.objectStore('myObjectStore')这样简洁的方法完全不同,indexedDB中不能这么直接去获取objectStore,而必须通过transaction。

transaction方法有两个参数:

而通过transaction的objectStore方法可以获取想要操作的objectStore,但是它的参数必须存在于上面的objectStores数组中,毕竟你的这个事务已经规定了要对哪些objectStore进行操作。

因为objectStore是在事务中获取,因此一个objectStore实例,如果有一个transaction属性的话,那么可以通过这个属性找出它的事务的实例。在indexedDB中,你只能在事务中得到一个objectStore实例,如果通过db的话,最多只能得到objectStore的名字列表,要获得objectStore的实例,必须在transaction中。这样说来,一个objectStore,可能同时存在于多个事务中?这一点也暂时存疑。

补充:由于js是单线程运行程序,所以对于所有事务而言,也是有先后顺序的,只有当某些事务完成之后,才会进入后面当事务,因此即使一个objectStore存在于多个事务中,它也会按照事务出现当先后顺序被操作,而不是被不同当事务交叉操作。另外,对一个objectStore的写入操作的事务只允许存在一个,它会自动根据你传入的mode值为readwrite时进行判断和报错。

知道有事务的存在之后,一定要注意indexedDB事务的生命周期。一个事务,它会把你在它的生命周期里面规定的操作全部执行,一旦执行完,周期结束,那么事务就关闭了,你不能再利用这个事务的实例进行下一步操作。

举一个错误的例子:

var tx
var objectStore
var request = indexedDB.open('name', 1)
requst.onsuccess = function(e) {
  var db = e.target.result
  tx = db.transaction(["MyOBJ"],"readwrite")
  objectStore = tx.objectStore("MyOBJ")
}
btn.onclick = function() {
  objectStore.add({}) // 这时会报错,因为生命周期已经结束了,你不能在这里使用tx或objectStore
}

上面灰色的代码是一个错误的使用思路。写代码的人想在全局某个地方创建了一个事务,并用一个变量保存起来,好在后续操作中使用。但是,indexedDB中,事务会在非常短的时间里面循环检查自己,当发现自己内部已经没有任何任务要做的时候,就将自己关闭。要深入了解这一点,你需要了解javascript调用栈、事件循环的知识,可能有点复杂。

总之,你可以把indexedDB里面的事件想象成时间非常短(1ms)的debounce,如果在这个1ms里面,没有新的任务要做,它就关闭了,不能再被使用。所以,上面正确的代码应该是:

btn.onclick = function() {
  var request = indexedDB.open('name', 1)
  requst.onsuccess = function(e) {
    var db = e.target.result
    var tx = db.transaction(["MyOBJ"],"readwrite")
    var objectStore = tx.objectStore("MyOBJ")
    var request = objectStore.add({ ... })
  }
}

即在每一次发生click事件的时候去发起一个事务,再在这个事务中搞事情。

数据的存取

当一个事务开始之后,在它的生命周期以内,你可以对objectStore进行数据操作,数据操作无非是增删改查。前面介绍过如何获取事务中的objectStore,现在,我们就用获取到的objectStore进行数据操作。

获取数据

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.get('100001')
request.onsuccess = e => {
  let obj = e.target.result
}

的确,在indexedDB事务机制下进行操作是很麻烦的,上面代码中我们使用了get方法获取主键值为100001的object,但是获取过程是一个Request,后文会详细讲Request是什么东东,只有在其onsuccess事件中才能得到获取到的结果。

添加数据

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.add({
  id: '100002',
  name: 'Zhang Fei',
})

添加数据使用add方法,传入一个object。但是这个object有限制,它的主键值,也就是id值,不能是已存在的,如果objectStore中已经有了这个id,那么会报错。因此,在某些程序中,为了避免这种情况的发生,我们使用put方法。

更新数据

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.put({
 id: '100002',
 name: 'Zhang Fei',
})

put方法和add方法有两大区别。一,如果objectStore中已经有了该id,则表示更新这个object,如果没有,则添加这个object。二,在另一种情况下,也就是设置了autoIncrement为true的时候,也就是主键自增的时候,put方法必须传第二个参数,第二个参数是主键的值,以此来确定你要更新的是哪一个主键对应的object,如果不传的话,可能会直接增加一个object到数据库中。从这一点上讲,自增字段确实比较难把握,因此我建议开发者严格自己在传入时保证object中存在主键值。

删除数据

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.delete('100001')

delete方法把你传入的主键值对应的object从数据库中删除。

游标 Cursor

在indexedDB中,也存在游标的概念,所谓游标,简单的理解,就是“一个用来记录数组正在被操作的某个下标位置的变量”,举个例子,你有一个数组[1,2,3,4],现在你要对它进行遍历,使用forEach方法,那么forEach方法怎么知道你上次操作到第几个元素,现在应该操作第几个元素呢?就是通过游标。游标是一个机制,你无法把游标打印出来看,而是通过游标得到你当前操作的元素,同时,游标也就意味着有类似于next的方法,可以用来移动游标到下一个位置。

获取全部object

想要获取一个objectStore中的全部object可不是一件简单的事。indexedDB没有直接提供类似的方法来获取。那么我们应该怎么办呢?利用游标。

let transaction = db.transaction(['myObjectStore'], 'readonly')
let objectStore = transaction.objectStore('myObjectStore')
let request = objectStore.openCursor()
let results = []
request.onsuccess = e => {
  let cursor = e.target.result
  if (cursor) {
    results.push(cursor.value)
    cursor.continue()
  }
  else {
    // 所有的object都在results里面
  }
}

通过openCursor方法打开游标机制,再其onsuccess事件中,如果cursor没有遍历完所有object,那么通过执行cursor.continue()来让游标滑动到下一个object,onsucess会被再次触发。而如果所有的object都遍历完了,cursor变量会是undefined。
注意上面蓝色的results,它的声明必须放在onsuccess回调函数的外部,因为该回调函数会在遍历过程中反复执行。

在Firefox中,浏览器自主实现了一个getAll方法,但是它不是标准的indexedDB的接口,因此不推荐使用,而本例的操作方法,则是获取全部object的标准做法。

通过index查询集合

前文说过,在创建objectStore的时候,可以为其创建index,在查询时,可以利用这些index来进行object的获取。

let objectStore = db.transaction([storeName], 'readonly').objectStore(storeName) // 开始偷懒了
let objectIndex = objectStore.index('name')
let request = objectIndex.get('Li Hua')
request.onsuccess = e => {
  let item = e.target.result
}

(index方法我们在后面介绍。)但是,你可以发现,这里的get方法只能获取一个object,假如同name的object有多个,应该怎么办呢?这时可以利用游标的特性。

let objectStore = db.transaction([storeName], 'readonly').objectStore(storeName)
let objectIndex = objectStore.index('name')
let request = objectIndex.openCursor()
request.onsuccess = e => {
  let cursor = e.target.result
  if (cursor) {
    results.push(cursor.value)
    cursor.continue()
  }
  else {
    // 所有的object都在results里面
  }
}

你可以发现,整个操作跟前面的获取所有object几乎一样,只不过这里通过index方法利用index进行了键的切换。“键的切换”这个思维很重要,使用index方法之后,相当于之前的操作都是基于主键(keyPath)的,现在,基于是传入的indexName的。

你也可以总结出游标的使用,简单的说,就是对已知的集合对象(比如objectStore或indexView)进行遍历,在onsuccess中使用continue来进行控制。

IndexedDB API

本节主要把indexedDB中最常用的所有api进行列举,起到快速查阅的作用。注意,这里仅是最常用,基本可以覆盖90%的使用场景,但并不代表所有的api都在这里,你可以通过这里查阅全部。

IDBDatabase

也就是本文最开始使用open打开indexedBD得到的db对象db,通过这节,了解这个idb有些什么接口可以被调用。

let request = indexedDB.open(name, version)
request.onsuccess = (e) => {
  let db = e.target.result // 获得db
}

name

通过idb.name获取当前连接到的数据库的名字。这和你在open的时候传入的name是一致的。

version

和你在open的时候传入的version是一致的。

objectStoreNames

获取当前数据库的所有objectStore的name列表,是一个数组。

createObjectStore()

创建一个objectStore,有两个参数:

例子:

db.createObjectStore('students', { keyPaht: 'id', autoIncrement: false })

deleteObjectStore()

删除一个objectStore,参数只有一个:

删除的时候,它的所有index也被删除了。

close()

关闭当前打开的这个数据库。关闭之后,任何操作都会报错。

transaction()

重头戏,开启一个事务,是后续操作的开始。前文已经讲过了,任何操作都是要在事务中进行的,对于一个database而言,如果要获取里面的数据,或者修改其中的某一个objectStore,都要通过这个方法来开启一个事务,在事务中进行操作。

它有两个参数:

我们用代码来看下用法:

let tx = idb.transaction(['students'], 'readonly')

这样就获得了一个database的事务容器。

database的api主要用到的就这些了,接下来,我们来看下,开启事务之后,我们可以干什么。

IDBTransaction

关于transaction的概念前面已经说了,这里主要看下,得到一个transaction之后可以干什么。

db

获取这个transaction是对哪个database进行操作的事务。

objectStoreNames

这个事务要对哪些objectStore进行操作,和你传入的objectStoreNames是一致的,是一个数组。

mode

读写权限,和你传入的mode一致。

abort()

终止该事务,一旦人为终止,你程序中的某些操作就不会再执行了。

objectStore()

获取事务中的某个objectStore的容器。它有一个参数:

获得该objectStore容器之后,就可以利用它进行objectStore的数据增删改查了。

let tx = idb.transaction(['students'], 'readonly')
let objStore = tx.objectStore('students')

// 利用objStore进行查询:
let request = objStore.get('100001')

注意,它的参数必须是在transaction的第一个参数数组中的。

IDBObjectStore

重头戏,objectStore是最核心的概念了,它的容器,也就是上面这段代码中的objStore,都可以进行哪些后续操作呢?

name

获取该objectStore的name。和创建的时候传入的name一致。

keyPath

和创建的时候传入的keyPath一致。

autoIncrement

和创建的时候传入的autoIncrement一致。

indexNames

获取该objectStore的所有索引的命名列表,和createIndex传入的第一个参数一致。

transaction

获取该objectStore所属的transaction的容器。一个objectStore的容器,只有在事务中才能得到,因此这个objectStore的容器一定属于某个事务,那么也就有对应的transaction容器。通过transaction容器,其实可以做很多操作,比如获取和该objectStore一起操作的其他objectStore的name。

get()

发起一个获取object的Request。Request我们还没有讲到,后面会讲。它有一个参数:

代码说话:

let objStore = tx.objectStore('students')
let request = objStore.get(100001)

而要得到最后查询到的object,需要在request的onsuccess中获取。所以后面讲完Request之后,你就能正确使用代码了。

add()

发起一个添加object的Request。它有一个参数:

还是用代码来看下:

let objStore = tx.objectStore('students')
let request = objStore.add({
  id: '100002',
  name: 'Li Hua',
})

delete()

发起一个删除object的Request。它有一个参数:

put()

发起一个更新某个object的Request。它有两个参数:

关于key非常难理解。当你在创建一个objectStore的时候,你可能会传入autoIncrement为true,这时,这个objectStore和我们经常使用的有点不同。比如你的主键是id,那么如果你在add或put的时候,不传这个id,id值会自动加1,你get到的object也会包含id属性。

当你在使用put方法去更新的时候,如果你的objectStore的autoIncrement是true,就必须传入第二个参数key,put方法会先通过key找到该object,然后在用object的内容去更新。而如果不传key,那么你传入的第一个参数object中必须包含id,否则会报错。
但是如果你的autoIncrement设置的是false,那就可以考虑忽略key。但是效果还是不一样,当你传入key值的时候,会更新传入的key对应的那个object。不传的时候,根据你object里面的主键来更新,没有的话会被认为是add操作,不会报错。

所以比较好的一种操作是,不设置autoIncrement,无论是添加还是修改object,都使用put,只要开发者自己注意,传入的object一定要有一个主键即可。这样当存在该主键值时,就更新,不存在时就插入。这比使用add好很多,因为add的时候,如果存在会报错。

如果你还有不理解的地方,欢迎在下方留言,对这个问题进行探讨。

count()

没有参数,发起一个查询当前objectStore的所有object的数量的Request。

clear()

没有参数,发起一个删除objectStore里面的所有object的Request。

清除数据之前请备份好数据。

openCursor()

发起一个打开游标的Request。它的参数统一在IDBCursor中讲。

index()

通过index方法,可以建立一个类似SQL数据库中视图。它先得到一个IDBIndex,然后你在对这个IDBIndex进行操作。因此它不是发起一个Request,这有别于前面的方法。返回的IDBIndex会在下文详细描述它的API。index方法由一个参数:

看下代码怎么用:

let objStore = tx.objectStore('students')
let objIndex = objStore.index('name')
let request = objIndex.get('Li Hua')

createIndex()

创建一个索引,和前面的方法不同,它不是发起一个Request,而是直接进行操作,并且返回一个IDBIndex。关于IDBIndex,我会在Request后面再讲。它有三个参数:

虽然objectParameters还有其他选项,但是常用的就是unique。

注意,只能在onupgradeneeded中使用。

deleteIndex()

删除一个索引,也不用发起Request。它有一个参数:

删除索引之后,就不能再根据索引查询数据了。

另外需要注意,创建和删除索引,都需要在打开db的Request的onupgradeneeded事件中完成。

IDBRequest

先来说下Request的概念。前面一直强调事务的概念,没有对Request进行介绍。Request是在事务过程中,发起某项操作的请求。一个事务过程中,可以有多个Request,Request一定存在与事务中,因此它肯定会有一个transaction属性来获取它所属于的那个事务的容器。

为什么要有Request呢?你可以把transaction当做一个队列,在这个队列中,Request在进行排队,每一个Request都只包含一个操作,比如添加,修改,删除之类的。这些操作不能马上进行,比如修改操作,如果你马上进行,就会导致大家同时修改怎么办的问题,把多个修改操作放在Request中,这些Request在transaction中排队,一个一个处理,这样就会有执行的顺序,修改就有前后之分。同时,transaction都可以被abort,这样当一系列的操作被放弃之后,后续的操作也不会进行。

而且非常重要的思想是,Request是异步的,它有状态,一个Request处于什么状态,可以通过readyStates属性查到,这对开发者而言也更可控。

目前,在indexedDB中,有四种可能产生Request:open database,objectStore request, cursor request, index request。

readyState

Request的状态。只有两种状态:pending, done。

transaction

获取该Request所属于的transaction。

source

获取该Request是由谁发起的,它可能有四种情况:objStore, cursor, index, null. 当该Request是open database的时候发起,source值为null。

通过该source值,其实可以获取更多信息,比如objectStore的其他信息。

result

获取该Request的输出结果。该值最开始是undefined,只有当Request成功之后,该值才会出现。因此,要获取一个get的最终结果,必须在Request的onsuccess事件中调用:

let request = objectStore.get(10001)
request.onsuccess = e => {
  let item = request.result
  // 等价于
  let item = e.tareget.result
}

IDBIndex

索引的概念就不讲了,这里主要是把最常用的有关所有的api列举出来,方便使用。

什么情况下会产生IDBIndex呢?当调用一个objectStore的index方法时,前文在讲IDBObjectStore的时候已经讲到过了。

let objStore = tx.objectStore('students')
let objIndex = objStore.index('name')

通过index方法之后,这个objIndex可以进行哪些操作呢?

name

该属性可以获取index的name。

keyPath

该属性可以获取index的keyPath,也就是object的某个属性的名字。这个你在createIndex的时候传入的keyPath是一致的。

unique

返回是否是唯一的,和你createIndex的时候传入的值是一致的。

isAutoLocale

返回一个boolean值,它表示这个index是否是自增的,和前面的autoIncrement有关。

locale

如果这个index对应的字段是自增的,那么现在的自增基础值是多少?比如你的id是自增的,现在objectStore里面有10个object,这时objIndex.locale应该是10.

objectStore

因为IDBIndex总是通过objectStore的index方法产生的,所以它自然会有对应的那个objectStore,而自己的objectStore属性正好是一个引用,指向那个产生自己的objectStore。

count()

发起一个当前index视图总共有多少个object的Request。

get()

发起一个从当前index获取一个值为传入参数的object的Request。它有一个参数:

这里的key有点难理解,前面objectStore的get方法传入的是主键的值,而这类的get传入的是你选择的这个索引对应的属性的值。比如说:

{
  id: 1,
  name: 'tom',
}

id是主键,name是一个索引。那么这个时候,如果你想得到这个object,应该使用:

let request = objIndex.get('tom') // 这个tom是name属性的值

openCursor()

发起一个打开游标的Request。它的参数统一在IDBCursor中讲。

可以看出,objIndex对于objStore而言,方法少了很多,它不能更新、删除等。但是实际上,通过objectStore属性,就可以反过来对objectStore进行操作。

IDBCursor

游标通过前文的阐述,应该比较容易理解了。这里我们来看下,一个cursor都可以进行哪些操作。

但是在开始之前,你要回头看下游标是怎么得到的?

openCursor()

objectStore或objectIndex可以使用这个方法打开一个游标的Request。它有两个参数:

打开游标后,游标怎么获得呢?在Request的onsuccess中得到的:

objStore.openCursor().onsuccess = e => {
  let cursor = e.target.result // 这个才是我们要的游标
}

接下来就是看看,这个cursor有哪些接口。

direction

游标遍历的方向,方向前面说过了,和你openCursor的时候传入的是一致的。

key

把当前游标所在的那个object的值返回给你,这个值是你使用index方法时,选择的那个索引对应的属性的值。有点绕,举个例子,如果游标当前停留在:

{
  id: 2,
  name: 'sue',
}

而你开启这个游标,又是通过objStore.index('name').openCursor()开启的,那么这个时候这个key值,就是sue了。

primaryKey

游标遍历所在位置的object的主键。这个比较难理解,key和primaryKey的区别主要体现在你用索引进行检索的时候,如果你openCursor是在非索引集合情况下,其实key和primaryKey是一样的,因为都是以主键作为索引。但是当你用index方法筛选出子集,那么这个时候primaryKey在某些情况下就非常有必要知道。

source

该属性返回开启游标的容器的引用,也就是objStore或objIndex。

value

改属性返回当前遍历到的object,严格的说,这个属性是IDBCursorWithValue的属性。

continue()

游标往下移动一格或移动到你规定的位置。注意,它是根据你规定的方向进行移动的。它可以有一个参数:

advance()

continue()是只移动一格,advance()可以自己规定移动的格数。它有一个参数:

continuePrimaryKey()

前面讲了primaryKey这个属性,也讲了continue(key)这个方法的参数key。但是,当你在openCursor的时候,索引的值可重复时,那就会出现尴尬的情况。在objectStore里面存储数据的时候,primaryKey和其他索引的key,它们的值可能是重复的,但是如果通过两个key的值来确定呢?那么就能更准确的定位某一个object。因此,当你使用continuPrimaryKey的时候,是为了解决这个问题,即continue()只会根据参数,往下移动到下一个给定参数值的object,而如果使用continuePrimaryKey(),那么在往下一个给定值移动时,还会再考虑primaryKey的值。

一般来说primaryKey的值都是唯一的,但也不排除有些情况不唯一的时候,这个时候,使用游标结合continuePrimaryKey才能正确获得你想要的那个object,通过普通的get只能得到第一个object。

delete()

发起一个删除当前游标所在的object的Request。删除之后,就要考虑用continuePrimaryKey(),而不是continue()。

update()

发起一个更新当前游标所在的这个object的对应字段的值的Request。它有一个参数:

用Promise对事务进行封装

既然indexedDB的事务机制这么复杂,又有transaction,又有Request,都需要在它们的onsuccess中的回调函数中去获取result之类的。既然是回调,那么我们就可以使用Promise来进行封装,让回调嵌套不再那么痛苦。

基于这个想法,我封装了一个包,取名hello-indexeddb,你可以在GitHub上获得源码。接下来我们我们来看下它怎么使用。

constructor

实例化类,要么是创建一个不存在的数据库,要么打开一个已经存在的数据库,打开的时候,还可以对已有的数据库结构进行修改。

创建一个数据库

通过下面的方式创建一个数据库。

let idb = new HelloIndexedDB({
  name: 'mydb',
  version: 1,
  stores: [
    {
      name: 'store1',
      primaryKey: 'id',
      indexes: [
        {
          name: 'id',
          key: 'id',
          unique: true,
        },
        {
          name: 'user',
        },
      ],
    },
    {
      name: 'store2',
      primaryKey: 'id',
    }
  ],
  use: 'store1',
})

通过上面的代码,可以创建一个名为mydb的数据库,version为1,包含了两个objectStore,其中store1还有两个索引。当数据库打开之后,默认操作store1.

打开一个已有的数据库

上面我们已经创建了mydb这个数据库。当你刷新当前页面的时候,上面这段代码会打开mydb这个数据库,无需做任何改动。

这里你要回到真实的使用场景,我们是用Javascript把代码写在一个网页中的,indexedDB是用户浏览器中的数据库,用户可能会反复打开这个网页,因此,上面这段代码并没有变化,它的作用是如果存在mydb,就打开它。

修改已有数据库结构

有一天,你作为开发者,想要调整你的数据库结构,那么怎么办?你需要发布新的代码,新代码中,需要把version进行调整,升级为新的version,代码调整如下:

let idb = new IndexDB({
  name: 'mydb',
  version: 2,
  stores: [
    {
      name: 'store1',
      primaryKey: 'id',
      indexes: [
        {
          name: 'id',
          key: 'id',
          unique: true,
        },
        {
          name: 'user',
          unique: true,
        },
        {
          name: 'email',
          unique: true,
        },
      ],
    },
    {
      name: 'store2',
      primaryKey: 'id',
    }
  ],
  use: 'store1',
})

当用户再次访问你的应用的时候,会加载新的代码,新的代码里面version值变大了,这个时候会触发修改数据库结构的动作,修改的部分用红色标注出来了。

options

在实例化的时候,传入的options如下:

{
  name: 'mydb', // 必须,数据库名
  version: 1, // 必须,数据库版本
  stores: [ // 可选,创建的时候必须,包含数据库中所有的objectStore信息,数组中的一个元素就是一个objectStore
    {
      name: 'store1', // 必须,objectStore的name
      primaryKey: 'id', // 必须,objectStore的keyPath
      indexes: [ // 可选的,objectStore的索引信息
        {
          name: 'id', // 必须,索引的名称
          key: 'id', // 可选,如果不传,则直接使用index的name的值
          unique: true, // 可选,默认false
        },
        {
          ...
        },
      ],
    },
    ...
  ],
  use: 'store1', // 必须,数据库打开之后,默认连接到哪个objectStore
}

get(key)

通过get方法获取当前选中的objectStore中的object。这个key是primaryKey的值。

用代码说话:

idb.get('100001').then(obj => {
   console.log(obj)
})

前面我们实例化出来的db直接调用get方法,返回一个Promise实例。key就是我们前面定义的primaryKey的值,比如在store1中有一个object:

{
  id: '100001',
  user: 'Li Hua',
  email: 'lihua@xxx.com',
}

那么,这个object就会被返回到then中。

query(key, value)

查询出某个索引键为某值的object的集合,返回这个集合为一个数组。两个参数:

上代码:

idb.query('email', 'lihua@xxx.com').then((objs) => {
  console.log(objs)
})

那么上面那个object又被检索出来了。因为主键一般都会加入到索引中,所以也可以通过这个方法来实现主键的get方法。

all()

没有参数,检索出objectStore中的所有object。也是Promise用法。then中返回的是object的数组集合。

count()

查询当前objectStore里面的object个数。

add(item)

添加一个对象到objectStore中。

put(item)

更新一个对象,如果该对象不存在,则添加这个对象。

根据primaryKey,如果objectStore中已经有一个object的primaryKey为传入的item的primaryKey的值,那么表示更新,否则就是添加。

delete(key)

删除一个对象。

close()

关闭当前打开的数据库。注意,它不返回任何值。而且,它是异步的。

use(objectStoreName)

切换objectStore,并返回一个新的HelloIndexedDB实例。当你打算向另外一个objectStore写入数据时,要使用use方法进行切换。

let idb2 = idb.use('store2')
idb2.get('10000').then(...)

注意,你一定要确保store2是存在的。

扩展

因为IndexDB是一个ES6的class,因此可以方便的使用extends关键字对它进行扩展,比如你想添加一个借口:

class MyIndexedDB extends HelloIndexedDB {
  clear() {
    this.store(this.currentObjectStore, 'readwrite').then(objectStore => objectStore.clear())
  }
}

这样就添加了一个接口,用来清除当前活动的objectStore的所有数据。实例化的时候,实例化MyIndexedDB即可。

要根据索引来进行删除,可能就有点麻烦。你可以先通过query方法查出一个集合,然后再通过遍历数组找到要删除的那一条,然后再用delete来进行删除。

小结

本文所有的内容就讲完了,本文从IndexedDB的概念和结构开始,把所有常用的API都讲了一遍,并且还给出了很多例子。
有人做过一个预测,Web SQL数据库已经从标准中移除了,有可能在后面的浏览器版本中被去除,IndexedDB将有可能统一浏览器本地化存储的数据库。

虽然本文对大部分IndexedDB的接口都进行了讲解,但是还只是入门教程,还有很多东西没讲。举个例子,作为数据库,最重要的就是检索功能,也就是索引检索的那个部分,但是本文并没有对这个部分展开。索引检索还可以实现阈值检索,比如检索某一段日期内的所有object,比如检索所有同姓氏的同学,这些问题都是经常遇到的,但都不再本文的讲解范围内。想要完全了解indexedDB,还需要你继续深入学习。

参考文献

  1. HTML5 indexedDB前端本地存储数据库实例教程
  2. Dexie.js 一个封装来对indexeddb操作的库

2017-09-09

已有4条评论
  1. ospider 2018-02-28 01:40

    关于版本那一块,好像文中也没有详细解释了,如果有时间,还请写一下呀

    > 这时你会想,如果我从新的version切换为老的version,还可以在老的version里面添加数据吗?这个问题我们暂时保留。

    • 否子戈 2018-02-28 14:11

      在文章中添加来补充内容,关于降级我没有实际测试过,如果你有兴趣对话,可以试一下。

  2. ospider 2018-02-26 18:59

    代码都被压成一行了。。大佬有时间改下么

    • 否子戈 2018-02-28 13:09

      谢谢提醒