Electron应用数据库选型暨indexedDB扫盲

名谓扫盲,实则扫自己的盲

选型

要说到最近的一个个人项目calendone,它是一个普通的Electron日历应用,有每日记录,定计划等功能,需要做数据持久化。数据量不大,不需要考虑性能问题。简单看了些方案。

SQLite

  • 关系型数据库,具有关系型数据库的一切特性,事务遵循ACID属性。小巧轻便,有knex这样的库做ORM。
  • 是node原生模块,需要重新编译,而且有坑

NeDB

  • NoSQL类型数据库,可以实现本地存储,也可以作为内存存储
  • API是MongoDB的一个子集
  • 纯js实现,一个文件对应一张表

Leveldb

  • NoSQL类型数据库,支持内存和持久化存储
  • 没有事务机制,默认按key查询,没有索引
  • 默认保存的不是js对象,而是字符串。如果要保存为对象,可以在level函数第二个参数加入{ valueEncoding: "json" }来让保存的js对象以json的形式读取

Lowdb

  • 基于Loadsh的纯JSON文件数据库,速度较慢
  • 不支持索引/事务/批量操作等数据库功能

indexedDB

  • NoSQL数据库,浏览器自带,可以储存大量数据,容量为250MB以上
  • 支持事务,有版本号的概念。
  • 支持较多的字段类型

综上考虑,最后采用浏览器自带的indexedDB,足够满足要求,漫游接入成本,升级方便,调试方便。

indexedDB介绍

indexedDB脱胎于HTML本地存储。

HTML本地存储

上古时代中,HTML中持久化数据只有几种方法[1]:

  • cookie。cookie的缺点很明显,最多只能存储4KB的数据,且会携带在同域名下每个HTTP请求的头部,明文传输(除非你使用SSL)。
  • IE userData。微软在上世纪90年代的浏览器大战时推出的本地存储方案,允许每个页面最多存储64K数据,每个站点最多640K数据,它不是Web标准的一部分
  • Flash cookie。它实际上和HTTP cookie并不是一回事,它的名字可能叫做”Flash本地存储”更为合适。考虑到Flash已经是要被淘汰的技术……

本地存储出现后,有了下面一些标准化的更简单的方法:

  • Web Storage接口,即localStorage和sessionStorage
  • Web SQL,这是一个已经废弃的规范。就跟它的名字一样,它就是浏览器端的一个SQL数据库,可以执行SQL语句。由于语法和SQLite绑定过紧,后被标准组织废弃
  • indexedDB,Web SQL的替代品,也是浏览器端的数据库,但他是No SQL的。有MongoDB使用体验的,对它就不会感到陌生。

indexedDB

indexedDB是浏览器提供的本地数据库,目标是持久化存储大量数据,提供类No SQL的增删改查体验。因此它有以下几点特色:

  • 键值对存储,采用对象仓库(object store)存放数据,所有类型的数据都可以直接存入,包括 JavaScript 对象。
  • 支持索引,indexedDB没有表列的概念,但可以建立索引,查询数据时使用id或索引搜索
  • 支持事务,保证操作的原子性,事务中的任意一步失败,数据库都会回滚到操作事务前的状态
  • 异步操作,indexedDB操作都是异步的,在执行数据增删改查时,不会影响界面性能。
  • 同源限制,每一个数据库会关联创建它的域名。网页只能访问自身域名下的数据库,不能跨域访问数据库。
  • 其他,包括存储空间大、支持二进制存储等…

基本概念

  • 数据库,IDBDatabase对象类型,每个域名(协议 + 域名 + 端口)可以新建任意多个数据库。
  • 对象仓库,IDBObjectStore对象类型,一个数据库包含若干个对象仓库,类似于关系型数据库中的表
  • 数据记录,类型于关系型数据库中的行,但是只有主键和数据体两部分。数据体可以是任意数据类型,不限于对象
  • 索引,IDBIndex对象类型,数据记录里除了主键以外的搜索参照
  • 事务,IDBTransaction数据类型,对数据库的增删改查都需要通过事务进行,执行结果通过errorsuccessabort事件回调拿到

indexedDB定义了许多对象接口,即API,除了上面介绍的一些,更完善的接口介绍可以参考MDN的介绍。

操作

indexedDB 鼓励使用的基本模式如下所示:

  1. 打开数据库。
  2. 在数据库中创建一个对象仓库(object store)。
  3. 启动一个事务,并发送一个请求来执行一些数据库操作,像增加或提取数据等。
  4. 通过监听正确类型的DOM事件以等待操作完成。
  5. 在操作结果上进行一些操作(可以在request对象中找到)

打开数据库

使用 IndexedDB 的第一步是打开数据库,使用indexedDB.open()方法如下。

1
var request = window.indexedDB.open(databaseName, version);

其中第一个参数为数据库名,第二个参数是数据库版本号。indexedDB.open()方法返回一个IDBRequest对象。这个对象通过三种事件error、success、upgradeneeded,处理打开数据库的操作结果。

如果数据库不存在,open操作会创建该数据库,然后onupgradeneeded事件被触发,需要在该事件的处理函数中创建数据库模式。如果数据库已经存在,但指定了一个更高的数据库版本,会直接触发 onupgradeneeded事件,允许你在处理函数中更新数据库模式。

注意:这里的版本号是一个unsigned long long数字,使用浮点数是会被转化到最近的整数

