原文来源于:程序员成长指北;作者:Django强哥
如有侵权,联系删除
最近在给前端班授课,在这次之前的最后一次课已经是在2年前,2年的时间,前端的变化很大,也是时候要更新课件了。整理客户端存储篇章时模糊记得HTML5是有形成标准的数据库,但是当年好像很多方法还处于草案和实验阶段,于是去查了查:本以为Chrome所推崇的WebSQL(受SQLite热门程度影响)会成为标准,但现实并没有,反而是类NoSQL的IndexedDB到时日趋成熟。我希望用15分钟时间,让大家可以把一个前端CRUD程序给跑起来~
IndexedDB概述
众所周知,前端存储无外乎LocalStorage和Cookie,后者功能和空间受限,可以几乎无视,LocalStorage方便,但是如果遭遇要存储的数据较多的场景中,显得力不从心。一方面是来自于存储空间的:
IndexedDB的存储空间(所有访问的网站总和)为磁盘可用空间的50%,或根据浏览器的设定分配;
另外一方面,用LocalStorage只能保存字符串,如果是其他的类型,那就必须用JSON.stringify来转换为字符串后再保存,而IndexedDB则可以直接保存。此外,还具备一般DBMS的常用功能,例如遍历、筛选等。
就目前(2021年),IndexedDB的兼容性已经足够好,可以满足绝大多数场景下的应用,甚至2.0版本也没问题;
01_caniuse.png
02_caniuse_2.png
IndexedDB是类NoSQL类型数据库,可以说是没有结构的。通过预设索引,可以快速的根据索引值进行筛选查询;并且可以将任意JavaScript变量类型或对象直接存入数据库中,而不需要手动转换。一个数据库中可以包含多种对象集合,相对于SQL数据库来说就是多个表;在一个域(名)下,还可以有多个数据库。但是不能跨域访问别的域名之下的数据库。
03_database.png
IndexedDB有一个与普通数据库不同的“版本”概念,考虑到是因为提升用户体验,提高响应速度,才将部分数据存储在客户端,那么如何保持数据同步就会是一个显著的问题。每当因程序更新而需要同步更新数据库时,IndexedDB便提供了很好的支持;
04_db_version.png
IndexedDB所有操作(CRUD),都是基于事务的,这在一定程度上保证了数据的一致性。当出现问题时会自动回滚。同时,大多数数据库的操作也是异步完成的,需要通过在中间对象中绑定成功与失败的相应事件来进一步处理;
应用
大致介绍完IDB,这进就如正题,先看下面IndexedDB的“全家福”:
IndexedDB.jpg
上图列举了IndexedDB所设计的对象(视作为“类”更容易理解),结合后文的案例和过程,阅读起来更方便;
关键概念
在讲应用之前,先跟大家介绍几个关键概念(上图的主要对象):
- 工厂IDBFactory:浏览器为数据库操作的提供的入口,全局静态方法,通过它才能打开一个数据库,open之后获得一个异步对象;
- 异步对象:前文说过,大多数数据库的操作都是异步的,返回的异步对象参照上图的IDBRequest;可以把这个对象看做是一个连接当前与异步结果的媒介。它有成功和错误(失败)两个待绑定的事件;在成功之传入的后续对象一般会在event.target.result中,也可以使用this指针,使用this.result;所有异步的操作都是通过它来传递;另外它还有唯一子类IDBOpenDBRequest,是在打开数据库时专用的异步对象,特殊之处在于它有一个处理数据库升级的专用事件onupgradeneeded和数据库被独占的事件blocked;
- 数据库升级onupgradeneeded:首次运行程序创建数据库,或原有数据库结构变化执行升级过程,都会触发这个事件。另外,但凡是数据结构上的变动,都必须在这个事件中处理,也会使当前数据库也处于独占模式;例如创建集合(一种对象)createObjectStore、删除集合deleteObjectStore或修改对象索引;
- 数据库IDBDatabase:实例化后IndexedDB的顶层对象,只有通过它才能在onupgradeneeded中创建并打开一个数据表IDBObjectStore或打开一次事务IDBTransaction;
- 事务IDBTransaction:一般数据操作都需要通过事务来处理,一次事务至少要申请1个对象集合,也可以是多个;通过objectStore方法同步获得一个对象集合IDBObjectStore,必须是创建事务时申请的集合名称;
- 对象集合IDBObjectStore:操作数据的基本单位(但不是最小),包括常用的CRUD;获取对象的操作分为获取完整的对象或仅获取对象的键;如果获取完整的对象,则浏览器会将数据从数据库中取出后反序列化为对象提供给程序操作;而且只获取对象的键,并以此为基础打开的游标是不能读取和修改数据的;读取方式分为单个读取get或getKey、按条件读取多个getAll或getAllKey以及打开游标openCursor或openKeyCursor;保存put同时具备了新增和更新(不存在或未提供主键就执行新增操作)。
- 索引:IndexedDB把实际的对象数据序列化后保存在磁盘上,不难得知,序列化后保存的数据是不能被查询的,于是需要被条件IDBKeyRange进行筛选、定位(游标方式)的属性/字段,就需要为其设置索引IDBIndex,索引与数据是分开保存的。一个对象集合可以包含0个或多个索引,有了索引就可以用大于、小于、等于方式对某个范围进行筛选和定位。如果要对没有建立过索引的数据字段进行查找,那只能先将所有数据全部取出之后,利用游标的方式一个一个遍历计算(效率较低,但是占用内存小)。
创建数据库连接
创建数据库连接前,最好统一一下兼容性问题,然后通过IDBOpenDBRequest来绑定事件:
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction || { READ_WRITE: "readwrite" };
window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange;
const openRequest = indexedDB.open('TestDB', 1);
openRequest.onerror = event => {
console.log('error', event);
};
let db;
openRequest.onsuccess = event => {
console.log('success');
db = event.target.result;
};
处理数据库创建升级
注意:从无到有创建数据库(相当于从0到1.0)或者数据库版本变大,都属于数据库升级事件;
openRequest.onupgradeneeded = event => {
console.log('upgradeneeded');
const db = event.target.result;
// 数据库集合名称,主键名称
const store = db.createObjectStore('contacts', { keyPath: 'id', autoIncrement: true });
// 为这个对象创建两个索引,分别是姓名和邮件
store.createIndex('name', 'name');
store.createIndex('email', 'email', { unique: true});
};
获取事务对象
// tx 为同步获得的IDBTransaction对象
const tx = db.transaction(['notes', 'users', 'customers'], 'readwrite');
获集合作对象
const store = tx.objectStore('contacts');
CRUD
经过上面所有的基本准备工作就绪后,就可以进入实际操作阶段了。
插入数据
store.add({ name: 'John', lastName: 'Doe', age: 31, email: 'jd@email.com', hobbies: ['reading', 'drawing', 'painting'] });
store.add({ name: 'Madge', lastName: 'Frazier', age: 22, email: 'mf@email.com', hobbies: ['swimming', 'jogging', 'yoga'] });
删除数据
var objectStore = tx.objectStore('contacts');
objectStore.delete(1);
// 游标方式删除
var beDelete = store.openCursor(IDBKeyRange.only(1));
beDelete.onsuccess = e=>{
const cursor = event.target.result;
if (cursor) cursor.delete()
}
修改数据
指定主键keyPath的集合修改数据
const cursorRequest = store.openCursor(1);
cursorRequest.onsuccess = (evt) => {
const cursor = evt.target.result;
if (cursor) {
let contact = cursor.value;
contact.name = "J";
cursor.update(contact);
}
};
没有指定主键keyPath的集合修改数据,即在onupgradeneeded中的创建集合代码如下(没有keyPath参数)
const store = db.createObjectStore('contacts', { autoIncrement: true });
那么除了游标方式之外,还可以直接使用put
store.put({ name: 'Madge', lastName: 'Frazier', age: 43, email: 'mf@gmail.com', hobbies: ['swimming', 'jogging', 'yoga'] },3);
查询方法
生成取值范围的方式
利用IDBKeyRange对象的静态方法创建取值范围对象:
区间 | 表达式 |
key <= x | IDBKeyRange.upperBound(x) |
key < x | IDBKeyRange.upperBound(x, true) |
key >= y | IDBKeyRange.lowerBound(y) |
key > y | IDBKeyRange.lowerBound(y, true) |
key >= x && <= y | IDBKeyRange.bound(x, y) |
key > x &&< y | IDBKeyRange.bound(x, y, true, true) |
key > x && <= y | IDBKeyRange.bound(x, y, true, false) |
key >= x &&< y | IDBKeyRange.bound(x, y, false, true) |
key === z | IDBKeyRange.only(z) |
范围集查询方式
根据主键的范围
javascript
复制代码store.getAll(IDBKeyRange.bound(1, 3)).onsuccess = (evt) => { console.log(evt.target.result) }
根据索引范围
javascript
复制代码// 假设添加了上述代码中的John和Madge,注意:'Madge' < 'Mb'
const nameIndex = store.index("name");
nameIndex.getAll(IDBKeyRange.bound("J", "Mb")).onsuccess = (evt) => { console.log(evt.target.result) }
游标的查询
以IDBObjectStore主键查询打开游标
store.openCursor(IDBKeyRange.bound(1, 3)).onsuccess = (evt) => {
const cursor = evt.target.result;
if (cursor) {
console.log(cursor.value);
cursor.continue();
}
};
以IDBIndex索引方式打开游标
const nameIndex = store.index("name");
nameIndex.openCursor(IDBKeyRange.bound("B", "Mb")).onsuccess = (evt) => {
const cursor = evt.target.result;
if (cursor) {
console.log(cursor.value);
cursor.continue();
}
};
小结
到这里,差不多15分钟了,关于IndexedDB的基本操作也就这么些,下文就是关于数据库每一个属性方法的具体说明,如果你有兴趣深入了解可以继续往下读。
IndexedDB所有对象详情
IDBFactory
IDBFactory是IndexedDB的总入口,与一般的数据库操作方式类似,一切都需要从打开一个数据库连接开始。工厂方式除了打开和删除数据库外,还提供一个两个值的比较函数cmp。
方法
open(dbName,dbVersion)
打开数据库连接dbName参数是字符串(UTF-16),dbVersion是长整型,代表所要打开的数据库版本号;
deleteDatabase(dbName)
删除整个数据库;
open和deleteDatabase都属于异步操作,返回一个IDBOpenDBRequest对象,该对象扩展了IDBRequest。而IDBRequest对象会根据结果产生两种事件,分别是:onsuccess和onerror。这两种事件可以通过addEventListener方法绑定success以及error来处理相应过程,也可以直接以赋值函数的方式实现:
const request = indexedDB.open(dbName, dbVersion);
request.onsuccess = function(evt){...}
request.onerror = function(evt){...}
刚刚说到IDBOpenDBRequest扩展了IDBRequest,所以它还有个两种不同的事件onupgradeneeded和blocked,即处理数据库升级的过程;如果当本地数据库已经存在,且版本小于打开所需要的版本时,会触发数据库onupgradeneeded事件;
request.onupgradeneeded = function(evt){...}
以上这些事件中,都需要依赖一个对象EventTarget,
cmp(valueA ,valueB)
用于比较两个值的大小,返回-1代表a<b,0代表a=b,1代表a>b;
databases()
返回一个异步对象,读取当前源下,所有可用的数据库名称
indexedDB.databases().then(dbs => {
dbs.forEach((db) => {
console.log(db.name, db.version);
});
})
IDBDatabase
利用IBDFactory的open方法,异步打开个数据库获得的对象,代表数据库对象。
属性
name
只读字符串属性,返回当前的数据库名称;
version
只读整形属性,返回当前数据库版本号;
objectStoreNames
只读字符串数组属性,返回当前数据库所包含的集合;注意,如果想要在这里删除掉全部的数据集,不能直接用for循环,可以采用for...of或者将其转换为array后逐个删除;
方法
createObjectStore(collectionName[,options])
创建collectionName为名称的集合,名称在数据库中必须唯一。这个方法只能在onupgradeneeded中使用;options为一个对象,可以包含两个字段:keyPath和autoIncrement。
其中keyPath是定义主键的名称,一般情况下是一个字符串;也可以是一个数组,就相当于多重主键的模式,这样的情况下添加数据都必须要有相关的键值,且不能重复;如果keyPath为空,那么在针对这个集合的新增操作都必须要手动指定一个key值(也就是out-of-line的模式)。
autoIncrement则定义key的属性值是否为自增量(如果keyPath为数组,则这个属性不能必须设置为false);该方法调用后返回一个IDBObjectStore对象;
deleteObjectStore(collectionName)
删除``collectionName`为名称的集合,同样也只能在onupgradeneeded中使用;
close()
关闭当前数据库连接;
transaction(storeNames[, mode[, options]])
返回一个IDBTransaction对象,它包含了objectStore方法,用于CRUD操作。其中storeNames是关于即将进行操作的数据集合名称,可以使数组(多个对象操作)或者字符串(单个对象操作)。这里,也可以将先IDBDatabase的属性objectStoreNames送入,代表存取所有的对象;
mode仅支持两种以字符串为类型的可选参数;代表操作的事务类型,分别是readonly只读,readwrite读写操作;
options为对象类型的可选参数,目前仅有一个属性durability ,数据的可靠程度,分别是strict代表偏重可靠性而放弃性能;relaxed代表偏重性能,比较适合应用于缓存的场景中;default为默认,均衡前两者;
事件
abort
当操作(transaction)发生终止操作事件时被触发;
close
数据库连接意外断开事件(注意:这里是意外断开,而非有意断开)
error
由IDBObjectStore、IDBIndex、IDBCursor等对象引发的错误,冒泡至IDBDatabase的错误事件;
versionchange
当数据库的结构发生变化事引发的事件
IDBTransaction
异步事务使用数据库中的事件对象属性。所有的读取和写入数据均在事务中完成。由IDBDatabase的transaction方法发起事务同时设置事务的模式(例如:是否只读readonly或读写readwrite),后续利用IDBObjectStore来发起一个请求,执行具体的操作任务。同时也可以使用它来中止事务和回滚操作;
属性
db
上级IDBDatabase对象,只读。
durability
创建transaction时指定的数据可靠程度,字符串类型,只读。
mode
事务操作的操作模式,只读还是读写,字符串类型,只读。
objectStoreNames
操作对象集合。创建transaction是,指定的操作对象集合,数组类型,只读。
方法
abort()
终止,并回滚本次操作。如果已经保存完成或者已经退出,那么会触发异常事件;
objectStore(name)
参数是字符串,指向一个存储的集合名称,返回一个IDBObjectStore对象,代表一个对象集合,相当于在NoSQL类数据库中(如MongoDB)的一个Collection,在SQL类数据库中相当于一个表对象;
commit()
手动将当前未提交事务提交给作业。但这个步骤并非必须,即便是没有commit,事务也会在没有其他请求时被自动提交。
事件
abort
事务时间被终止时触发事件;
complete
事务成功完成后,触发事件;
error
可能因某个子对象(IDBObjectStore)发生异常冒泡至此或其本身发生错误触发的事件;
IDBObjectStore
表示一个对象集合,相当于在NoSQL类数据库中(如MongoDB)的一个Collection,在SQL类数据库中相当于一个表对象;在这对象中可以对索引进行操作,例如排序和查找;
属性
indexNames
返回当前集合中的索引名称集合,其元素类型是字符串集合(Set);
javascript
复制代码var objectStore = transaction.objectStore("staff");
Array.from(objectStore.indexNames).forEach((index) => {
console.log(index);
});
keyPath
如果把集合类比作为SQL类数据的表,那么返回当前集合的主键名称。是集合中的不可重复的键值;如果在createObjectStore 时并没有指定keyPath,那么这里返回null;该属性只读;
name
当前集合的名称;只有在onupgradeneeded中这个属性值可以被修改,一般情况下为只读属性;
transaction
返回所隶属的transaction对象,只读属性;
autoIncrement
返回是否为自增量类型,只读属性;
方法
add(value[, key])
向指定集合中添加一个对象,同步返回一个IDBRequest对象;除了监听IDBRequest的success事件之外,还可以监听transaction对象的complete事件以判断是否完成添加动作。实际上,当添加动作放入事务队列时,就触发了IDBRequest的success事件,但只有真正写入数据之后,才会触发transaction的complete事件。
另外,如果在创建集合时,没有指定keyPath,那么需要在这里指定key,并且不可以重复;
var transaction = db.transaction(["staff", "department"], "readwrite");
transaction.oncomplete = function (event) {
conosole.log("事务处理完成");
};
transaction.onerror = function (event) {
console.log("事务遇到错误");
};
var objectStore = transaction.objectStore("department");
var objectStoreRequest = objectStore.add({ name: "RD", leader: "Django" }, 5);
objectStoreRequest.onsuccess = function (event) {
console.log("新建对象进入队列");
};
clear()
返回IDBRequest对象,此时将开始清空所有集合中的数据;
count([query])
返回IDBRequest对象,开始计算存储总记录数;如果提供了query参数,那么会根据条件计算;query参数是一个IDBKeyRange对象
createIndex(indexname,keyPath[,objectParameters])
创建新的字段或列,并立刻返回一个IDBIndex对象。这个方法仅能在IDBDatabase的onupgradeneeded事件中使用。indexName代表索引名称;keyPath含义与集合的主键keyPath雷同,同样也支持数组;objectParameters是IDBIndexParameters对象,该对象包含以下属性:
- unique:布尔值,是否允许重复的键;
- multiEntry:布尔值,如果为true,则索引将会为每个数组元素添加一个keyPath的入口,否则一个数组共享一个入口;
- locale:字符串类型,本地化代码,可以使用auto;
delete(key|keyRange)
删除指定的数据,参数类型为IDBKeyRange,返回IDBRequest对象;
deleteIndex(indexName)
删除指定名称的索引,没有返回值,并且只能在onupgradeneeded中执行;
get(key|keyRange)
获取单个指定(key)或者IDBKeyRange查询匹配的对象(数据/记录)同步返回IDBRequest对象,在onsuccess中的event.target.result返回第一个命中的结果;
getKey(key|keyRange)
与get()方法雷同,但不是返回命中的数据对象,而是这个对象的主键;
getAll([query[, count]])
根据查询条件返回指定数量的结果集;query是IDBKeyRange对象,如果不传任何参数,则会返回将所有数据返回回来;count是限制返回命中的结果集上限,参数必须大于0小于2^32-1;方法返回IDBRequest对象;
getAllKeys([query[, count]])
雷同getAll()方法,返回的结果不是数据,而是数据的主键值;
index(name)
打开以name命名的的索引,同步返回IDBRequest对象,success后得到IDBIndex对象;
openCursor([query[, direction]])
创建一个游标,该方法适合在不需要查询检索(利用索引)而操作数据库的场景下使用;query是IDBKeyRange对象,用于表示查询参数;direction表示游标跳转方向,在这里是string类型,支持4种游标移动方向,分别是:
- next:移动到下一个数据上也是默认值;
- nextunique:下一个唯一值;
- prev:移动到上一个数据;
- prevunique:上一个唯一值;
openCursor()调用后同步返回一个IDBRequest对象,成功后获得一个IDBCursorWithValue对象,以此便可以迭代获得数据;
openKeyCursor([query[, direction]])
基本雷同openCursor(),但是其结果中只有key值的集合,不含有整条记录
objectStore.openKeyCursor().onsuccess = function (evt) {
let keys = Array.from(evt.target.result.key);
keys.forEach((key) => console.log(key));
// key1,key2,...
};
put(item[, key])
将数据存储集合中,item是要被存储的数据;key为数据键值。如果设置了自动增量autoIncrement的主键,那么就是选填项,且执行的操作是新增。如果key已经存在与集合中,那么此时的put操作就是更新操作;这个方法也是异步的,返回IDBRequest对象;
IDBIndex
IDBIndex是对数据库中索引的异步访问,而在IDB中的索引则是一个重要的概念,用来检索数据(用大于、小于、等于方式),如果要对没有建立过索引的数据字段进行查找,那只能利用游标的方式一个一个遍历操作(效率较低)。IDIndex对象的大部分方法被IDBObjectStore的方法所重用(也许相反),这里就重复部分简单略过;
属性
name
索引的名称,一般情况下只读能在upgradeneeded事件中修改;
objectStore
索引所隶属IDBObjectStore对象,只读;
keyPath
索引被创建时设置的keyPath值字符串或数组类型,只读;
multiEntry
索引被创建时设置的多入口值,boolean类型,只读;
unique
索引被创建时设置的是否唯一值,boolean类型,只读;
事件
属于IDBIndex的事件有:count(),get(),getKey(),getAll(),getAllKeys(),openCursor(),openkeyCursor(),均与IDBObjectStore雷同,区别只是局限于当前的IDBIndex对象中;
IDBKeyRange
用于表示一组取值范围的对象,起到表示数据库查询条件对象;
属性
lower
返回小于某个key值的属性,只读;
upper
返回大于某个key值的属性,只读;
lowerOpen
小于是否为开区间(不包括),布尔值,只读
upperOpen
大于是否为开区间(不包括),布尔值,只读
静态方法
bound((lower:any, upper:any[, lowerOpen:boolean[, upperOpen:boolean]]))
创建一个介于lower和upper值之间的IDBKeyRange对象,第三第四个参数分别表示下界和上界是否为开区间;例如 A <= x <= B 则是:bound(A,B,false,false)
only(value)
产生一个等于value值的IDBKeyRange;
lowerBound(key[, open])
生成一个只有下界的区间,open是否为开区间,默认为false(包含);
upperBound(key[, open])
生成一个只有上界的区间,open是否为开区间,默认为false(包含);
方法
includes(key)
验证某个key值是否包含在IDBKeyRange区间内,返回布尔值;
IDBCursor
get和getAll都需要批量的将磁盘中的序列化数据反序列化后存入内存以便操作,这样的情况遇到需要大量的数据读取就会产生性能问题,此时可以考虑使用游标的方式,操作一条就只序列化一条数据(一个对象)。
属性
source
当前游标的父级对象,可能是IDBObejctStore或者IDBIndex,只读属性;
direction
游标的移动方向,字符串类型,参考IDBObjectStore对象的open方法,只读属性;
key
当前游标的Key值,参考createIndex方法,只读属性;
primaryKey
当前游标的集合的主键类,参考createObjectStore的options参数,只读属性;
request
打开当前游标的IDBRequest对象,只读属性;
方法
advance(count:integer):void
游标向后移动count个记录;
continue([key:any]):void
将游标移动到所指定的键的位置,但必须是大于当前游标所在的键上;
continuePrimaryKey(key:any,primaryKey:any):void
IDB是允许重复的主键和索引存在,这样的情况下用单值就可能无法将游标定位到真正所需要的记录上,所以这个方法借助主键和索引两个条件来定位一条数据。另外,调用该方法还要求其source得是IDBIndex对象,不支持IDBObjectStore对象;
delete()
删除游标所指的对象,同步返回IDBRequets对象;如果以openKeyCursor方法获得的游标对象,此方法不可用,要改用openCursor方法;
update(value:any)
更新游标指向对象的值,同步返回IDBRequest对象;如果以openKeyCursor方法获得的游标对象,此方法不可用,要改用openCursor方法;
IDBCursorWithValue
它扩展了IDBCursor对象,区别是在使用这个对象时,游标指向的数据会被反序列化成为对象,可以进行访问操作,也就是有value属性;
属性
value
游标指向的数据,只读;
方法
在官方的文档中,delete和update是属于IDBCursor的,但是这两者在没有value的情况下是无法被使用的,所以我认为可能存在一些瑕疵,所以做类图的时候,我将这两个方法归属到了IDBCursorWithValue的对象中;
delete()
删除游标所指的对象,同步返回IDBRequets对象;
update(value:any)
更新游标指向对象的值,同步返回IDBRequest对象;
参考
w3.org
MDN
A closer look at IndexedDB