indexedDB

更新时间: 2021-06-07 15:01:25

# 什么是indexedDB

Indexed Database API简称IndexedDB,是浏览器中存储结构化数据的一个方案。IndexedDB用于代替目前已废弃的Web SQL Database APIIndexedDB背后的思想是创造一套API,方便JavaScript对象的存储和获取,同时也支持查询和搜索。

IndexedDB的设计几乎完全是异步的。为此大多数操作以请求的形式执行,这些请求会异步执行,产生成功的结果或错误。绝大多数IndexedDB操作要求添加onerroronsuccess事件处理程序来确定输出。

# 数据库

IndexedDB是类似于MySQLWeb SQL Database的数据库。于传统数据库最大的区别在于,IndexedDB使用对象存储而不是表格保存数据。IndexedDB数据库就是一个在公共命名空间下的一组对象存储,类似于NoSQL风格的实现。

  • 使用IndexedDB数据库的第一步是调用indexedDB.open()方法。并给它传入一个要打开的数据库名称

    如果给定名称的数据库已存在
    则会发送一个打开它的请求

    如果不存在
    则会发送创建并打开这个数据库的请求。这个方法会返回一个IDBRequest的实例,可以在这个实例上添加onerroronsuccess事件处理程序。

let db,
    request,
    version = 1

request = indexedDB.open("admin",version);
request.onerror = (event) => {
  alert(`Failed to open: ${event.target.errorCode}`);
}    
request.onsuccess = (event) => {
  db = event.target.result;
}
1
2
3
4
5
6
7
8
9
10
11

在两个事件处理程序中,event.target都指向request,因此使用哪个都可以。如果onsuccess事件处理程序被调用,说明可以通过event.target.result访问数据库(IDBDatabase)实例了,这个实例会保存到db变量中。之后,所有与数据库相关的操作都要通过db对象本身来进行。如果打开数据库期间发生错误,event.target.errorCode中就会存储表示问题的错误码。

注意

以前,IndexedDB使用setVersion()方法指定版本号。这个方法目前已废弃。如前所示,要在打开数据库的时候指定版本。这个版本号会被转换成一个unsigned long long数值,因此不要使用小数,而要使用整数。

# 对象存储

建立了数据库连接之后,下一步就是使用对象存储。如果数据库版本与期待的不一致,那可能需要创建对象存储。不过,在创建对象存储前,有必要想一想要存储什么类型的数据。 假设要存储包含用户名、密码等内容的用户记录。可以用如下对象来表示一条记录:

let user = {
  username:"007",
  firstName:"James",
  lastName:"Bond",
  password:"foo"
}
1
2
3
4
5
6

观察这个对象,可以很容易看出最适合作为对象存储键的username属性。用户名必须全局唯一,它也是大多数情况下访问数据的凭据。这个键很重要,因为创建对象存储时必须指定一个键。

数据库的版本决定了数据库模式,包括数据库中的对象存储和这些对象存储的结构。如果数据库还不存在,open()操作会创建一个新数据库,然后触发upgradeneeded事件。可以为这个事件设置处理程序,并在处理程序中创建数据库模式,如果数据库存在,而你制定了一个升级版的版本号,则会立即触发upgradeneeded事件,因而可以在事件处理程序中更新数据库模式。

request.onupgradeneeded = (event) => {
  const db = event.target.result;

  //如果存在则删除当前objectStore。测试的时候可以这样做
  //但是这样会在每次执行事件处理程序时删除已有数据
  if(db.objectStoreNames.contains("users")){
    db.deleteObjectStore("users")
  }

  db.createObjectStore("users",{keyPath:"username"})
}
1
2
3
4
5
6
7
8
9
10
11

这里第二个参数的keyPath属性表示应用该用作键的存储对象的属性名。

# 事务

创建了对象存储之后,剩下的所有操作都是通过事务完成的。事务要通过调用数据库对象的transaction()方法创建。任何时候,只要想读取或修改数据,都要通过事务把所有修改操作组织起来。最简单的情况下,可以像下面这样创建事务:

let transaction = db.transaction()
1

如果不指定参数,则对数据库中所有的对象存储只有读权限。更具体的方式是指定一个或多个要访问的对象存储的名称:

