IndexedDB - 使用教学

2,186 阅读10分钟

这是我参与更文挑战的第1天,活动详情查看: 更文挑战

前言

IndexedDB 是网页浏览器的一个数据库,它的存储空间比 localStorage 大得多,有时候我们实现某些功能(如聊天)时需要存储大量的数据,这个时候就可以用上 IndexedDB。
除了存储大量数据特点外,IndexedDB 还有这几个特点,它的数据结构是键值类型,除了主键是必须存在外,每个记录的其他的键值可以随意定义;它的所有操作都是异步。

下面将逐一介绍如何使用 IndexedDB 和解决某些场景问题,以聊天场景为例。

初始化数据库

假设有一个 db.js,在里面创建一个 DB 类,封装 init 方法,init 方法里面先用 window.indexedDB.open 打开对应的数据库(若没有,则自动创建),这个时候会返回一个对象,这个对象主要有以下三个回调 api:
1、onsuccess,数据库连接成功时回调
2、onupgradeneeded,数据库版本升级时回调
3、onerror,数据库连接失败时回调

使用如下:

class DB {
  constructor() {
    this.db = null // 内部的数据库对象
    this.tb_chat = 'chat' // 这个是表名
    this.version = 1 // 版本号
  }
  // 初始化
  init() {
    return new Promise((resolve, reject) => {
      var that = this
    
      // 打开数据库 db_chat,指定数据库版本号为 1(如果没有指定,那么默认为1)
      var request = window.indexedDB.open('db_chat', this.version)
      
      // 成功打开数据库时回调
      request.onsuccess = () => {
        this.db = request.result
        resolve()
      }
      
      // 连接数据库时,版本号大于现有版本号或第一次创建数据库时也会回调,进行回调,可以利用版本号来更改数据结构
      request.onupgradeneeded = function (event) {
        this.db = event.target.result
        var objectStore
        // 如果没有聊天 chat 表(对象仓库),则创建 chat
        if (!this.db.objectStoreNames.contains(that.tb_chat)) {
          objectStore = this.db.createObjectStore(that.tb_chat, { keyPath: 'id' }) // 创建表 chat,并指定 id 为主键
          objectStore.createIndex('id', 'id', { unique: true }) // 创建 id 索引,具有唯一性
          objectStore.createIndex('uid', 'uid', { unique: false }) // 创建 发送者id 索引
          objectStore.createIndex('uid2', 'uid2', { unique: false }) // 创建 接收者id 索引
          objectStore.createIndex('sendTime', 'sendTime', { unique: true }) // 发送时间 索引
          objectStore.createIndex('uid_uid2', ['uid', 'uid2'], { unique: false }) // 发送者—接收者 索引
        }
      }
      // 打开数据库失败时回调
      request.onerror = (error) => {
        reject(error)
      }
    })
  }
}

注意一点,IndexedDB 的所有相关操作都是异步的,因此上面的 init 方法用了 promise,可以让一些需要在数据库初始化后立刻操作的代码写到 promise 的 then 里面去。

为了方便使用,这里直接导出 DB 类的对象。

class DB {
    ...
}
let db = new DB()
export default db

需要用到的地方,可以这样调用

// 导入上面的 db.js,路径写为自己的
import db from db.js

db.init().then(() => {
    // 操作
    db.add({id: 1, content: 'hello'}) // 结合下面章节的新增代码
})

如果需要设置主键为自增,则可以用以下两种方式设置,但是记得,设置自增后,新建数据时,主键的值只能由 IndexdDB 自身控制,不能传入自定义值,否则会报错。

  1. 主键有 keyPath,新增数据时,会自动给数据添加 id 属性
objectStore = this.db.createObjectStore(that.tb_chat, { keyPath: 'id', autoIncrement: true }) // 在创建表时,添加 autoIncrement
  1. 主键没有 keyPath,主键将只有值的概念,不会给数据添加主键信息
objectStore = this.db.createObjectStore(that.tb_chat, { autoIncrement: true }) // 在创建表时,添加 autoIncrement

设置自增 id 后,应该这样调用

import db from db.js

