blog
blog copied to clipboard
离线应用技术之——IndexedDB
想要构建离线应用,除了使用service worker,另一个绕不开的话题便是IndexedDB。
IndexedDB是浏览器端的一个基于键值对存储的事务型数据库。为什么要用它,因为想要做到在离线情况下展示数据,数据的持久化是离线应用绕不过去的一个坎。以前使用的是Web SQL,不过它已经被废弃掉了,所以never mind。什么,你说localstorage?确实有很多人会使用localstorage来存储数据,但是相比IndexedDB,它存在很大的不足,原因有三:
-
localstorage 存储有大小限制,限制5MB,不能存储大量数据,尤其是带有结构的数据。
-
没有查询语句,没有schema,基本上没有任何有关数据库的操作。每次的写入和写出都要字符串化和对象化,何其麻烦。所以在处理带结构的大型数据上基本毫无扩展性。
-
最关键的一点是,localstorage的API是同步的,这就意味着它会阻塞DOM操作。并且很多时候,离线应用的数据操作需要在service worker中进行,service worker只接受异步的API,所以相较而言,IndexedDB是更好的选择。
下面这张表摘自张鑫旭的博文,改装了一下,可以快速了解IndexeDB的一些基础特性。
IndexedDB | |
---|---|
优点 | 1. 允许对象的快速索引和搜索,因此在Web应用程序场景中,您可以非常快速地管理数据以及读取/写入数据;2. 由于是NoSQL数据库,因此我们可以根据实际需求设定我们的JavaScript对象和索引;3. 在异步模式下工作,每个事务具有适度的粒状锁。这允许您在JavaScript的事件驱动模块内工作。 |
不足 | 如果你的世界观里面只有关系型数据库,恐怕不太容易理解。 |
位置 | 包含JavaScript对象和键的存储对象。 |
查询机制 | Cursor APIs,Key Range APIs,应用程序代码 |
事务 | 锁可以发生在数据库版本变更事务,或是存储对象“只读”和“读写”事务时候。 |
事务提交 | 事务创建是显式的。默认是提交,除非我们调用中止或有一个错误没有被捕获。 |
也许,上表中说到的一些概念你还不是很懂。嘿,您先别急,先坐下,且听我慢慢给您说。
Q1:什么是NoSQL数据库? A1:非关系型数据库,其中的一大类便是通过键值(key-value)存储数据。IndexedDB便是属于这一类。
Q2:什么是事务? A2:指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。它有以下四点特性:
- 原子性(Atomicity):事务中的所有操作作为一个整体提交或回滚。
- 一致性(Consistemcy):事物完成时,数据必须是一致的,以保证数据的无损。
- 隔离性(Isolation):对数据进行修改的多个事务是彼此隔离的。
- 持久性(Durability):事务完成之后,它对于系统的影响是永久的,该修改即使出现系统故障也将一直保留。
Q3:Cursor是干嘛用的? A3:cursor即游标,类似于现实中的游标,一个刻度表示一行数据,游标就是尺子上的一片区域,想要获得数据库一行一行的数据,我们可以遍历这个游标就好了。
前菜上的差不多了,现在进入我们的正餐部分。
如何使用IndexedDB?
使用IndexedDB其实还蛮简单的,你只需要做两件事:
-
创建或打开一个数据库
-
创建一个Object Store对象仓库(它是IndexedDB存储数据的机制,习惯了关系型数据库的同学可以把它想象成一张表)
遵循上面两步,我们便可以开始愉快的使用IndexedDB了。代码如下:
// 创建或使用一个数据库
const request = window.indexedDB.open("todos", 1)
// 创建schema, 如果浏览器没有找到todos数据库,则它会创建一个,同时触发upgradeneeded事件。
request.onupgradeneeded = event => {
//db代表的是与数据库的连接
const db = event.target.result;
//首先检测是否存在我们要创建的Object Store
if(!db.objectStoreNames.contains("todo-meta")) {
//通过createObjectStore方法创建ObjectStore对象来存储对象
const todoStore = db.createObjectStore('todo-meta', {keyPath: 'id'});
//使用index来检索数据,createIndex方法接受两个参数,一个代表index的name,一个代表要被indexed的属性
todoStore.createIndex('nameIdx', 'name');
}
if(!db.objectStoreNames.contains("todo-items")) {
// keyPath也可以传递一个数组,来共同组成可以索引
const itemStore = db.createObjectStore(
"todo-items",
{ keyPath: [ "todoId", "row" ] }
);
itemStore.createIndex("todoIndex", "todoId");
}
if(!db.objectStoreNames.contains("attachments")) {
// 不传keypath也行,交由数据库自己处理key,适合于一些文件内容的存储。
const fileStore = db.createObjectStore(
"attachments",
{ autoIncrement: true }
);
}
}
好吧,我撒谎了,从代码上看,使用IndexedDB并不是那么简单啊,这还是在我没有考虑兼容性的情况下,而忽略了以下这么一大段代码。
// In the following line, you should include the prefixes of implementations you want to test.
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
// DON'T use "var indexedDB = ..." if you're not in a function.
// Moreover, you may need references to some window.IDB* objects:
window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction;
window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange;
// (Mozilla has never prefixed these objects, so we don't need window.mozIDB*)
来自中外各大名宿的吐槽,IndexedDB的API是出了名的膈应人。就比如说新建一个数据库,为啥我要给它取名叫request而不是idb之类的。事实上,对IndexedDB来说,新建数据库是一个request请求,用MDN的原话是,IndexedDB uses a lot of requests
。通过这些请求,它能够获取到相应的DOM事件,从而判断该操作是成功了还是失败了。同时,这些请求也有readyState,result和errorCode等一系列属性。这怎么看都像是一个XMLHTTPRequest对象,简直不能再坑了。
吐槽归吐槽,我们还是认真看一下上一段代码做了什么。在连接数据库后,我们通过createObjectStore方法新建了三个对象仓储,第一个参数即为仓储名,第二个参数即为配置项,包含两个属性:1. keyPath,2. autoIncrement。keyPath用于指定对象的键,如果未指定,则对象的创建使用的是out-of-line keys;指定了,则使用in-line keys。至于什么是out-of-line keys和in-line keys,我们通过一图流来进行详细的说明。
可以这么理解,out-of-line keys即为单独生成的一个key,可能需要我们自己指定。而in-line keys则是指定对象的一个属性作为key值,由数据库自动绑定。
至于autoIncrement属性,可以看成一个key generator,自动生成key值。一般来说,keyPath和autoIncrement属性只要使用一个就够了,如果两个同时使用,表示键名为递增的整数,且对象不得缺少指定属性。
当然,除了使用key存储对象,也可以为对象指定index。就像上面代码中的createIndex做的那样,我们依然通过一图流来说明key和index的区别。
可以看到,两者其实是同一份数据,只是由不同的属性索引,当需要检索某一特定属性的数据时,index格外有用。
下面讲讲如何进行数据库的CRUD操作,毕竟这才是我们真正关心的。
执行IndexedDB的CRUD操作只需要如下五步:
- 与数据库建立连接
- 创建一个事务
- 指明Object Store
- 在该store上执行操作
- 清除数据库连接
1. 添加数据
const request = window.indexedDB.open("todos", 1);
request.onsuccess = () => {
const db = request.result;
// db.transaction的第一个参数是一个数组,表明我们希望事务执行操作的ObjectStore列表,通常是只有一个。第二个参数表明事务执行的操作,一般为readonly(只读)和readwrite(读写)
const transaction = db.transaction(
[ "todo-meta", "todo-items" ],
"readwrite"
);
// 事务使用的两个store
const metaStore = transaction.objectStore("todo-meta");
const itemStore = transaction.objectStore("todo-items");
// 新增数据
metaStore.add(
{ todoDate: 112342392131, todoAuthor: 'luffy' }
);
itemStore.add({
todoId: 1,
row: 1,
name: 'todo 1',
completed: false
});
// 清除数据库连接
transaction.oncomplete = () => {
db.close();
};
2. 更新数据
// ...如上连接数据库,创建事务
// 这个操作会通过keypath来更新数据,itemStore的keyPath为属性todoId和row的结合,可以看到上个操作新建的todo item的completeed属性被更新为true
const itemStore = objectStore.put({
todoId: 1,
row: 1,
name: 'todo 1',
completed: true
});
// 当然,objectStore.put也可以接受第二个参数,表明对象的键,一般用于out-of-line key的情况。
attachmentStore.put(VALUE, KEY);
// ...清除数据库连接
3. 删除数据
// ...如上连接数据库,创建事务
// 大概是最简单的API了,指明我们要删除的key就行了
itemStore.delete([ "123", "2" ]);
// ...清除数据库连接
4. 读取数据
读取操作有那么点不同,因为它会新建一个request,读取数据在request的回调中。
// ...如上连接数据库,创建事务
// 通过监听onsuccess来获取数据,通过键名获取指定数据
const getRequest = itemStore.get("123");
// 也可以通过getAll方法获取全部数据
const getAllRequest = itemStore.getAll();
getRequest.onsuccess = () => {
// 获取的数据在request的result属性中
console.log(getRequest.result);
};
5. 遍历数据
get()和getAll()可以获取单个数据或全部数据,但如果想进行更精细的读取操作,比如读取3-20范围的数据,则需要用到cursor及IDBKeyRange两个对象了。
索引的有用之处,在于可以指定读取数据的范围
IDBKeyRange对象的作用则是生成一个表示范围的Range对象。它的生成方法有四种
lowerBound方法:指定范围的下限。 upperBound方法:指定范围的上限。 bound方法:指定范围的上下限。 only方法:指定范围中只有一个值。
下面的代码直接摘自阮一峰老师的文章,仅供参考
// All keys ≤ x
var r1 = IDBKeyRange.upperBound(x);
// All keys < x
var r2 = IDBKeyRange.upperBound(x, true);
// All keys ≥ y
var r3 = IDBKeyRange.lowerBound(y);
// All keys > y
var r4 = IDBKeyRange.lowerBound(y, true);
// All keys ≥ x && ≤ y
var r5 = IDBKeyRange.bound(x, y);
// All keys > x &&< y
var r6 = IDBKeyRange.bound(x, y, true, true);
// All keys > x && ≤ y
var r7 = IDBKeyRange.bound(x, y, true, false);
// All keys ≥ x &&< y
var r8 = IDBKeyRange.bound(x, y, false, true);
// The key = z
var r9 = IDBKeyRange.only(z);
通过IDBKeyRange,结合cursor,便可以实现在一定范围内读取数据的操作了。
// 想要遍历数据,就要openCursor方法,它在当前对象仓库里面建立一个读取光标(cursor)
// 绑定range
var range = IDBKeyRange.bound('3', '20');
const cursor = db.trasaction(['todo-item'], 'readOnly').objectStore('todo-item').openCursor(range);
//回调函数接受一个事件对象作为参数,该对象的target.result属性指向当前数据对象。当前数据对象的key和value分别返回键名和键值(即实际存入的数据)。continue方法将光标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向null。
cursor.onsuccess = function(e) {
var res = e.target.result;
if(res) {
console.log("Key", res.key);
console.dir("Data", res.value);
res.continue();
}
}
结语
终于把IndexedDB的API给走了一遍,基本上涵盖了我们日常开发的大部分操作,当然还有一部分API可以直接从MDN上查阅。可以看到,这些API不可谓不繁琐,如果直接使用这些API,估计你们不是累死就是被气死。
正所谓哪里有压迫哪里就有反抗,我们的Jake Archibald大神在官方的基础上封装了一个Promise风格的库--idb,可以方便开发者们按照现代JavaScript的方式使用IndexedDB。这里有一篇文章便是基于这个库来介绍IndexedDB的,写的相当不错。
下面这行代码大概展示了使用idb来写出promise及async风格的代码,来源于medium。
async function getAllData() {
let db = await idb.open('db-name', 1)
let tx = db.transaction('objectStoreName', 'readonly')
let store = tx.objectStore('objectStoreName')
// add, clear, count, delete, get, getAll, getAllKeys, getKey, put
let allSavedItems = await store.getAll()
console.log(allSavedItems)
db.close()
}
感兴趣的同学也可以看看这个简单的使用idb实现的todoList。
以上,XD。