let transaction = db.transaction("users")
1

这样可以确保在事务期间只加载users对象存储的信息。如果想要访问多个对象存储,可以给第一个参数传入一个字符串数组:

let transaction = db.transaction(["users","anotherStore"])
1

如前所述,每个事务都以只读方式访问数据。要修改访问模式,可以传入第二个参数。这个参数应该是下列字符串之一:

  • "readOnly"
  • "readwrite"
  • "versionchange"
let transaction = db.transaction("users","readwrite")
1

这样事务就可以对users对象存储读写了。

有了事务的引用,就可以使用objectStore()方法并传入对象存储的名称以访问特定的对象存储。

  • 然后,可以使用add()put()方法添加和更新对象
  • 使用get()取得对象
  • 使用delete()删除对象
  • 使用clear()删除所有对象。

其中get()delete()方法都接收对象键作为参数,这5个方法都创建新的请求对象。

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.get("007");

request.onerror = (event) => alert("Did not get the object!")
request.onsuccess = (event) => alert(event.target.result.firstName)      
1
2
3
4
5
6

因为一个事务可以完成任意多个请求,所有事务对象本身也有事件处理程序:onerroroncomplete。这两个时间可以用来获取事务升级的状态信息:

transaction.onerror = (event) => {
  //整个事务被取消
}

transaction.oncomplete = (event) => {
  //整个事务成功完成
}
1
2
3
4
5
6
7

注意

不能通过oncomplete事件处理程序的event对象访问get()请求返回的任何数据。因此,仍然需要通过这些请求的onsuccess事件处理程序来获取数据。

# 插入对象

拿到了对象存储的引用后,就可以使用add()put()写入数据了。这两个方法都接收一个参数,即要存储的对象,并把对象保存到对象存储。

这两个方法只在对象存储中已存在同名键时有区别。这种情况下,add()会导致错误,而put()会简单地重写该对象。更简单的说,可以把add()想象成插入新值,而把put()想象为更新值。因此第一次初始化对象存储时可以这样做:

//users是一个用户数据的数组
for(let user of users){
  store.add(user)
}
1
2
3
4

每次调用add()put()都会创建对象存储的更新请求。如果想验证请求成功与否,可以把请求对象保存到一个变量,然后为它添加onerroronsuccess事件处理程序:

// users是一个用户数据的数组
let request,
    requests = []

for(let user of users){
  request = store.add(user)
  request.onerror = () =>{
    //处理错误
  }
  request.onsuccess = () =>{
    //处理成功
  }
  requests.push(request)
}    
1
2
3
4
5
6
7
8
9
10
11
12
13
14

创建并填充了数据后,就可以查询对象存储了。

# 通过游标查询

使用事务可以通过一个已知键取得一条记录。如果想取得多条数据,则需要在事务中创建一个游标。游标是一个指向结果集的指针。与传统数据库查询不同,游标不会事先收集所有结果。相反,游标指向第一个结果,并在接到指令前不会主动查找下一条数据。
需要在对象存储上调用openCursor()方法创建游标。与其他IndexedDB操作一样,openCursor()方法也返回一个请求,因此必须为它添加oonsuccessonerror事件处理程序。

const transaction = db.transaction("users")
      store = transaction.objectStore("users")
      request = store.openCursor()

request.onsuccess = (event) => {
  //处理成功
}
request.onerror = (event) => {
  //处理错误
}
1
2
3
4
5
6
7
8
9
10

在调用onsuccess事件处理程序时,可以通过event.target.result访问对象存储中的下一条记录,这个属性保存着IDBCursor的实例(有下一条记录时)或null(没有记录时)。这个IDBCursor实例有几个属性:

  • direction:字符串常量,表示游标的前进方向以及是否应该遍历所有重复的值。可能的值包括:
    • NEXT("next")
    • NEXTUNIQUE("nextunique")
    • PREV("prev")
    • PREVUNIQUE("prevunique")
  • key:对象的键
  • value:实际的对象
  • primaryKey:游标使用的键。可能是对象键或索引键
request.onsuccess = (event) =>{
  const cursor = event.target.result;
  if(cursor){ //永远要检查
    console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
  }
}
1
2
3
4
5
6