db.init().then(() => {
    // 操作
    // db.add({id: 1, content: 'hello'}) // 若设置 id 为自增主键后,不能这样调用,会报错
    db.add({content: 'hello'})
    // 如果主键的 keyPath 是 id,那么入库数据将会是 { id: 1, content: 'hello'}
    // 如果主键没有 keyPath,那么入库数据将会是 { content: 'hello' }
})

新增操作

在 IndexedDB 里,增删改查都要通过事务来进行操作,所谓的事务就是,如果中途有一个操作出现了异常,那么之前的操作都会进行回滚,数据回到操作前的样子。

新增数据前,先创建一个事务(transaction),事务要指定需要操作的表名(数组)和处理方式(只读或读写),创建好事务对象后,用事务对象调用 objectStore(表名).add(json对象)即可以新增数据。

class DB {
  ...
  
  // 新增数据(返回主键)
  add(tb, data) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb], 'readwrite').objectStore(tb).add(data)
      request.onsuccess = (event) => {
        resolve(event.target.result)
      }
      request.onerror = (event) => {
        reject(event)
      }
    })
  }
}

调用

db.add(db.tb_chat, {id:1, uid:1, uid2:2, sendTime: 1622539418861, content:'Hello World'})

删除操作

根据主键删除数据,和新增代码差不多。

class DB {
  ...
  
  // 根据主键删除数据
  delete(tb, key) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb], 'readwrite')
        .objectStore(tb)
        .delete(key)

      request.onsuccess = (event) => {
        resolve(event)
      }

      request.onerror = (error) => {
        reject(error)
      }
    })
  }
}

调用

// 删除主键为 1 的数据
db.delete(db.tb_chat, 1)

修改操作

根据主键修改数据(貌似修改数据是只能通过主键修改,不能通过其他条件修改)

class DB {
  ...
  // 根据主键修改数据
  update(tb, data) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb], 'readwrite')
        .objectStore(tb)
        .put(data)

      request.onsuccess = (event) => {
        resolve(event)
      }

      request.onerror = (error) => {
        reject(error)
      }
    })
  }
}

调用

// 修改主键为 1 的数据,修改内容为 hi
db.delete(db.tb_chat, {id:1, uid:1, uid2:2, sendTime: 1622539418861, content:'hi'})

查询操作

查询是数据库操作的重点难点,通常涉及到查询的场景千姿百态。
在初始化操作里,你可能注意到在 onupgradeneeded 回调方法里,在创建表时,同时也创建了几个索引,这是因为在 IndexedDB 里,任何的查询条件必须是索引或主键,比如我要查询“‘发送时间’等于2021年6月1日”的聊天记录,如果没有创建“发送时间”的索引,则无法查询。

主键查询和索引查询

下面贴出主键查询和索引查询的代码。

class DB {
  ...
  
  // 根据主键查询数据
  selectById(tb, key) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb])
        .objectStore(tb)
        .get(key)

      request.onsuccess = (event) => {
        if (request.result) {
          resolve(request.result)
        } else {
          resolve()
        }
      }

      request.onerror = (error) => {
        reject(error)
      }
    })
  }
  // 根据索引查询
  select(tb, index, content) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb])
        .objectStore(tb)
        .index(index)
        .get(content)

      request.onsuccess = (event) => {
        if (request.result) {
          resolve(request.result)
        } else {
          resolve()
        }
      }

      request.onerror = (error) => {
        reject(error)
      }
    })
  }
}

可是,有时候我们会遇到需要多个索引作为条件进行查询,这个时候我们可以建立一个由多个索引组合起来的组合索引。再回头看看 onupgradeneeded 方法,是不是有一个由发送者 id 和接收者 id 组合得来的“发送者-接收者”组合索引,这个是为查询“发送者-接受者”的聊天记录做准备的。

objectStore.createIndex('uid_uid2', ['uid', 'uid2'], { unique: false }) // 发送者—接收者 索引

不过,我们很快就发现,没有这么简单,如果我们这样调用。

// 查询 id 为 1 的发送者 与 id 为 2 的接收者的聊天记录
db.select(db.tb_chat, 'uid_uid2', [1, 2]).then(res => {
    console.log(res)
})

