indexDB出坑指南
对于入了前端坑的同学,indexDB绝对是需要深入学习的。
本文虽然不是一个入门教程,但会简单的介绍如何使用indexDB,相关教程网上有很多,可以参考教程。如果你有一些使用indexDB的经验那就更好了,本文一定能让你有更深的收获!
indexDB的特点
优点:
- indexDB 大小取决于你的硬盘,可以说是不受限的
- 可以直接存储任何 js 数据,包括blob(其实是支持结构化克隆的数据),不像 storage 只能存放字符串!
- 可以创建索引,提供高性能的搜索功能!
- 采用事务,保证数据的准确性和一致性。(绝对的黑科技,某些棘手的场景只能用它了!
唯一的缺点就是太复杂了,比storage和cookie都要复杂的多!
使用indexDB
使用分为3步:
1、打开数据库DB
2、在versionChange事件中 创建表(ObjectStore),包括定义表的键,索引规则等。
3、操作数据(增删改查)
操作数据又分为4步:
1、开启事务
2、获取事务中的objectStore
3、通过objectStore发起操作请求
4、定义请求的回调函数
打开数据库很简单:
const opendbRequest = indexedDB.open("MyDatabase", version); // 注意:并不是直接打开数据库,而是发起了一个打开数据库的请求! let db; opendbRequest.onsuccess = function(event) { // 请求的 success 回调里面就可以获取打开的数据库了: db = event.target.result; // 或 opendbRequest.result };
当 indexDB.open 第二个参数version 的值 比 已经存在的DB的版本号大时,或者 当前不存在对应的DB 这是第一次打开数据库时,就会触发changeVersion事件,通过<span>onupgradeneeded</span>
设置回调!
opendbRequest.onupgradeneeded = e=>{ const db = e.target.result // 只有在这个回调里面,才能定义(增删改)对象仓库及对象仓库的规则! // 术语:对象仓库(objectStore) 相当于 MySQL中的表(table),mogodb中的repository(仓库) // 创建objectStore // 创建时 一定要注意定义好key的规范,key就相当于 MySQL里的主键,关于key的规范请参考教程 const objectStore = DB.createObjectStore(‘myObjectStore‘, { keyPath: ‘id‘ }); // 创建索引: // 有联合索引,唯一索引,对数组字段建索引 这些强大的功能,教程里都有讲解! objectStore.createIndex(‘index_name‘, [‘field1‘, ‘field2‘, ‘field3‘], { unique: true }) }
现在我们了解了 如果打开一个DB,以及如果在DB中定义 objectStore 及其规则(schedule)!接下来就是往数据库的objectStore中增删改查数据了!
操作数据有4步:
// 创建事务: // 第一个参数指明事务所涉及的objectStores,如果只有一个objectStore,[]可以省略,本例可以直接写 ‘myObjectStore‘ // 第二参数指明事务操作数据的方式,如不写 默认是 readonly,表示只能读数据 不能写。如果不仅仅是读,还有增删改数据,必须用 readwrite。 // 请注意 readwrite 事务性能较低,并且只能有一个处于活动状态。所以除非必要,不要随意使用readwrite! let transaction = db.transaction([‘myObjectStore‘],‘readwrite‘) // 获取事务中的objectStore (注意:objectStore只有事务才能获取,而不能通过db直接获取;只有objectStore才能发起操作数据的请求!) let objectStore = transaction.objectStore(‘myObjectStore‘) // 在事务objectStore上发起操作数据的请求: // add 新增, put (不存在相同key值的,是新增;存在,是修改), // delete 删除,get 查询 这两个参数只能传入特定对象的 key!如:let request = objectStore.delete(myId) let request = objectStore.add(modifyData) // 请求成功的回调 request.onsuccess=e=>{ console.log(‘添加数据成功。新数据的key是:‘,e.target.result) }
indexDB的查询介绍
以上操作 基本都是要先知道数据的key值,如delete和get都要传入一个key。但更多时候,我们并不知道key(特别是当你采用Key Generator生成key值时),比如我们也许只知道要delete或get的数据的name是“Jeck”,这时我们如何得到想要操作的数据的key呢。
我们可以通过全表查询objectStore.getAll() ,然后逐个遍历表中的数据,但这是性能最低的查询,也是所有数据库设计中要竭力避免的查询,这里就不详述了!
indexDB还提供了索引查询,这也是本节要介绍的重点!不过还是要强调一点,虽然查询的方式相当多,但都大同小异!记住下面三点将有助于你快速掌握:
- 索引和key的操作形式(参数形式,查询条件形式)是一模一样的
- IDBIndex和ObjectStore的各种api:get, getKey, getAll, getAllKeys, openCursor, openKeyCursor 里面都可以传入条件(也可以不传),条件可以是(key的或索引的)特定值或范围。
- 需要一次操作多个数据的情况很常见,但是并不提倡直接 getAll( condition )或 getAllKeys( condition ) 这样的操作!思考一下它的性能,以及占用的内存资源你就明白了!我们更多采用的是游标(cursor)。
接下来使用游标,举一个常见的,稍微有点难的查询场景!开始之前:
// 回顾 前面定义的索引:(索引必须先创建再使用) objectStore.createIndex(‘index_name‘, [‘field1‘, ‘field2‘, ‘field3‘], { unique: true })
查询单个数据:
// 单个查询: const dbIndex = objectStore.index(‘index_name‘) // 注意: 下面传入索引值的语法规则,v1 对应字段 field1,v2 对应字段 field2, v3 对应字段 field3 // 注意:如果索引不是unique的(unique索引get最多当然只会得到一条数据),有可能有多条对应的数据,这时get只会得到最小key的数据。获取所有数据要使用 getAll dbIndex.get([v1, v2, v3]).onsuccess = e => { let data = e.target.result; // 得到符合条件的数据 }
使用IDBKeyRange查询范围内的多个数据:
// 游标查询范围内的多个: const range = IDBKeyRange.bound([min1, min2, min3], [max1, max2, max3]) // 除了bound 还有 only,lowerBound, upperBound 方法,还可以指明是否排除边界值 dbIndex.openCursor(range, "prev").onsuccess = e => { // 传入的 prev 表示是降序遍历游标,默认是next表示升序;如果索引不是unique的,而你又不想访问重复的索引,可以使用nextunique或prevunique,这时每次会得到key最小的那个数据 let cursor = e.target.result; if (cursor) { let data = cursor.value // 数据的处理就在这里。。。 [ 理解 cursor.key,cursor.primaryKey,cursor.value ] cursor.continue() } else { // 游标遍历结束! } }
需要说明的是 IDBKeyRange.bound([min1, min2, min3], [max1, max2, max3]) 到底是什么样的范围?如下:(更多关于IDBKeyRange信息请参考上面的 教程链接!)
max1 > min1 || max1 === min1 && max2 > min2 || max1 === min1 && max2 === min2 && max3 > min3 // 好好理解一下这个 bound 的含义吧 !
事务
理解事务是用好indexDB的关键!事务是在一个特定的数据库上,一组具备原子性和持久性的数据访问和数据修改的操作。
考虑大文件断点续传-任务队列的场景:
实现断点续传,你需要缓存上传的文件,和这个文件的上传任务信息(任务名称,上传进度等),这样就可以下次打开browser时续传了,还可以在任务失败后重启任务了;
上传大文件需要很长时间,设计一个可以随时查看的任务队列,这样用户就不用一直等待了 —— 为了能让用户能随时查看,所有的任务信息需要常驻内存。
考虑到大文件的内存占用过大,你应该只将当前正在上传的文件放到内存中,而非所有任务的所有文件 —— 大部分的文件,应当待在indexDB缓存中,而非内存中。
综上所述:indexDB数据库将会有两个ObjectStore:tasks用于存放任务除了文件之外的信息,files用于存放任务要上传的文件!
现在我们考虑删除任务的场景,删掉一个任务,需要同时删除tasks中的信息和files中的信息;
如果只成功删除了tasks,files中将额外多出永远访问不到的大文件;
如果只删除了files,tasks中将存在一个无法重启的异常任务!这都是不可取的
这就是一个典型的事务场景,具有原子性,不可拆分性,必须都成功!
错误代码:
// 注意这里是错误示范,实际上开启了两个事务:删除tasks 和 files 不能保证都同时成功 const tasksStore = db.transaction(‘tasks‘, ‘readwrite‘).objectStore(‘tasks‘) const filesStore = db.transaction(‘files‘, ‘readwrite‘).objectStore(‘files‘) tasksStore.delete(processId).onsuccess = () => { console.log(‘删除了任务‘) } filesStore.delete(processId).onsuccess = () => { console.log(‘删除了文件‘) }
其实我们只需要做一个很简单的改变,就是声明一个事务来发送两个请求:
const trans = db.transaction([‘tasks‘, ‘files‘], ‘readwrite‘) const tasksStore = trans.objectStore(‘tasks‘) const filesStore = trans.objectStore(‘files‘) // 下方两个操作请求共用了一个事务trans,必须同时成功,否则就失败(即使成功了的请求,数据也将会回滚) tasksStore.delete(processId).onsuccess = () => { console.log(‘删除了任务‘) } filesStore.delete(processId).onsuccess = () => { console.log(‘删除了文件‘) }
或者这样写(虽然效率低了写,但看起来更具原子性):
const trans = db.transaction([‘tasks‘, ‘files‘], ‘readwrite‘) const tasksStore = trans.objectStore(‘tasks‘) const filesStore = trans.objectStore(‘files‘) // 还可以这样: tasksStore.delete(processId).onsuccess = () => { filesStore.delete(processId).onsuccess = () => { console.log(‘删除成功‘) } }
深入事务的生命周期
也许你觉得上面的写法都不够优雅,或者仅仅是想抽出更通用的逻辑,而想做一些封装和抽取时,你会发现事情并不是那么简单。深刻理解indexDB事务的生命周期很关键,虽然这并不容易。
在这里先假设你已经很熟悉js的 Event Loop 和 DOM Event (如果不熟悉,就先去了解一下再回来吧!),接下来一起探讨indexDB的事务生命周期。
正常情况下的生命周期
也许你已经注意到了,indexDB核心就是一个一个的请求,这种请求的处理很像ajax,与其使用回调函数来编程,为何不将其封装成更优雅的promise呢?就像下面这样:
function request(objectStore, method, params) { return new Promise(resolve => { objectStore[method](params).onsuccess = e => { resolve(e.target.result) } }) } const trans = db.transaction([‘tasks‘, ‘files‘], ‘readwrite‘) const tasksStore = trans.objectStore(‘tasks‘) const filesStore = trans.objectStore(‘files‘) await request(tasksStore, ‘delete‘, processId) // 此时事务已经结束,所以下面的请求会报错: await request(filesStore, ‘delete‘, processId)
回顾一下 js的event loop!下面直接给出事务生命周期的要点:
【要点】:当event loop 任务队列中没有等待处理的该事务发起的回调函数,并且正在处理的任务也不是该事务发起的回调函数,这个事务就会停止。
参考官方:Transactions are tied very closely to the event loop. If you make a transaction and return to the event loop without using it then the transaction will become inactive. The only way to keep the transaction active is to make a request on it. When the request is finished you‘ll get a DOM event and, assuming that the request succeeded, you‘ll have another opportunity to extend the transaction during that callback. If you return to the event loop without extending the transaction then it will become inactive, and so on. As long as there are pending requests the transaction remains active.
上面代码第11行结束后,event loop 任务队列为空,事务就会结束,第13行就会报 事务已失活的错误。我们可以把await去掉,像这样:
// request 是个异步函数,而调用是同步的,这里恰好用了异步延迟的特点,让两个请求都能在事务失活前发出。(这里不过是钻了个空子!) request(tasksStore, ‘delete‘, processId) request(filesStore, ‘delete‘, processId)
结合上面标注的【要点】,好好理解一下去掉await前后代码的本质差异,为什么前面的会失败,而后面的会成功。
再看下面的例子
// 错误代码,这和上面await的例子本质是一样的,第一个请求结束后 事务就失活 request(tasksStore, ‘delete‘, processId).then(() => { request(filesStore, ‘delete‘, processId).then(() => { console.log(‘结束‘) }) })
再回顾一下前面的代码:
// 正确的代码,本质和上面不带await的是一样的,不过这里与其说是钻空子,不如说是使用了异步回调的延迟技巧, // 因为上面不带await的代码并不能直观的看出request是异步的,极有可能会出错,而这里却可以很明显的看出 tasksStore.delete(processId).onsuccess = () => { console.log(‘删除了任务‘) } filesStore.delete(processId).onsuccess = () => { console.log(‘删除了文件‘) }
进一步回顾代码,以便理解 【要点】 的本质
tasksStore.delete(processId).onsuccess = () => { // 第一个请求的回调处理,在处理结束(return)前,又发起了一个请求,从而保证了事务的活性! filesStore.delete(processId).onsuccess = () => { console.log(‘删除成功‘) } }
异常情况下的生命周期
以上是所有请求都成功(success)的情况。事务还有一个特性:任何一个请求失败了,其他请求都会回滚,整个事务就失败!
indexDB请求中,我们最常用的回调就是onsuccess,onerror,onupgradeneeded,这些都是对应的DOM event,所以你也可以使用 addEventListener和 removeEventListener,…… 但这里真正的重点是,DOM Event 具有传递的特性。
想象event 在html DOM树中的传递,event在 indexDB事务中的传递基本一样,不过只有error事件会传递!!任何一个error event 一旦被传递给事务,这个事务就会失败,但你也可以像下面这样阻止这种情况:
let req = filesStore.delete(processId) req.onsuccess = () => { console.log(‘删除成功‘) } req.onerror = e => { // 你可以处理错误,但请记住:只有显式的阻止error event 向上传递,它才不会向上传递! // 这和 promise的catch 不一样,你虽然处理了错误,但是没有阻止其传递,整个事务还是会失败! e.stopPropagation() }
indexDB的数据库升级问题
当你打开数据库时,版本号参数比当前已存在的数据库版本高时,或者当前本地不存在这个数据库,就会触发versionChange事件(升级事件),对应于onupgradeneeded 回调!
定义db的chema(首次创建db或升级db) ,比如创建和删除objectStore,创建和删除索引,这样的事情都只能在onupgradeneeded 回调中进行!
由于indexDB是运行在客户端(浏览器)的数据库,它的升级比服务端的数据库升级要复杂(的多),毕竟你可以完全掌控服务端,但用户的行为却无法预测,你需要考虑各种情况。
不能只基于上一个版本做升级
举个例子:假如你的数据库经历过两次升级,版本号由1,到2,又到现在的3了。在做2到3的升级时,你不能只写2到3这一个升级逻辑,你的逻辑必须能够适配1到3的升级,以及直接到3的创建。
因为用户可能是第一次打开你的网站,本地压根就不存在数据库,这时要进行直接到3的创建;
用户也可能在你的indexDB版本还是1的时候打开过你的网站,但直到现在版本升到3了才再次打开,这时要进行1到3的升级;
……
以此类推,你的数据库升级代码必须足够灵活,已便适配所有场景,可以由 无、第1版、第2版 。。。直接到当前的最新版!
索引升级与数据升级的问题
在增删索引时需要先得到对应的objectStore,而要得到objectStore必须先有事务,但是onupgradeneeded 时 你不能创建事务,这似乎是一个矛盾!
其实onupgradeneeded 时已经自带了一个 versionchange的事务,这是一个作用域覆盖了所有objectStores的事务,像这样就可以操作数据了:
openDBRequest.onupgradeneeded = (e) => { objectStore = openDBRequest.transaction.objectStore(‘myObjectStore‘) objectStore.createIndex(‘index_name‘, [‘field1‘, ‘field2‘, ‘field3‘], { unique: true }) }
有些时候我们必须要在onupgradeneeded 中操作数据,已便在升级数据库的同时,升级转换已经存在了的数据!上面解决拿到objectStore的问题(操作数据必须拿到objectStore),但确实不应该在onupgradeneeded中操作数据,当你成功完成了onupgradeneeded 数据库升级后,会触发 onsuccess回调,你应该在这里面操作数据!
数据库升级面临的多窗口问题
用户可能打开了多个浏览器标签或窗口,这时所有页面链接的都是旧版的indexDB。如果用户刷新了某一个页面,从而下载了最新的代码,就会在这个页面触发数据库的升级,这时升级就会出现问题 —— 好在我们在其他页面,可以监听到数据库在请求升级,也可以主动断开链接,你可以这样:
db.onversionchange = function(event) { db.close(); // 关闭链接 console.log("页面内容已过期,请刷新"); };
当数据库已经升级,但页面没有刷新而使用老代码在打开低版本的数据库时,这时会触发VersionError错误,你可以监听这个错误,并提示用户刷新页面!
未经用户同意就直接关闭数据库的链接,可能会给用户带来不好的体验,如果不这么做,就要像下面这样给出提示:
openReq.onblocked = function(event) { console.log("请先关闭其他页面,再加载本页面!"); };