注意,这个例子中的cursor.value保存着实际的对象。正因为如此,在显示它之前才需要使用JSON来编码。

游标可用于更新个别记录。update()方法使用指定的对象更新当前游标对应的值。与其他类似操作一样,调用update()会创建一个新请求,因此如果想知道结果,需要为onsuccessonerror赋值:

request.onsuccess = (event) =>{
  const cursor = event.target.result;
  let value,updateRequest;

  if(cursor) { //永远要检查
    if(cursor.key == 'foo'){
      value = cursor.value; //取得当前的对象
      value.password = "magic!" //更新密码
      updateRequest = cursor.update(value); //请求保存更新后的对象

      updateRequest.onsuccess = () => {
        //处理成功
      }
      updateRequest.onerror = () => {
        //处理错误
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

也可以调用delete()来删除右边位置的记录,与update()一样,这也会创建一个请求:

request.onsuccess = (event) => {
  const cursor = event.target.result
  let value,
      deleteRequest
  if(cursor){
    if(cursor.key == "foo"){
      deleteRequest = cursor.delete() //请求删除对象
      deleteRequest.onsuccess = () => {
        //处理成功
      }
      deleteRequest.onerror = () => {
        //处理错误
      }
    }
  }    
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果事务没有修改对象存储的权限,update()delete()都会抛出错误。

默认情况下,每个游标只会创建一个请求。要创建另一个请求,必须调用下列中的一个方法:

  • continue(key):移动到结果集中的下一条记录。参数key是可选的。如果没有指定key,游标就会移动到下一条记录;如果指定了,则游标移动到指定的键。
  • advance(count):游标向前移动指定的count条记录。

这两个方法都会让游标重用相同的请求,因此也会重用onsuccessonerror处理程序,直至不再需要。例如,下面代码迭代了一个对象存储中的所有记录:





 
 
 



request.onsuccess = (evnet) => {
  const cursor = event.target.result
  if(cursor){
    console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
    cursor.continue() //移动到下一条记录
  }else{
    console.log("Done!")
  }
}
1
2
3
4
5
6
7
8
9

# 键范围

使用游标会给人一种不太理想的感觉,因为获取数据的方式收到了限制。使用键范围(key range)可以让游标更容易管理。键范围对应IDBKeyRange的实例。有四种方式指定键范围:

  1. 第一种是使用only()方法并传入想要获取的键:
const onlyRange = IDBKeyRange.only("007")
1

这个范围保证只获取键为"007"的值。使用这个范围创建的游标类似于直接访问对象存储并调用get("007")

  1. 第二种范围可以定义结果集的下限。下限表示游标开始的位置。例如,下面的键范围保证游标从"007"这个键开始,直到最后:
//从“007”记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound("007")
1
2

如果想从"007"后面的记录开始,可以再传入第二个参数true:

//从“007”的下一条记录开始,直到最后
const lowerRange = IDBKeyRange.lowerBound("007",true);
1
2
  1. 第三针范围可以定义结果集的上限,通过调用upperBound()方法可以指定游标不会越过的记录。下面的键范围保证游标从头开始并在到达键为"ace"的记录停止:
//从头开始,到“ace”记录为止
const upperRange = IDBKeyRange.upperBound("ace");
1
2

如果不想包含指定的键,可以在第二个参数传入true:

const upperRange = IDBKeyRange.upperBound("ace",true);
1
  1. 要同时指定上限和下限,可以使用bound()方法。这个方法接收四个参数:
  • 下限的键
  • 上限的键
  • 可选的布尔值表示是否跳过下限
  • 可选的布尔值表示是否跳过上限
//从“007”记录开始,到“ace”记录停止
const boundRange = IDBKeyRange.bound("007","ace");
//从“007”的下一条记录开始,到“ace”记录停止
const boundRange = IDBKeyRange.bound("007","ace", true);
//从“007”的下一条记录开始,到“ace”的前一条记录停止
const boundRange = IDBKeyRange.bound("007","ace",true,true);
//从“007”记录开始,到“ace”的前一条记录停止
const boundRange = IDBKeyRange.bound("007","ace",false,true); 
1
2
3
4
5
6
7
8

定义了范围之后,把它传给openCursor()方法,就可以得到位于该范围内的游标:

const store =  db.transaction("users").objectStore("users"),
      range = IDBKeyRange.bound("007","ace"),
      request = store.openCursor(range);
request.onsuccess = function(event) {
  const cursor = event.target.result;
  if(cursor){
    console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
    cursor.continue() //移动到下一条记录
  }else{
    console.log("Done!")
  }
}      
1
2
3
4
5
6
7
8
9
10
11
12

# 设置游标方向

openCursor()方法实际上可以接受两个参数:

  • IDBKeyRange的实例
  • 表示方向的字符串

通常,游标都是从对象存储的第一条记录开始的,每次调用continue()advance()都会向最后一条记录前进。这样的游标其默认方向为“next”.

如果对象存储中有重复的记录,可能需要游标跳过那些重复的项。为此,可以给openCursor()的第二个参数传入“nextunique”:

cosnt transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.openCursor(null,"nextunique")
1
2
3

注意

openCursor()的第一个参数是null,表示默认的键范围是所有值。

此游标会遍历对象存储中的记录,从第一条记录开始迭代,到最后一条记录,但会跳过重复的记录。

另外,也可以创建在对象存储中反向移动的游标,从最后一项开始向第一项移动。此时需要给openCursor()传入“prev"或"prevunique"作为第二个参数(后者的意思是避免重复)。

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.openCursor(null,"prevunique")
1
2
3

在使用"prev"或"prevunique"打开游标时,每次调用continue()advance()都会在对象存储中反向移动游标。

# 索引

对某些数据集,可能需要为对象存储指定多个键。例如,如果同时记录了用户ID和用户名,那可能需要通过任何一种方式来获取用户数据。为此,可以考虑将用户ID作为主键,然后再用户名上创建索引。

要创建新索引,首先要取得对象存储的引用,然后像下面的例子一样调用createIndex():

const tranaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.createIndex("username","username",{unique,true})
1
2
3

createIndex()的**第一个参数是索引的名称,第二个参数是索引属性的名称,第三个参数是包含键uniqueoptions对象。**这个选项中的unique应该必须指定,表示这个键是否在所有记录中唯一。因为username可能不会重复,所以这个键是唯一的。

createIndex()返回的是IDBIndex实例。在对象存储上调用index()方法也可以得到同一个实例。

例如,要使用一个已存在的名为"username"的索引:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username")
1
2
3

索引非常像对象存储。可以在索引上使用openCursor()方法创建新游标,这个游标与在对象存储上调用openCursor()创建的游标完全一样。只是其result.key属性中保存的是索引键,而不是主键。

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username"),
      request = index.openCursor();
request.onsuccess = (event) => {
  //处理成功
}      
1
2
3
4
5
6
7

使用openKeyCursor()方法也可以在索引上创建特殊游标,只返回每条记录的主键。这个方法接收的参与openCursor()一样。最大的不同在于,event.result.key是索引键,且event.result.value是主键而不是整个记录。

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username"),
      request = index.openKeyCursor();
request.onsuccess = (event) => {
  //处理成功
  //event.result.key是索引键,event.result.value是主键
}      
1
2
3
4
5
6
7
8

可以使用get()方法并传入索引键通过索引取得单条记录,这会创建一个新请求:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username"),
      request = index.get("007");
request.onsuccess = (event) => {
  //处理成功
}
request.onerror = (event) => {
  //处理错误
}      
1
2
3
4
5
6
7
8
9
10

如果想只给取得给定索引的主键,可以使用getKey()方法。这样也会创建一个新请求,但result.value等于主键而不是整个记录:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username"),
      request = index.getKey("007");
request.onsuccess = (event) => {
  //处理成功
  //event.target.result.key是索引键,event.target.result.value是主键
}      
1
2
3
4
5
6
7
8

在这个onsuccess事件处理程序中,event.target.result.value中应该是用户ID。

任何时候,都可以使用IDBIndex对象的下列属性取得索引的相关信息

  • name:索引的名称。
  • keyPath:调用createIndex()时传入的属性路径。
  • objectStore:表示索引对应的对象存储
  • unique:表示索引键是否唯一的布尔值

对象存储自身也有一个indexNames属性,保存着与之相关索引的名称。使用如下代码可以方便地了解对象存储上已存在哪些索引:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      indexNames = store.indexNames

for(let indexName in indexNames) {
  const index = store.index(indexName)
  console.log(`Index name : ${index.name}  KeyPath:${index.keyPath}  Unique:${index.unique}`)
}
1
2
3
4
5
6
7
8

以上代码迭代了每个索引并在控制台中输出了他们的信息。

在对象存储上调用deleteIndex()方法并传入索引的名称可以删除索引:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      store.deleteIndex("username")
1
2
3

因为删除索引不会影响对象存储中的数据,所以这个操作没有回调。

# 并发问题

IndexedDB虽然是网页中的异步API,但仍存在并发问题。如果两个不同的浏览器标签页同时打开了同一个网页,则有可能出现一个网页尝试升级数据库而另一个尚未就绪的情形。有问题的操作是设置数据为新版本,而版本变化只能在浏览器只有一个标签页使用数据库时才能完成。

第一次打开数据库时,添加onversionchange事件处理程序非常重要。另一个同源标签页将数据库打开到新版本时,将执行此回调。对这个事件最好的回应是立即关闭数据库,以便完成版本升级。

let request,database

request = indexedDB.open("admin",1)
request.onsuccess = (event) => {
  database = event.target.result
  database.onversionchange = () => database.close()
}
1
2
3
4
5
6
7

应该在每次成功打开数据库后都指定onversonchange事件处理程序。记住onversionchange有可能会被其他标签页触发。

# 如何使用

所以要怎么使用呢?

由于 IndexedDB 本身的规范还在持续演进中,当前的 IndexedDB 的实现还是使用浏览器前缀。在规范更加稳定之前,浏览器厂商对于标准 IndexedDB API 可能都会有不同的实现。但是一旦大家对规范达成共识的话,厂商就会不带前缀标记地进行实现。实际上一些实现已经移除了浏览器前缀:IE 10,Firefox 16 和 Chrome 24。当使用前缀的时候,基于 Gecko 内核的浏览器使用 moz 前缀,基于 WebKit 内核的浏览器会使用 webkit 前缀。

如果你希望在仍旧使用前缀的浏览器中测试你的代码, 可以使用下列代码:

// 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*)
1
2
3
4
5
6
7

要注意的是使用前缀的实现可能会有问题,或者是实现的并不完整,也可能遵循的还是旧版的规范。因此不建议在生产环境中使用。我们更倾向于明确的不支持某一浏览器,而不是声称支持但是实际运行中却出问题:

if (!window.indexedDB) {
    window.alert("Your browser doesn't support a stable version of IndexedDB. Such and such feature will not be available.")
}
1
2
3

基本使用

// 我们的客户数据看起来像这样。
const customerData = [
  { ssn: "444-44-4444", name: "Bill", age: 35, email: "bill@company.com" },
  { ssn: "555-55-5555", name: "Donna", age: 32, email: "donna@home.org" }
];

const dbName = "the_name";

var request = indexedDB.open(dbName, 2);

request.onerror = function(event) {
  // 错误处理
};
request.onupgradeneeded = function(event) {
  var db = event.target.result;

  // 建立一个对象仓库来存储我们客户的相关信息,我们选择 ssn 作为键路径(key path)
  // 因为 ssn 可以保证是不重复的
  var objectStore = db.createObjectStore("customers", { keyPath: "ssn" });

  // 建立一个索引来通过姓名来搜索客户。名字可能会重复,所以我们不能使用 unique 索引
  objectStore.createIndex("name", "name", { unique: false });

  // 使用邮箱建立索引,我们向确保客户的邮箱不会重复,所以我们使用 unique 索引
  objectStore.createIndex("email", "email", { unique: true });

  // 使用事务的 oncomplete 事件确保在插入数据前对象仓库已经创建完毕
  objectStore.transaction.oncomplete = function(event) {
    // 将数据保存到新创建的对象仓库
    var customerObjectStore = db.transaction("customers", "readwrite").objectStore("customers");
    customerData.forEach(function(customer) {
      customerObjectStore.add(customer);
    });
  };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35