即使聊天记录有多条,我们会发现只有一条记录查询出来,很明显无法符合我们的业务需求,因此我们需要用到游标,记得需要查询多条记录出来的场景就要用到游标。这个时候我们再封装一个游标查询。

游标查询

什么叫游标,游标就是指向数据集合的特定行的一个指针,我们可以通过游标来对查询的结果集进行遍历并将数据放到数组里,把数组 return 出去。

// 根据索引游标查询
selectList(tb, index, content) {
    return new Promise((resolve, reject) => {
      var request = this.db.transaction([tb]).objectStore(tb).index(index)
      // 创建游标
      var c = request.openCursor(IDBKeyRange.only(content))
      var arr = []

      c.onsuccess = (event) => {
        var cursor = event.target.result
        if (cursor) {
          arr.push(cursor.value)
          cursor.continue()
        } else {
          resolve(arr)
        }
      }

      c.onerror = (error) => {
        reject(error)
      }
    })
}

调用

// 查询 id 为 1 的发送者 与 id 为 2 的接收者的聊天记录
db.selectList(db.tb_chat, 'uid_uid2', [1, 2]).then(res => {
    console.log(res)
})

游标查询还可以控制查询条件范围,比如,查询“2019年~2021年”的聊天记录。只要使用好关键代码 IDBKeyRange 的方法,还可以做其他炫酷的查询,在这里就不一一详述查询方法。

修改数据表结构

有时候,我们需要修改数据表结构,添加、删除、更改数据表或索引,这个时候 window.indexedDB.open 和 onupgradeneeded 方法尤其重要。

假设我们新增一个索引“发送者昵称”:nickname。

首先,我们先要修改数据库的版本号,比以前的版本号大即可。

class DB {
  constructor() {
    this.db = null // 内部的数据库对象
    this.tb_chat = 'chat' // 这个是表名
    //this.version = 1 // 旧版本号
    this.version = 2 // 新版本号
  }
  ...
}

然后再修改 onupgradeneeded 方法

request.onupgradeneeded = function (event) {
    this.db = event.target.result
    var objectStore
    // 如果没有聊天 chat 表(对象仓库),则创建 chat
    if (!this.db.objectStoreNames.contains(that.tb_chat)) {
      objectStore = this.db.createObjectStore(that.tb_chat, { keyPath: 'id' }) // 创建表 chat,并指定 id 为主键
      objectStore.createIndex('id', 'id', { unique: true }) // 创建 id 索引,具有唯一性
      objectStore.createIndex('uid', 'uid', { unique: false }) // 创建 发送者id 索引
      objectStore.createIndex('uid2', 'uid2', { unique: false }) // 创建 接收者id 索引
      objectStore.createIndex('sendTime', 'sendTime', { unique: true }) // 发送时间 索引
      objectStore.createIndex('uid_uid2', ['uid', 'uid2'], { unique: false }) // 发送者—接收者 索引
    
      objectStore.createIndex('nickname', 'nickname', { unique: false }) // 发送者昵称 索引
    }
}

重新 init 数据库后,我们按F12,打开控制台的 application,可以发现 IndexedDB 的聊天表并没有 nickname 索引。原来 IndexedDB 的表是一旦创建好就无法修改其结构了。 我们只好弃用旧表,创建新表。在这里,我们只需要更改构造函数的表名即可(这就是为什么要特别用一个变量来存储表名的原因)。

class DB {
  constructor() {
    this.db = null // 内部的数据库对象
    //this.tb_chat = 'chat' // 这个是旧表名
    this.tb_chat = 'new_chat' // 新表名
    //this.version = 1 // 旧版本号
    this.version = 3 // 新版本号,版本号改为3,因为上面已经 init 过 1 次了
  }
  ...
}

这里可能还会涉及到一个问题,就是旧表数据如有用处,则需要转移到新表里。因此,在设计 IndexedDB 的表时,一定要考虑周到。

其他

如果嫌原生代码封装麻烦,可以直接使用 dexie 库。

完整代码

db.js

