适时用起indexDB
23 June 2018

最近在做用户在富文本编辑器中编辑内容缓存时首次接触使用了indexDB,较系统的整理了一下相关知识。

使用场景

需要在web端存储大量数据,且只用兼容现代浏览器,具有一定复杂度。(兼容性和复杂性也是indexDB存在这么久运用却不太高的重要因素吧)

web端存储数据的优先考虑项是localstorage,方便、兼容性好。

但是indexDB也有它存在的场景:

  • 存储数据量更大,localstorage每个域名是3M左右(各浏览器有差别),indexDB大的多,一般来说不少于250MB
  • 效率更高,数据存储结构加上索引能更高效的查询
  • 异步操作,不阻塞主线程的js任务

譬如邮件各种编辑器场景下,缓存用户的文章、图片,以防止关闭标签页丢失数据,是不二选择。

简介

IndexedDB 是一个用于在浏览器中储存较大数据结构的 Web API, 并提供索引功能以实现高性能查找. 像其他基于 SQL 的 关系型数据库管理系统 (RDBMS) 一样, IndexedDB 是一个事务型的数据库系统. 然而, 它是使用 JavaScript 对象而非列数固定的表格来储存数据的.

存储键值对

值可以是复杂的对象,表的key可以是对象的一个属性值,可以使用属性值创建索引提高查询效率

创建在事务(transactional)模型上

所有对于索引、表的操作都通过事务,事务有一系列的生命周期,并且不受人工影响而自动完成。

事务模型对于多个同时进行的操作非常有效,譬如同时打开多个操作访问数据库,每个都能独立安全的进行而不会相互影响

异步

所有api都是异步的,通过回调函数来获取返回

IndexedDB使用大量requests来进行读写操作,requests拥有onsuccessonerror放发,可以用addEventListenerremoveEventListener来监听他们

基于对象

这是与关系型数据库的很大不同,IndexedDB没有关系型数据库中的行和列来存储数据。它需要为存储对象创建一个object store和一个js对象去存储。每个object store可以有独特的索引值来让其高效的查询和遍历

概念

database

由一个或者多个object store构成,需要有:

  • 名称
  • 版本:一般是在表字段有增删、修改时改动版本。通过打开一个更高版本的数据库,触发versionchangeupgradeneeded的事务,然后来更改表字段

一个数据库在同一时刻可以被多个任务连接

durable

readwrite事务的IDBTransaction.oncomplete事件并不发生在所有数据都写完成,而是发生在系统被告知去写数据,但是并不是数据真的都写到了硬盘里,这样有利于提升效率

object store

object store记录的数据是键值对,每个object store需要有个唯一标识的key字段,如果定义了key path,它使用in-line keys,否则,使用out-of-line keys

可以指定记录中的一个字段作为键值(key path),或者可以使用自动生成的递增数字作为键值(key generate)

比较难理解

  • in-line keys:指定了使用存储对象中的特定key作为类似数据库表的主键,也就是object store的key path,这个key的每条记录值需要是唯一的

  • out-of-line keys:不指定,由系统自动生成唯一的key,这时候存储对象中就没有这个key字段了

image

看着这两个的定义还是有点复杂的。我们可以联系SQL数据库来思考。SQL的表结构包含多个字段,必须有一个主键。主键可以设置为自增或者是自定义。IndexedDB只不过是存储结构有一些不同,它更加灵活,它也需要一个主键,只是这个可以是数据上的,也可以是数据外的。KeyPath为No,也就是不在数据中保存主键,所以存储对象空间可以存放任何类型,而KeyPath为Yes,则需要在数据中保存主键,所以数据也就只能是对象了。Key Generator则是指定主键是否是自增的,如果是Yes,则主键自增,但是也可以自己指定,如果为No,则主键必须用户自己指定。

key path和key generate配置的是对象存储空间的主键和是否自增。IndexedDB还支持索引和唯一索引,前提是对象存储空间存储的是对象。 - 木杉的博客

transaction

获取或者修改数据库里的数据都是通过事务。一个数据库连接可以同时存在多个活跃中的事务

耗时太长的事务会被浏览器终止,也可以手动调用终止,终止时,会回滚该事务中的所有操作

index

索引,每条记录的唯一值,用来区别不同数据记录。是稳定的key-value结构,它的value是 object store 的key字段的值?(The index is a persistent key-value storage where the value part of its records is the key part of a record in the referenced object store) 区别记录

索引是一种数据结构,而不是表象中的表中的一个字段,他相当于指向表中字段的指针

key

用来存储数据和获取数据的key,类似对象中的属性名

  • 来源:自动生成的key generator, 创建object store时定义的key path, 或者指定的一个名称
  • 特征:1)key名不可重复,类似于对象中的属性不能重复 2)默认升序,后一条记录的比前一条记录的值大

value