绑定处理函数

几乎所有我们产生的请求我们在处理的时候首先要做的就是添加成功和失败处理函数。

1
2
3
4
5
6
request.onerror = function(event) {
// Do something with request.errorCode!
};
request.onsuccess = function(event) {
// Do something with request.result!
};

如果一切顺利的话,相关request的onsuccess()处理函数就会被触发。如果不是所有事情都成功的话,error 事件会在request上被触发。

新建数据库

新建数据库与打开数据库是同一个操作。不同之处在于,后续的操作主要在upgradeneeded事件的监听函数里面完成。通常新建数据库后,第一件事是新建对象仓库,即下面这样:

1
2
3
4
5
6
7
request.onupgradeneeded = function (event) {
db = event.target.result;
var objectStore;
if (!db.objectStoreNames.contains('person')) {
objectStore = db.createObjectStore('person', { keyPath: 'id' });
}
}

上面这段语句使用id作为对象仓库的主键,如果没有合适作为主键的属性,可以让indexedDB自动生成主键。为了便于查询,可以在数据仓库中建立索引。

1
2
3
4
5
6
var objectStore = db.createObjectStore(
'person',
{ autoIncrement: true }
);
objectStore.createIndex('name', 'name', { unique: false });
objectStore.createIndex('email', 'email', { unique: true });

增删改查

增删改查都通过事务进行。事务来自于数据库对象,必须指定你想让这个事务跨越哪些对象仓库。事务中有三种模式,表示你想对数据库进行的操作类型:

  • readonly 默认,只读
  • readwrite 读写操作
  • versionchange 修改数据库模式或结构

只在必要时指定 readwrite 事务。你可以同时执行多个readonly事务,哪怕它们的作用域有重叠;但对于在一个对象仓库上只能运行一个readwrite事务。

增加一条数据的语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
var transaction = db.transaction(["person"], "readwrite");

// 在所有数据添加完毕后的处理
transaction.oncomplete = function(event) {
alert("成功");
};

transaction.onerror = function(event) {
alert("失败");
};

var objectStore = transaction.objectStore("person");
.add({ id: 1, name: 'foo', email: 'foo@bar.com' });

在有了数据后,可以通过几种方法对它进行提取。首先是简单的 get(),通过键获得值。

1
2
3
4
5
6
7
8
9
10
var transaction = db.transaction(["person"]);
var objectStore = transaction.objectStore("person");
var request = objectStore.get(1);
request.onsuccess = function(event) {
// 对 request.result 做些操作!
alert(request.result.name);
};
request.onerror = function(event) {
alert('出错了');
};

在很多场景下你并不知道数据的键,这时可以用索引找到你要的数据。例子中的name属性可能并不是唯一的,在这种情况下,你总是得到键值最小的那个。

1
2
3
4
5
6
// 前提是你已经建立了name索引
var index = objectStore.index("name");

index.get('foo').onsuccess = function(event) {
alert("foo's id is " + event.target.result.id);
};

在需要遍历某一范围的数据集合时,也可以使用游标,这里要用到openCursor方法。比如查询整个数据对象存储。

1
2
3
4
5
6
7
8
9
10
objectStore.openCursor().onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
console.log(cursor.value);
cursor.continue();
}
else {
alert('遍历结束');
}
};

上面的功能也可以通过getAll完成,但是由于数据对象是懒生成的,getAll性能会有消耗。当然你如果想直接拿到整个数据组成的数组,还是getAll好点

结合索引(IDBIndex)和游标(IDBCursor)可以查询指定索引的所有记录,方法openCursoropenKeyCursor分别返回不同的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
index.openCursor().onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
// cursor.key 是一个 name, 就像 "Bill", 然后 cursor.value 是整个对象。
alert("Name: " + cursor.key + ", email: " + cursor.value.email);
cursor.continue();
}
};

index.openKeyCursor().onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
// cursor.key 是一个 name, 就像 "Bill", 然后 cursor.value是主键的值。
// 没办法得到存储对象的其余部分。
alert("Name: " + cursor.key + ", id: " + cursor.value);
cursor.continue();
}
};

更多游标设置参考mdn的介绍。

更新和删除数据,分别使用putdelete方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var request = db.transaction(['person'], 'readwrite')
.objectStore('person')
.put({ id: 1, name: '李四', age: 35, email: 'lisi@example.com' });

request.onsuccess = function (event) {
console.log('数据更新成功');
};

request.onerror = function (event) {
console.log('数据更新失败');
}

var request = db.transaction(['person'], 'readwrite')
.objectStore('person')
.delete(1);

request.onsuccess = function (event) {
console.log('数据删除成功');
};

其中put的数据为全量替换,可以通过get方法拿到数据后,修改特定值再交给put完成增量替换。

indexedDB封装库

indexedDB虽然强大,但是有些API对于用户来说还是不够方便,下面是几个indexedDB的封装库:

  • localForage,支持类Storage API语法的客户端数据存储polyfill,支持回退到Storage和Web SQL
  • dexie.js,提供更友好和简单的语法便于快速的编码开发,有Typescript支持
  • ZangoDB,提供类MongoDB的接口实现,提供了许多MangoDB的特性实现
  • JsStore,提供基于indexedDB的类SQL的语法实现。

综上,考虑到dexie.js的语法更加友善,文档页较完善。最终选择它作为客户端存储的实现。

参考