class DB {
      constructor() {
        this.db = null // 内部的数据库对象
        this.tb_chat = 'chat' // 这个是表名
        this.version = 1 // 版本号
      }
      // 初始化
      init() {
        return new Promise((resolve, reject) => {
          var that = this

          // 打开数据库 db_chat,指定数据库版本号为 1(如果没有指定,那么默认为1)
          var request = window.indexedDB.open('db_chat', this.version)

          // 成功打开数据库时回调
          request.onsuccess = () => {
            this.db = request.result
            resolve()
          }

          // 连接数据库时,版本号大于现有版本号或第一次创建数据库时也会回调,进行回调,可以利用版本号来更改数据结构
          request.onupgradeneeded = function (event) {
            this.db = event.target.result
            var objectStore
            // 如果没有聊天 chat 表(对象仓库),则创建 chat
            if (!this.db.objectStoreNames.contains(that.tb_chat)) {
              objectStore = this.db.createObjectStore(that.tb_chat, {
                keyPath: 'id'
              }) // 创建表 chat,并指定 id 为主键
              objectStore.createIndex('id', 'id', {
                unique: true
              }) // 创建 id 索引,具有唯一性
              objectStore.createIndex('uid', 'uid', {
                unique: false
              }) // 创建 发送者id 索引
              objectStore.createIndex('uid2', 'uid2', {
                unique: false
              }) // 创建 接收者id 索引
              objectStore.createIndex('sendTime', 'sendTime', {
                unique: true
              }) // 发送时间 索引
              objectStore.createIndex('uid_uid2', ['uid', 'uid2'], {
                unique: false
              }) // 发送者—接收者 索引
            }
          }
          // 打开数据库失败时回调
          request.onerror = (error) => {
            reject(error)
          }
        })
      }

      // 新增数据(返回主键)
      add(tb, data) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb], 'readwrite').objectStore(tb).add(data)
          request.onsuccess = (event) => {
            resolve(event.target.result)
          }
          request.onerror = (event) => {
            reject(event)
          }
        })
      }

      // 根据主键删除数据
      delete(tb, key) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb], 'readwrite')
            .objectStore(tb)
            .delete(key)

          request.onsuccess = (event) => {
            resolve(event)
          }

          request.onerror = (error) => {
            reject(error)
          }
        })
      }

      // 根据主键修改数据
      update(tb, data) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb], 'readwrite')
            .objectStore(tb)
            .put(data)

          request.onsuccess = (event) => {
            resolve(event)
          }

          request.onerror = (error) => {
            reject(error)
          }
        })
      }

      // 根据主键查询数据
      selectById(tb, key) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb])
            .objectStore(tb)
            .get(key)

          request.onsuccess = (event) => {
            if (request.result) {
              resolve(request.result)
            } else {
              resolve()
            }
          }

          request.onerror = (error) => {
            reject(error)
          }
        })
      }
      // 根据索引查询
      select(tb, index, content) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb])
            .objectStore(tb)
            .index(index)
            .get(content)

          request.onsuccess = (event) => {
            if (request.result) {
              resolve(request.result)
            } else {
              resolve()
            }
          }

          request.onerror = (error) => {
            reject(error)
          }
        })
      }
      
      // 根据索引查询列表
      selectList(tb, index, content) {
        return new Promise((resolve, reject) => {
          var request = this.db.transaction([tb]).objectStore(tb).index(index)
          // 创建游标
          var c = request.openCursor(IDBKeyRange.only(content))
          var arr = []

          c.onsuccess = (event) => {
            var cursor = event.target.result
            if (cursor) {
              arr.push(cursor.value)
              cursor.continue()
            } else {
              resolve(arr)
            }
          }

          c.onerror = (error) => {
            reject(error)
          }
        })
      }
}

let db = new DB()
export default db

调用

import db from db.js

db.init().then(() => {
    db.add(db.tb_chat, {id:1, uid:1, uid2:2, sendTime: 1622539418861, content:'hi'})
    db.selectList(db.tb_chat, 'uid_uid2', [1, 2]).then(res => {
        console.log(res)
    })
    db.delete(db.tb_chat, 1)
})