每条记录都有个value,可以存储js包含的类型: boolean, number, string, date, object, array, regexp, undefined, null. 甚至是Blobs 和 files

key path

存储结构store object对应存储数据中对象的属性名

(以下内容懒得翻译了。。。。)

scope

The set of object stores and indexes to which a transaction applies. The scopes of read-only transactions can overlap and execute at the same time. On the other hand, the scopes of writing transactions cannot overlap. You can still start several transactions with the same scope at the same time, but they just queue up and execute one after another.

cursor

A mechanism for iterating over multiple records with a key range. The cursor has a source that indicates which index or object store it is iterating. It has a position within the range, and moves in a direction that is increasing or decreasing in the order of record keys. For the reference documentation on cursors, see IDBCursor.

key range

A continuous interval over some data type used for keys. Records can be retrieved from object stores and indexes using keys or a range of keys. You can limit or filter the range using lower and upper bounds. For example, you can iterate over all values of a key between x and y.

整理

存数据放value里就可以,创建key字段是为了方便查询,建Index是为了高效查询,而且index要求唯一

使用

打开数据库

let db
const DBOpenRequest = window.indexedDB.open(dbName, version)
// 如果数据库打开失败
  DBOpenRequest.onerror = () => {
    console.log('数据库打开异常')
  }
  
  DBOpenRequest.onsuccess = () => {        
    // 存储数据结果
    db = DBOpenRequest.result;
  }

创建object store(数据表)信息

DBOpenRequest.onupgradeneeded = (event) => {
    const db = event.target.result;
    
    db.onerror = function() {
      console.log('数据库打开失败');
    };
    
    // 创建一个数据库存储对象
    const objectStore = db.createObjectStore(dbName, { 
      keyPath: 'id'
    });
    
    objectStore.createIndex('id', 'id', {
      unique: true    
    })
};

这个示例中创建了in-line keysobject store,要求所有存储的数据中都要含有id字段,并且以此创建索引,方便后续针对此字段的查找。

createObjectStore的第二个参数即可指定in-line keys中的keyPath

objectStore.createIndex的第一个参数是存储结构object store的key值,第二个字段是存储结构中的key字段对应的要存储的数据对象中的属性值

也可以创建out-of-line keysobject store


// 使用自动生成的递增数字作为键值
const objectStore = db.createObjectStore(dbName, { autoIncrement: true });

objectStore.createIndex('id', 'id', {
  unique: true    
})

增加/更新记录

add接口,在明确能区分是增加还是更新操作时,建议增加用add接口与,更新用put接口,以提高性能。

但是有些场景是无法区分是增加或者是更新操作的,譬如某个操作后需要更新数据,但是并不知道这条数据之前是否已经存在,那么都使用put接口即可,put接口在没有记录是会增加,存在记录时则更新

const transaction = this.db.transaction([dbName], "readwrite")
// 打开已经存储的数据对象
const objectStore = transaction.objectStore(dbName)
// 添加到数据对象中
const objectStoreRequest = objectStore.put(value)  // 或者使用objectStore.add(value)   
objectStoreRequest.onsuccess = () => {
  resolve()
}
objectStoreRequest.onerror = (err) => {
  console.error(`从数据库读取${key}异常`,err)
  reject()
}

删除记录

const transaction = db.transaction([dbName], "readwrite")
// 打开已经存储的数据对象
const objectStore = transaction.objectStore(dbName)
// 添加到数据对象中
const objectStoreRequest = objectStore.delete(id)  
objectStoreRequest.onsuccess = () => {
    console.log(`删除${id}成功`)
}
objectStoreRequest.onerror = (err) => {
  console.error(`从数据库删除${id}异常`, err)
}

查询记录

查询记录需要使用cursor, cursor类似一个指针,在数据记录中一条条移动遍历出每条记录

let recordList = []
const objectStore = db.transaction(dbName).objectStore(dbName);
objectStore.openCursor().onsuccess = function(event) {
      var cursor = event.target.result;
      // 如果没有遍历完,继续下面的逻辑
      if (cursor) {
          recordList.push(cursor.value);            
          // 继续下一个游标项
          cursor.continue();
      // 如果全部遍历完毕
      } else {
          console.log('存储的数据为',JSON.stringify(recordList))
      }
  }

存储非对象类型的普通数据

以上示例代码都是针对含有索引的object类型数据的存储。这也是大多�数应用场景

image

其实indexDB可以存储任何js中合法类型的数据,可以没有主键keyPath,不创建索引index。

譬如:

// 创建object store
const objectStore = db.createObjectStore(dbName);
// 添加、更新数据
objectStore.put(value, key)

数据库结构:

image

实例

export default class DBOperator{

