IndexedDB 中文教程
前言
indexedDB 是 html5 标准引入的web数据持久化方案之一,现代浏览器大多按照标准对其进行了实现,我在新的项目中用到它来作为持久化数据存储,于是详细研究了一番,MDN 上的解释虽然权威,但是由于知识不成体系,细节也讲的不透,所以我打算自己写一篇教程,对 indexedDB 的各个细节进行详解。
为了和读者更贴近,我做了上面这套视频教程,总共7个课时,每天一小时,7天可学完。虽然本文的内容已经覆盖了所有与 indexedDB 相关的内容,但是视频教程的方式更轻松,而且其中有关概念的部分讲解的更详细。掌握一门技术,不需要很长时间,7天就可以。视频教程是付费教程,价格不贵,写教程真的不容易,希望大家多支持。
架构体系
这里说的结构体系,是指在 indexedDB 中,几个重要的概念之间的关系。这些概念包括:
- 数据库
- objectStore
- key-value
简单的说,indexedDB 是非关系型数据库,数据组织不像 SQL 数据库一样,有表和字段,indexedDB 里面没有表和字段的概念,它的最小组织单位(unit)是 key-value。在 indexedDB 里,一对 key-value 就相当于 SQL 数据库里面的一条记录,是数据的最终体现形式。我们通过下面的图来阐述 indexedDB 和 SQL 数据库结构体系的不同,以帮助理解。
关系型数据库和非关系型数据库本身有非常大的差别,关系型数据库大致上架构是一致的,但是非关系型数据库的架构各式各样,很不统一。就 SQL DB 和 indexedDB 而言:
- 在最下面那一层,两者都有 database 的概念,要存储数据,首先要创建一个数据库。
- 往上一层,两者就有了区别,SQL DB 有表的概念,而 indexedDB 对应的是 objectStore。
- 再往上一层,差别更大,SQL DB 的记录有字段的概念,而 indexedDB 的记录直接是键值对。
indexedDB 中的 objectStore 虽然和 SQL DB 中的 table 非常像,但是它们的存储形式却极其不同。objectStore 比 table 简单的多。
同时,indexedDB 内置了事务系统,所有读写操作都是在事务中完成。
数据库
indexedDB 是一个完备模型的数据库,有标准的数据库结构体系。它虽然不是一个运行时服务,而是基于文件的即时存取数据库,但是仍然可以用其他数据库的模型思维来了解它。在 indexedDB 中,我们可以创建多个数据库,每个数据库有自己独立的空间。
连接数据库
要在 indexedDB 中存储数据,要进行两步操作,第一步连接到某个数据库,第二步挑选某个 objectStore 进行数据操作。现在讲解连接到 indexedDB 数据库。在 window 对象上有一个 indexedDB 对象,直接调用 open 方法进行打开,如果该数据库不存在,则创建一个新的数据打开:
const request = window.indexedDB.open('mydb', 1)
open有两个参数:
- name:数据库名
- version:版本
数据库版本
上面的version参数对于初学者而言不是很好理解。如果你用过 docker 的话,知道有一个东西叫镜像(image),在镜像的基础上构建自己的容器,可以再创建一个新的镜像,那么这个新的镜像其实就包含了老的镜像。这里的 version 也类似,它表示当前的数据库结构的版本。version 必须是正整数,只能升不能将,只有当需要进行数据库结构修改的时候,才需要升级 version。
例如,一开始,version 为 1,创建了一些 objectStore,当你需要添加新的 objectStore 或者修改某些 objectStore 的时候,就需要升级 version。一个数据库只能在同一时间存在一个 version,不能同时存在多个 version。当你的 version 升级之后,你还用老的 version 去连接数据库,并进行数据操作时,会抛出错误。
补充:有同学留言想对这个问题进行深入探讨。我这里说一下我的想法。从项目的开发上讲,我们只会在发布代码时去升级 version,而不会在程序运行过程中通过程序去更改 version,因为一旦用户刷新页面,这个 version 会变成代码中设置的 version,这会造成错误。我们升级 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,它的使用场景只有两种:
- 当你需要修改 objectStore 时
- 当你需要添加新的 objectStore 时
从代码的层面来看,并非这两个事情发生才触发了 version 的改变,恰恰相反,如果你要修改或添加 objectStore,你必须通过传递新的 version 参数到 open 方法中,触发 onupgradeneeded,在 onupgradeneeded 的回调函数中才能实现目的。
获得 db 容器
怎么从上面的 open 方法之后,获得可以用来进行操作的数据库对象容器:
const request = window.indexedDB.open('mydb', 1) request.onsuccess = e => { const db = e.target.result }
这样就获得了db的容器,利用该容器,我们就可以进行数据库的下一步操作。
这是一个异步的操作,你不能在上面代码之后直接使用 db 变量,而是要在 onsuccess 的函数体内继续写代码。而且 indexedDB 的所有编程都是这样,这会导致嵌套层层加深,因此,我们这篇文章后面还会提供使用 Promise 进行封装的类,使用该类你无需做复杂的操作。
objectStore
objectStore 是 indexedDB 中非常核心的概念,它是数据的存储仓库,一个 objectStore 类似于 SQL 数据库中的表,存放着相关的所有数据记录。
什么是 objectStore?
indexedDB 是 key-value 数据库,具体体现就是 objectStore. objectStore 是 indexedDB 的数据存储机制,和 SQL 的表的地位一致。每一条记录包含了 key 和 value。
indexedDB 之所以将自己的存储机制称为 objectStore,在 store 前面加上 object,是因为它所存储的记录的集合,就像一个 object 一样,是一个 key-value 的集合。
什么是 key?
key 是 value 的标记值。
我们通过 key 得到 indexedDB 中存储的对应值。要获得 value,必须通过 key 来获得。key 和 value 具有绑定关系,key 相当于 value 的缩写、别名、标记。
但和 localStorage 不同,indexedDB 的 key 有两种形态:inline 和 outline。inline key 是指 key 被包含在 value 中,例如你存储的 value 是对象时,就可以将 key 包含在 value 中。outline key 则是不被包含在 value 中,例如你存储的是字符串,或者 ArrayBuffer,就不可能在 value 中包含 key,这种情况下通过开启 autoIncrement 来实现,下文会详细介绍。
上图中上面一条记录的 value 是一个对象,key 来自对象的 id 属性值,这个 key 是 inline key。下面一条记录,value 是一个字符串,key 是由系统自动生成,这个 key 是 outline key。
例如有一个值是 { body: { color: 'red' } }
而 keyPath 为 body.color
,那么就可以得到 key 为 red
。这可能和我们以前理解的地方有所不同。在 IndexedDB 中,我们要通过 red 去找到这个值。
什么是 keyPath?
keyPath 是指在对象中,获取一个节点值的属性链式方法的字符串表达。例如:
{ books: [ { name: 'My Uncle Tom', price: 13.4 } ], }
其中我要获得第一本书的价格,那么它的 keyPath 就是 'books[0].price'。它是一个字符串,但可以用来表达获得一个节点值的属性路径。所以,keyPath 这个单词中的 path 就是这个意思。
key 和 keyPath 的联系
keyPath 的作用是读取 key。
key 是 value 的 keyPath 的值。
这句话的意思是:1. value 是一个对象,2. 以 keyPath 作为属性路径从 value 中读取某个节点的值;3. 这个值就是 key。当然,这里面有一些前提条件,例如“value 是一个对象”,不是字符串,“value 存在 keyPath,并能读取值”。
一般而言,keyPath 在创建 objectStore 和 index 的时候就已经确定了,不会改。所以,在实际编程的时候,我们很少会去用 keyPath,而是拿到一个 key 进行操作。
keyPath 的高级用法是,支持一个数组作为 keyPath,但这个我们不深入探讨。如果 keyPath 是一个数组,表示在查询的时候,只要其中之一可以查到值,就返回得到的值。
对于 outline key 的情况,在创建 objectStore 时,我们不能传 keyPath,并且开启 autoIncrement,indexedDB 会默认以 'id' 作为 keyPath,但我们不会用到这个 'id'。
可以存储哪些数据类型?
根据 w3c 文档描述,indexedDB 可以存储的数据类型为:
String, Date, Object, Arra, File
, Blob
, ImageData
等等。另外,网上很多人指出,支持存储 ArrayBuffer。
根据 w3c 的描述,凡是能进行序列化的值,都可以被 indexedDB 存储。也就是说,不能被序列化的,例如自引用的 object,或者某些类的实例,还有 function,就不能被 indexedDB 存储。简单理解就是,不能存function等非结构化的数据,object必须是以键值对组成的字面对象。
创建 objectStore
创建 objectStore 和修改 objectStore 都只能在 db 的 onupgradeneeded 事件中进行,因此,要创建 objectStore,必须在前面的 open 操作那个时候来进行。
const request = window.indexedDB.open('mydb', 1) request.onupgradeneeded = (e) => { const db = e.target.result db.createObjectStore('mystore', { keyPath: 'id' }) } request.onsuccess = (e) => { const db = e.target.result // ... }
上面的红色代码中,我们使用 createObjectStore 方法来实现 objectStore 的创建。但是,需要注意的是,一个 database 中,只允许存在一个同名的 objectStore,因此,如果你第二次 createObjectStore 相同名的objectStore,程序会报错。比如你的程序如果像上面这样写,必然会遇到一个问题,就是当你更新 version 的时候,会再次执行 createObjectStore,那么就会报错,程序就会中断。另一个注意点是,一旦一个 objectStore 被创建,它的 name 和 keyPath 是不能修改的。那么有什么办法来控制呢?通过一个判断,判断是否已经存在同名的 objectStore 即可。
const request = window.indexedDB.open('mydb', 1) request.onupgradeneeded = (e) => { const db = e.target.result if (!db.objectStoreNames.contains('mystore')) { db.createObjectStore('mystore', { keyPath: 'id' }) } } request.onsuccess = (e) => { const db = e.target.result // ... }
onupgradeneeded 不会在每次刷新页面的时候执行,而是在刷新页面时,indexedDB 发现 version 被升级了的时候执行。因此,所有的数据库结构的东西,都要放在这个函数内去做。
objectStore 创建之后不可修改,如果创建时失误,给了错误的配置,必须先删除该 objectStore 之后,重新创建。
objectStore 的类型
在编程上,不需要去分 objectStore 的类型。但是,在我们实际存储数据的时候,我们自己需要去区分,因为不同类型的 objectStore 用法不同。
对象型仓库
如果一个 objectStore 是用来存对象的,那么这种情况下你应该每次都存入一个对象,而且,这个对象有一个属性路径是规定的 keyPath。倘若这个对象不存在对应的 keyPath,那么就会报错。
这里的对象不包括数组。
db.createObjectStore('mystore', { keyPath: 'id' })
非对象型仓库
如果一个 objectStore 是用来存非对象的,例如我们经常会考虑用来存 ArrayBuffer,这个时候,你应该在创建 objectStore 的时候,传入配置信息 autoIncrement 为 true,不传 keyPath。
db.createObjectStore('mystore', { autoIncrement: true })
这种情况下,存入数据(ArrayBuffer)时,key 会被自动添加上,且跟 SQL 数据的自增字段一样,索引值会在原有基础上自加 1。在查询或更新值的时候,都要使用这个值作为 key。但是,这种情况下,会有一个问题,key 是在运行时保存在内存中的,一旦用户刷新页面,你就不知道刚刚插入的值的 key 了。对于这种情况,如果想持久化,可以和 localStorage 一起使用来解决。
混合仓库
如果你硬是要在一个 objectStore 中混合存值,那么必须按照非对象型仓库来使用。但是会有一个不一样的地方,当你存入一个对象时,如果该对象中并没有对应的 keyPath,那么,它会在存入时被自动加上这个 keyPath。这一点非常奇怪,你会发现,存入 string, Blob 和存入 object 的效果竟然不同。
db.createObjectStore('mystore', { keyPath: 'id', autoIncrement: true })
当你插入的对象中不存在 id 属性时,对象会被污染,会自动加上 id 属性,并且赋予一个数字作为 key。
什么是 autoIncrement?
前面提到过 autoIncrement,但是没有详细解释。这需要涉及到一个叫 key generator 的东西,但是它过于底层,我们这里不深入讲解,你可以自行了解,我们这里做简化说明。autoIncrement 是一个配置。当创建 objectStore 时,如果 autoIncrement 被设置为 true,那么它的 key 将具备自动生成的能力。
什么是自动生成?
- 当存入的值不存在对应的 keyPath 时,自动创建 keyPath,并用一个自动生成的数字作为 key,这个动作被成为“污染”
- 自动生成的数字从 1 开始自增长,每次加 1
- 当用户手动传入 key 时,如果是一个数字,自增长的值会被覆盖,下次插入时,会从新的值开始计算
- 如果手动传入的 key 不是数字,那么不影响 key 的自增长,下次插入仍然以之前的值作为基底
- 它的最大取值范围为 253,超出时,继续享受自增长会报错,但可以通过自己给明确的字符串 key 继续添加记录
- 在一条记录被插入时,add 或 put 方法会返回该记录的 key
- 对于非对象的存入值,不会产生污染效果
对于对象型仓库,如果新增的值不存在对应的 key,那么被存的值会被“污染”。标准文档中是这么说的:“Then the value provided by the key generator is used to populate the key value.”也就是说,你在取出这个值的时候,这个 key 被添加到了值上,也就是说,存入的值被改了(添加了属性)。
对于我们开发者而言,一般的建议是,如果你的 objectStore 用于存储对象,尽量不要开启 autoIncrement 功能。只有在储存非对象值时,一定要开启 autoIncrement 功能。不建议使用混合型仓库。
index
索引是 indexedDB 中最值得玩味的部分,这也是它取名“indexedDB”的原因。索引,是一系列概念的统称:
- 用于存储索引的空间被称为“索引”,例如我们可以称 objectStore 的一个索引
- 某条记录的索引也被称为“索引”,例如我们可以称 value 的 indexName 索引
简单的说,索引是独立于 objectStore 之外,但又和 objectStore 绑定的,用于建立更多查询线索的工具。
什么是 index?
本质上,一个 index 是一个特殊的 objectStore,它的存储结构和 objectStore 基本一致,也有自己的 name(本文标记为 indexName), keyPath, key 和 value。特殊之处在于,索引的 key 具有一定的逻辑约束,例如 unique 用于规定该 key 是唯一的。
上图是一个 index 存储器的示意图,一个 objectStore 可以有多个 index 和自己绑定。
index 和 objectStore 的关系
index 依附于 objectStore。我们在创建索引时,首先要得到一个 objectStore,再在这个 objectStore 的基础上创建一个索引。一个 objectStore 可以有多个索引。
index 的 key 和 value 全部来自 objectStore。key 为某条记录的 keyPath(index.keyPath)的值,value 为这条记录的 key(objectStore.key)。index 中的一条记录自动和 objectStore 中的对应记录绑定,当 objectStore 中的记录发生变化时,index 中的记录会自动被污染(自动更新)。
index 实际上是对 objectStore 查询条件的补充。如果没有 index,我们只能通过 objectStore 的 key 来查值,但是有了 index,我们可以查询的能力被扩展到了任意属性路径。
和 objectStore 不同,index 的同一个 key 可以存在多个,因为不同的存入值的某个 keyPath 可能存在相同的值。
如何创建索引?
创建索引这个动作,实际上是对 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('price', 'book.price', { unique: false }) }
objectStore 对象有一个 createIndex 方法,它可以创建索引。它有三个参数,第一个参数是这个索引的 indexName,第二个参数是 keyPath,第三个参数是 options,其中 unique 选项被放在这里面。
如何修改索引?
虽然 objectStore 本身的信息是不能修改的,比如 name 和 keyPath 都是不能修改的,但是它所拥有的索引可以被修改,修改其实就是删除+添加操作。用到的就是 deleteIndex 这个方法,所以,如果你想修改一个索引,要做的就是先删除掉原来的同名索引,然后添加新的索引。举个例子,如果你知道数据库中已经存在 mystore 这个 objectStore 了:
request.onupgradeneeded = (e) => { const objectStore = e.target.transaction.objectStore('mystore') const indexNames = objectStore.indexNames if (indexNames.contains('name')) { objectStore.deleteIndex('name') } objectStore.createIndex('name', 'name', { unique: false }) }
上面这段代码通过对已有的 objectStore 的 index 进行操作,如果存在某个 index 时,就删除它再添加,如果没有就直接添加。
索引的运行机制
indexName 是对一个索引的命名,这个名字会被用在按索引检索中,它本身和使用被存储数据的那个属性路径没关系,但你可以把它当做是一个别名。
例如,你在创建索引的时候,是这样创建的:
objectStore.createIndex('indexName', 'index.keyPath', { unique: false })
在根据索引进行查询的时候,语法是这样的:
const request = objectStore.index('indexName').get('indexKey')
这实际上是两个过程,第一个过程是在保存数据时(包括创建和更新),索引区会同步更新自己的 key,它包含如下步骤:
- 遍历该 objectStore 的所有索引
- 读取索引的 keyPath,我们这里标记为 indexKeyPath
- 如果是更新某个值,遍历索引区记录,找到引用该值的索引记录[1]
- 解析得到被存入的值的 indexKeyPath,得到目标值,这个值即索引将要使用的 key,我们标记为 indexKey
- 索引 indexKey 的条件判断,例如,检查是否满足 unique 要求,不满足的情况下,抛出错误提示
- 更新索引记录的 key 为 indexKey
第二个过程是查询过程,.index('indexName')
这个动作就是从名字为 indexName 的索引区去进行索引查询,.get('indexKey')
这个动作包含了好几步:
- 连接到名字为 'indexName' 的索引区
- 在索引区中查询索引 key 为 'indexKey' 的索引记录
- 得到该记录后,读取记录的 value
- 将该 value 作为 objectStore 的 key 到 objectStore 中进行查询
- 找到第一个结果时,直接返回
- 将放回的结果作为索引查询的最终结果
从某种意义上讲,索引查询的性能肯定不如直接通过 objectStore 的 key 查询。
[1] 在索引中,存在一个叫 referenced value 的东西,即一个索引建立的时候,会自动引用 objectStore 中对应的记录。所以,对于开发者,不用担心索引是如何去快速找到它在 objectStore 的对应记录的。
什么是 unique?
默认情况下,index 的 key 是可以重复的。所有的值被存在 objectStore 中,但是所有值除了 objectStore 中规定的 keyPath 必须唯一之外,默认情况下,其他属性可以相同。但是,如果你建立的索引传入了 unique 参数,那么情况就会发生变化。也就是说,该索引的 keyPath 被用于检查存入的值对应的 keyPath 是否已经有值,如果 objectStore 中存在该 keyPath 的值为将要存入的值的该 keyPath 值,那么会抛出错误。
transaction
所有结构完备的数据库中都有“事务”这个概念,它是为了确保当并非执行某些操作时,不致混乱。举个简单的例子,当你在像好友打钱的时候,发起了一个请求,这个请求发起后,就建立了你打钱的事务,后面一大堆数据库写入的操作,但是,假如中间突然机房停电,银行系统发生故障,你又发起了第二个打钱的请求,那么会不会有这样一种可能,你第一次打出去的钱你朋友根本没收到?数据库系统为了避免这种情况,采用事务机制,如果出错,那就回滚,把你打出去但对方没收到的钱回到你账上,重新再执行一次打钱的操作,执行完这个操作之后,再执行你第二次打钱的操作。这样就保证了数据库增删改有序不混乱。
什么是 transaction?
indexedDB 里面的事务,保证了所有操作(特别是写入操作)是按照一定的顺序进行,不会导致同时写入的问题。另外,indexedDB 里面,强制规定了,任何读写操作,都必须在一个事务中进行。从前面的代码里面你也看到了,对 objectStore 的修改,其实也是在一个事务中进行。
特别是写操作,事务通过尝试性写入完成第一步更新,如果整个尝试过程没有出错,那么会通过一个 commit 操作使得整个更新生效。但是如果在尝试过程中出错了,就会进行一个 abort 操作,之前做过的尝试会直接被丢弃,数据不会发生改变。一个 objectStore 的写入过程中,另外一个写入操作会被挂起,直到上一个事务完成。
如何使用 transaction?
在代码层面,我们必须通过 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 方法有两个参数:
- objectStores:事务打算对哪些 objectStore 进行操作,因此是一个数组
- mode:对进行操作的 objectStore 的模式,即读写权限控制, readonly | readwrite
而通过 transaction 的 objectStore 方法可以获取想要操作的 objectStore,但是它的参数必须存在于上面的 objectStores 数组中,毕竟你的这个事务已经规定了要对哪些 objectStore 进行操作。
因为 objectStore 是在事务中获取,因此一个 objectStore 实例,如果有一个 transaction 属性的话,那么可以通过这个属性找出它的事务的实例。在 indexedDB 中,你只能在事务中得到一个 objectStore 实例,如果通过 db 的话,最多只能得到 objectStore 的名字列表,要获得 objectStore 的实例,必须在 transaction 中。
补充:由于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 事件的时候去发起一个事务,再在这个事务中搞事情。
什么是 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。
cursor
虽然 indexedDB 使用 objectStore 存储数据,是一个 key-value 数据库。但是,我们有遍历所有记录的需求。cursor 游标,就是 indexedDB 提供的遍历整个 objectStore 的能力接口。
什么是 cursor?
概念上讲,游标是“一个用来记录数组正在被操作的某个下标位置的迭代器”。举个例子,你有一个数组 [1,2,3,4],现在你要对它进行遍历,使用 forEach 方法,那么 forEach 方法怎么知道你上次操作到第几个元素,现在应该操作第几个元素呢?它通过内部的一个迭代器实现。游标本质上是一个迭代器,也就意味着有类似于 next 的方法,可以用来移动游标到下一个位置,有 value 属性用来读取当前值。当然,游标不能理解为纯粹的迭代器,因为它的内部过程需要进行 I/O 操作。
获取全部 object
想要获取一个 objectStore 中的全部 object 可不是一件简单的事。indexedDB 1.0 版本的时候没有直接提供类似的方法来获取(2.0 已经提供了 getAll 方法)。那么我们应该怎么办呢?利用游标。
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 没有遍历完所记录,那么通过执行 cursor.continue() 来让游标滑动到下一个记录,onsucess 会被再次触发。而如果所有记录都遍历完了,cursor 变量会是 undefined。
注意上面蓝色的 results,它的声明必须放在 onsuccess 回调函数的外部,因为该回调函数会在遍历过程中反复执行。
通过 index 查询集合
前文举例过如何通过 index 查询值。但是,在前面的演示代码中,仍然只能得到一个值,加入我们希望通过 index 得到某个 index key 的所有值呢?通过游标就可以实现。
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。
你也可以总结出游标的使用,简单的说,就是对已知的集合对象(比如 objectStore 或 index)进行遍历,在 onsuccess 中使用 continue 来进行控制。
数据的存取
当一个事务开始之后,在它的生命周期以内,你可以对 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从数据库中删除。
IndexedDB API
本节主要把indexedDB中最常用的所有api进行列举,起到快速查阅的作用。注意,这里仅是最常用,基本可以覆盖90%的使用场景,但并不代表所有的api都在这里,你可以通过这里查阅全部。
I D B Database
也就是本文最开始使用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,有两个参数:
- name:要创建的objectStore的name
- options:选项
- keyPath:主键,你将要存入的object的一个property name,比如每一个object都有一个id属性,那么可以使用id作为keyPath,在查询的时候,get方法的参数,是id值
- autoIncrement:keyPath是否自增,如果为true,那么你在添加一个object的时候,可以不用传id,id会自动加1。但是这样的话,你就不知道你的这个object的id值到底是多少,所以不建议使用。默认为false
例子:
db.createObjectStore('students', { keyPaht: 'id', autoIncrement: false })
deleteObjectStore()
删除一个objectStore,参数只有一个:
- name:要删除的objectStore的name
删除的时候,它的所有index也被删除了。
close()
关闭当前打开的这个数据库。关闭之后,任何操作都会报错。
transaction()
重头戏,开启一个事务,是后续操作的开始。前文已经讲过了,任何操作都是要在事务中进行的,对于一个database而言,如果要获取里面的数据,或者修改其中的某一个objectStore,都要通过这个方法来开启一个事务,在事务中进行操作。
它有两个参数:
- objectStoreNames:一个数组,表示你开启的这个事务,要准备对哪些objectStore进行操作,在这个事务中,只有这些objectStore能被选中,如果你选中其他的objectStore,会报错。
- mode:读写权限,主要可用的值:readonly, readwrite
我们用代码来看下用法:
let tx = idb.transaction(['students'], 'readonly')
这样就获得了一个database的事务容器。
database的api主要用到的就这些了,接下来,我们来看下,开启事务之后,我们可以干什么。
I D B Transaction
关于transaction的概念前面已经说了,这里主要看下,得到一个transaction之后可以干什么。
db
获取这个transaction是对哪个database进行操作的事务。
objectStoreNames
这个事务要对哪些objectStore进行操作,和你传入的objectStoreNames是一致的,是一个数组。
mode
读写权限,和你传入的mode一致。
abort()
终止该事务,一旦人为终止,你程序中的某些操作就不会再执行了。
objectStore()
获取事务中的某个objectStore的容器。它有一个参数:
- name:objectStore的name
获得该objectStore容器之后,就可以利用它进行objectStore的数据增删改查了。
let tx = idb.transaction(['students'], 'readonly') let objStore = tx.objectStore('students') // 利用objStore进行查询: let request = objStore.get('100001')
注意,它的参数必须是在transaction的第一个参数数组中的。
I D B Object Store
重头戏,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我们还没有讲到,后面会讲。它有一个参数:
- key:主键的值,比如你要获取一个id=100001的object,那么key应该传入100001
代码说话:
let objStore = tx.objectStore('students') let request = objStore.get(100001)
而要得到最后查询到的object,需要在request的onsuccess中获取。所以后面讲完Request之后,你就能正确使用代码了。
add()
发起一个添加object的Request。它有一个参数:
- object:要添加的object,object应该包含主键,这取决于autoIncrement是否为true
还是用代码来看下:
let objStore = tx.objectStore('students') let request = objStore.add({ id: '100002', name: 'Li Hua', })
delete()
发起一个删除object的Request。它有一个参数:
- key: 主键值,和get一样。
put()
发起一个更新某个object的Request。它有两个参数:
- object:要更新的object
- key:要更新的object的主键值
关于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方法由一个参数:
- indexName:要用来作为索引视图的索引名称
看下代码怎么用:
let objStore = tx.objectStore('students') let objIndex = objStore.index('name') let request = objIndex.get('Li Hua')
createIndex()
创建一个索引,和前面的方法不同,它不是发起一个Request,而是直接进行操作,并且返回一个IDBIndex。关于IDBIndex,我会在Request后面再讲。它有三个参数:
- indexName:索引的名字,自己规定的,后面作为根据索引进行查询的依据
- keyPath:该索引对应object中的那个属性名,最好indexName和keyPath相等,这样便于记忆
- objectParameters:
- unique:这个索引是否是唯一,也就是说所有object中,该属性值不能重复
虽然objectParameters还有其他选项,但是常用的就是unique。
注意,只能在onupgradeneeded中使用。
deleteIndex()
删除一个索引,也不用发起Request。它有一个参数:
- name:要删除的索引的name,和createIndex的时候传入的indexName一致。
删除索引之后,就不能再根据索引查询数据了。
另外需要注意,创建和删除索引,都需要在打开db的Request的onupgradeneeded事件中完成。
IDBRequest
前面一直强调事务的概念,没有对Request进行介绍。Request是在事务过程中,发起某项操作的请求。一个事务过程中,可以有多个Request,Request一定存在与事务中,因此它肯定会有一个transaction属性来获取它所属于的那个事务的容器。
目前,在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: 这个key其实是指你的index的keyPath的值
这里的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。它有两个参数:
- range:游标的值域,也就是说,游标在遍历过程中,仅对值域范围内的那些object进行遍历
- direction:遍历的方向,有如下选择:next,nextunique,prev,prevunique。看名字应该就可以理解了。
打开游标后,游标怎么获得呢?在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()
游标往下移动一格或移动到你规定的位置。注意,它是根据你规定的方向进行移动的。它可以有一个参数:
- key:你想要移动游标到指定位置的值。比如说你想让游标移动到id=4的那个object,那么这里传入4.
advance()
continue()是只移动一格,advance()可以自己规定移动的格数。它有一个参数:
- count:你规定的往前移动的格数。
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。它有一个参数:
- value:要用这个新的值替换掉老的值。注意,不是替换掉整个object,而只是更新你选中的那个key的值。
小结
本文所有的内容就讲完了,本文从 IndexedDB 的概念和结构开始,把所有常用的 API 都讲了一遍,并且还给出了很多例子。
有人做过一个预测,Web SQL 数据库已经从标准中移除了,有可能在后面的浏览器版本中被去除,IndexedDB 将有可能统一浏览器本地化存储的数据库。
虽然本文对大部分 IndexedDB 的接口都进行了讲解,但是还只是入门教程,还有很多东西没讲。举个例子,作为数据库,最重要的就是检索功能,也就是索引检索的那个部分,但是本文并没有对这个部分展开。索引检索还可以实现阈值检索,比如检索某一段日期内的所有 object,比如检索所有同姓氏的同学,这些问题都是经常遇到的,但都不再本文的讲解范围内。想要完全了解 indexedDB,还需要你继续深入学习。
参考文献
- HTML5 indexedDB前端本地存储数据库实例教程
- indb 可能是最便捷的 indexedDB 操作库
- Dexie.js 将 indexeddb 封装成类 sql 操作的库
如果你觉得本书对你有帮助,通过下方的二维码向我打赏吧,帮助我写出更多有用的内容。
2017-09-09 | indexedDB
文章中的部分图片失效了
CDN域名证书过期了,已经修复
最近在做ChatGPT相关的一个项目,想在浏览器本地保存聊天记录,才知道了有内置 indexedDB 这个数据库,在尝试之后发现好难用!
在网上搜索到了 Dexie.js 封装了相关的 API,找中文教程时顺藤摸瓜找到了博主的文章,非常感谢您,写得很详细,很多我不理解东西都被讲明白了,今晚尝试一下!
我想请教一个问题,indexdb上存储的数据怎么可以转移到另外一个pc端(假设数据量比较大,除了通过将数据全部查询出来,然后再一条一条导入新的的pc端,还有什么好的办法吗?)
indexeddb属于本地持久化存储,它设计的目标就是本地存储,不是共享,如果你是要共享数据,应该是搭数据服务,如果只是为了做数据迁移,目前没有更好的办法,自己写个程序把库导出为一个json,再发送给另外一个用户写个导入程序
感谢教程。
感謝大大分享
非常實用且完整
解釋淺顯易懂
讓在網路上自學的我收穫匪淺
关于版本那一块,好像文中也没有详细解释了,如果有时间,还请写一下呀
> 这时你会想,如果我从新的version切换为老的version,还可以在老的version里面添加数据吗?这个问题我们暂时保留。
在文章中添加来补充内容,关于降级我没有实际测试过,如果你有兴趣对话,可以试一下。
代码都被压成一行了。。大佬有时间改下么
谢谢提醒