  constructor(config){
    this.dbName = config.dbName || 'store'
    // 版本
    const version = config.version || 1
  
    this.DBOpenPro = new Promise((resolve, reject) => {
      // 打开数据库
      const DBOpenRequest = window.indexedDB.open(this.dbName, version)
          // 如果数据库打开失败
      DBOpenRequest.onerror = () => {
        reject('数据库打开异常')
      }
      
      DBOpenRequest.onsuccess = () => {        
        // 存储数据结果
        this.db = DBOpenRequest.result;
        resolve()
      }

      DBOpenRequest.onupgradeneeded = (event) => {
        const db = event.target.result;
     
        db.onerror = function() {
          reject('数据库打开失败');
        };
    
        // 创建一个数据库存储对象
        const objectStore = db.createObjectStore(this.dbName, { 
          keyPath: 'id',
          autoIncrement: true
        });
    
        // 定义存储对象的数据项
        objectStore.createIndex('id', 'id', {
          unique: true    
        })
        // 可以定义更多的字段
      };
    })
  }

  get(key){
    return new Promise((resolve, reject)=>{
      this.DBOpenPro.then(()=>{
        const transaction = this.db.transaction([this.dbName], "readonly")
        // 打开已经存储的数据对象
        const objectStore = transaction.objectStore(this.dbName)
        // 添加到数据对象中
        const objectStoreRequest = objectStore.get(key)     
        objectStoreRequest.onsuccess = () => {
          resolve(objectStoreRequest.result)
        }
        objectStoreRequest.onerror = (err) => {
          console.error(err)
          reject()
        }
      })
    })
  }

  set(value){
    return new Promise((resolve, reject)=>{
      this.DBOpenPro.then(()=>{
        const transaction = this.db.transaction([this.dbName], "readwrite")
        // 打开已经存储的数据对象
        const objectStore = transaction.objectStore(this.dbName)
        // 添加到数据对象中
        const objectStoreRequest = objectStore.put(value)     
        objectStoreRequest.onsuccess = () => {
          resolve()
        }
        objectStoreRequest.onerror = (err) => {
          console.error(`从数据库读取${key}异常`,err)
          reject()
        }
      })
    })
  }

  delete(id){
    return new Promise((resolve, reject)=>{
      this.DBOpenPro.then(()=>{
        const transaction = this.db.transaction([this.dbName], "readwrite")
        // 打开已经存储的数据对象
        const objectStore = transaction.objectStore(this.dbName)
        // 添加到数据对象中
        const objectStoreRequest = objectStore.delete(id)  
        objectStoreRequest.onsuccess = () => {
          resolve()
        }
        objectStoreRequest.onerror = (err) => {
          console.error(`从数据库删除${id}异常`, err)
          reject()
        }
      })
    })
  }
}

更多

范围查询

使用IDBKeyRange和Cursor进行范围查询

索引的有用之处,还在于可以指定读取数据的范围。这需要用到浏览器原生的IDBKeyRange对象。 IDBKeyRange对象的作用是生成一个表示范围的Range对象。生成方法有四种:

  • lowerBound方法:指定范围的下限。
  • upperBound方法:指定范围的上限。
  • bound方法:指定范围的上下限。
  • only方法:指定范围中只有一个值。

下面是一些代码实例:


// All keys ≤ x	
var r1 = IDBKeyRange.upperBound(x);

// All keys > y	
var r4 = IDBKeyRange.lowerBound(y, true);

// All keys ≥ x && ≤ y	
var r5 = IDBKeyRange.bound(x, y);

// All keys > x && ≤ y	
var r7 = IDBKeyRange.bound(x, y, true, false);

// The key = z	
var r9 = IDBKeyRange.only(z);

前三个方法(lowerBound、upperBound和bound)默认包括端点值,可以传入一个布尔值,修改这个属性。

生成Range对象以后,将它作为参数输入openCursor方法,就可以在所设定的范围内读取数据。

var t = db.transaction(["people"],"readonly");
var store = t.objectStore("people");
var index = store.index("name");

var range = IDBKeyRange.bound('B', 'D');

index.openCursor(range).onsuccess = function(e) {
  var cursor = e.target.result;
  if(cursor) {
      console.log(cursor.key + ":");
      for(var field in cursor.value) {
          console.log(cursor.value[field]);
      }
      cursor.continue();
  }
}

(以上”范围查询”内容搬运于阮一峰老师博客)

多个数据段的存储

一般数据库的表可以有多个字段,譬如id, createTime, value, operater

通过创建多个索引可以达到这个效果,但是索引是为了提高查询效率的,如果一个字段从来不会被作为查询条件,那么对其建立索引是没有意义的。

目前对于这种场景,我的做法是通过存储object类型的数据来做到,需要更改哪个字段,就去改这个object对应属性值,再把object作为一条记录整体更新。不知道是否还有其他做法。

封装

以上代码片段也看到了indexDB的api非常繁琐,操作起来不方便。

那么我们项目中如果有大量应用时,可以用一些封装好的库来提高效率,譬如:

localforage

参考