一行代码,搞定浏览器数据库 IndexedDB

前言

2021 年,若是你的前端应用,须要在浏览器上保存数据,有三个主流方案能够选择:javascript

  • Cookie:上古时代就已存在,但能应用的业务场景比较有限
  • LocalStorage:使用简单灵活,可是容量只有 10Mb,且储存 JS 对象存在问题
  • IndexedDB:算得上真正意义上的数据库,功能强大,但坑异常多,使用麻烦,古老的 API 设计放在现代前端工程中总有种格格不入的感受

关于这三者的区别与应用场景,能够参考我写的 深刻浅出前端本地储存前端

我在大三的时候,曾经用 IndexedDB 写过一个背单词 App,当时就有把 IndexedDB 封装一遍的想法,可是因为学业紧张,后来就搁置了vue

最近,我终于有了空闲时间,因而捡起了当年的想法,开始尝试用 TypeScriptIndexedDB 封装一遍,把坑一个个填上,作成一个开发者友好的库,并开源出来,上传至 npmjava

拍脑壳后,我决定把这个项目命名为 GoDB.jsreact

GoDB.js

GoDB 的出现,让你即便你不了解浏览器数据库 IndexedDB,也能把它用的行云流水,从而把关注点放到业务上面去webpack

毕竟要用好 IndexedDB,你须要翻无数遍 MDN,而 GoDB 替你吃透了 MDN,从而让你把 IndexedDB 用的更好的同时,操做还更简单了git

当前项目处于 Alpha 阶段(版本 0.4.x),意味着以后随时可能会有 breaking changes,在正式版(1.0.0 及之后)发布以前,不要把这个项目用到任何严肃的场景下github

项目GitHub: github.com/chenstarx/G…web

若是以为不错的话就点个 Star 吧~chrome

项目完整文档与官网正在紧张开发中,现阶段能够经过下面的 demo 来尝鲜

安装

首先须要安装,这里默认你使用了 webpack、gulp 等打包工具,或在 vue、react 等项目中

npm install godb
复制代码

在第一个正式版发布后,还会提供 CDN 的引入方式,敬请期待~

简单上手

操做很是简单,增、删、改、查各只须要一行代码:

import GoDB from 'godb';

const testDB = new GoDB('testDB');
const user = testDB.table('user');

const data = { name: 'luke', age: 22 };

user.add(data) // 增
  .then(luke => user.get(luke.id)) // 查
  .then(luke => user.put({ ...luke, age: 23 })) // 改
  .then(luke => user.delete(luke.id)); // 删
复制代码
  • Table.get()Table.add()Table.put() 都返回完整数据
  • Table.delete() 不返回数据(返回 undefined

须要注意的就是,put(obj) 方法中的 obj 须要包含 id,不然就等价于 add(obj)

上面的 demo 中,get() 获得的 luke 对象包含 id,所以是修改操做

以后会引入一个 update 方法来改进这个问题

也能够一次性添加多条数据

const data = [
    { name: 'luke', age: 22 },
    { name: 'elaine', age: 23 }
];

user.addMany(data)
  .then(() => user.consoleTable());
复制代码

Table.consoleTable()

这里用了一个 Table.consoleTable() 的方法,它会在浏览器的控制台打印出下面的内容:

image-20210116212924230

这里的 (index) 就是 id

虽然 chrome 开发者工具内就能看到表内全部数据,但这个方法好处是能够在须要的时候打印出数据,方便 debug

注意:这个方法是异步的,由于须要在数据库里把数据库取出来;异步意味着紧接在它后面的代码,可能会在打印出结果以前执行,若是不但愿出现这种状况,使用 awaitPromise.then 便可

addMany(data) 方法:

  • 严格按照 data 的顺序添加
  • 返回 id 的数组,与 data 顺序一致

之因此单独写个 addMany,而不在 add 里加一个判断数组的逻辑,是由于用户想要的,可能就是添加一个数组到数据库中

注意:addManyadd 不要同步调用,若是在 addMany 正在执行时调用 add,可能会致使数据库里的顺序不符合预期,请在 addMany 的回调完成后再调用 add(将来可能会引入一个队列来修复这个问题)

Table.find()

若是你想在数据库中查找数据,还可使用 Table.find() 方法:

const data = [
    { name: 'luke', age: 22 },
    { name: 'elaine', age: 23 }
];

user.addMany(data)
  .then(() => {
    user.find((item) => {
      return item.age > 22;
    }).then((data) => {
      console.log(data)
    })
    // { name: 'elaine', age: 23 }
  });
复制代码

Table.find(fn) 接受一个函数 fn 做为参数,这个函数的返回值应当为 truefalse

用法其实和 JS 数组方法 Array.find() 一模一样

这个方法在内部会从头遍历整个表(使用 IndexedDB 的 Cursor),而后把每一次的结果放进 fn 执行,若是 fn 的返回值为 true(内部使用 if(fn()) 判断),就返回当前的结果,中止遍历

这个方法只会返回第一个知足条件的值,若是须要返回全部知足条件的值,请使用 Table.findAll(),用法与 Table.find() 一致,可是会返回一个数组,包含全部知足条件的值

Schema

若是你但愿数据库的结构更严格一点,也能够添加 schema

GoDB 会根据 schema 创建 IndexedDB 数据库索引,给字段添加特性

import GoDB from 'godb';

// 定义数据库结构
const schema = {
    // user 表:
    user: {
        // user 表的字段:
        name: {
            type: String,
            unique: true // 指定 name 字段在表里惟一
        },
        age: Number
    }
}

const testDB = new GoDB('testDB', schema);
const user = testDB.table('user');

const data = {
    name: 'luke'
    age: 22
};

user.add(data) // 没问题
  .then(() => user.get({ name: 'luke' })) // 定义schema后,就能够用id之外的字段获取数据
  .then(luke => user.add(luke)) // 报错,name 重复了
复制代码

如上面的例子,指定了 schema

  • 定义了 schema,所以 get()delete() 中可使用 id 之外的字段搜索了,不然只能传入 id
  • 指定了 user.name 这一项是惟一的,所以没法添加剧复的 name

固然,你也能够在 table 那定义 schema

const testDB = new GoDB('testDB');
const user = testDB.table('user', {
    name: {
        type: String,
        unique: true
    },
    age: Number
});
复制代码

但这种方式的缺点是,若是定义 table 发生在链接数据库以后,GoDB 会先发起一个 IDBVersionChange 的事件,致使 IndexedDB 数据库版本升级,此时若是有不止一个 GoDB 实例链接了一样的数据库,版本升级将会被 block,致使建表失败

要避免这个问题却是很简单,把全部获取 table 的操做紧接在 new GoDB() 以后(保证这两操做是同步而非异步执行的)就能够,这样能够确保全部 table 都在链接完成以前获取到(JS 的事件循环特性)

固然,最佳实践仍是在链接数据库时就定义好全部的 schema,这样 GoDB 会在应用初始化时就创建好全部的数据库和表,并创建字段的索引

Table.get() 与 Table.find() 区别

get() 使用数据库索引搜索,性能更高,可是须要定义 schema,才能使用 id 之外的索引进行搜索

find() 利用函数判断遍历全表,使用上更灵活,可是性能相对没有 get()

关于 schema:

部分同窗或许会发现,上面定义 schema 的方式有点眼熟,没错,正是参考了 mongoose

  • 定义数据库的字段时,能够只指明数据类型,如上面的 age: Number
  • 也可使用一个对象,里面除了定义数据类型 type,也指明这个字段是否是惟一的(unique: true),以后会添加更多可选属性,如用来指定字段默认值的 default,和指向别的表的索引 ref

不定义 schema 时,GoDB 使用起来就像 MongoDB 同样,能够灵活添加数据;区别是 Mongodb 中,每条数据的惟一标识符是 _id,而 GoDBid

虽然这样作的问题是,用户使用不规范的话(如每次添加的数据结构都不同),长此以往可能会使得数据库的字段特别多,维护和使用起来很是麻烦

定义 schema 后,你将没法给 schema 内没有的字段添加数据(在写文档的时候尚未实现这个功能,即便 schema 不符合也能加,下个版本会安排上)

所以推荐在项目中,首先定义好 schema,这样不论是维护性上,仍是性能上,都要更胜一筹

使用 await

因为 GoDB 的 API 都是 Promise 的,所以在不少场景下可使用 await,使代码更简洁,同时拓宽使用场景(await 能够很方便用在循环内,而 Promise.then 很难)

import GoDB from 'godb';

const db = new GoDB('testDB', schema);
const user = db.table('user', {
    name: {
      type: String,
      unique: true
    },
    age: Number
});

crud();

async function crud() {
  // 增:
  await user.addMany([
    { name: 'luke', age: 22 },
    { name: 'elaine', age: 23 }
  ]);

  console.log('add user: luke');
  await user.consoleTable(); // await 非必须,这里为了防止打印顺序出错

  // 查:
  const luke = await user.get({ name: 'luke' });

  // 改:
  luke.age = 23;
  await user.put(luke);

  console.log('update: set luke.age to 23');
  await user.consoleTable();

  // 删:
  await user.delete({ name: 'luke' });

  console.log('delete user: luke');
  await user.consoleTable();

}

复制代码

上面这段 demo,会在控制台打印出下面的内容:

crud-test

API 设计

由于「链接数据库」和「链接表」这两个操做是异步的,在设计之初,曾经有两个 API 方案,区别在于:要不要把这两个操做,作为异步 API 提供给用户

这里讨论的不是「API 如何命名」这样的细节,而是「API 的使用方式」,由于这会直接影响到用户使用 GoDB 时的业务代码编写方式

以链接数据库 -> 添加一条数据的过程为例

设计一:提供异步特性

GitHub 上大多数开源的 IndexedDB 封装库都是这么作的

import GoDB from 'godb';

// 链接数据库是异步的
GoDB.open('testDB')
    .then(testDB => testDB.table('user')) // 链接表也须要异步
    .then(user => {
        user.add({
            name: 'luke',
            age: 22
        });
    });
});
复制代码

这样的优势是,工做流程一目了然,毕竟对数据库的操做,要放在链接数据库以后

可是,这种设计不适合工程化的前端项目!

由于,全部增删改查等操做,都须要用户,手动放到链接完成的异步回调以后,不然没法知道操做时有没有连上数据库和表

致使每次须要操做数据库时,都要先打开数据库一遍数据库,才能继续

即便你预先定义一个全局的链接,你在以后想要使用它时,若是不包一层 Promise,是没法肯定数据库和表,在使用时有没有链接上的

以 Vue 为例,若是你在全局环境(好比 Vuex)定义了一个链接:

import GoDB from 'godb';

new Vuex.Store({
  state: {
    godb: await GoDB.open('testDB') // 不加 await 返回的就是 Promise 了
  }
});
复制代码

这样,在 Vue 的任何一个组件中,咱们都能访问到 GoDB 实例

问题来了,在你的组件中,若是你想在组件初始化时,好比 createdmounted 这样的钩子函数中(React 中就是 ComponentDidMount),去访问数据库:

new Vue({
   mounted() {
       const godb = this.$store.state.godb; // 从全局环境取出链接
       godb.table('user')
           .then(user => {
               user.add({
                   name: 'luke',
                   age: 22
               }); // user is undefined!
           });
   }
});
复制代码

你会发现,若是这个组件在 App 初始化时就被加载,在组件 mounted 函数触发时,本地数据库可能根本就没有链接上!(链接数据库这样的操做,最典型的执行场景就是在组件加载时)

解决办法是,在每个须要操做数据库的地方,都定义一个链接:

import GoDB from 'godb';

new Vue({
    mounted() {
        GoDB.open('testDB')
          .then(testDB => testDB.table('user'))
          .then(user => {
              user.add({
                  name: 'luke',
                  age: 22
              });
          });
    }
});
复制代码

这样不只代码又臭又长,性能低下(每次操做都须要先链接),在须要链接本地数据库的组件多了后,维护起来更是一场噩梦

简而言之,就是这个方案,在工程化前端的不一样组件中,须要在每次操做以前,都连一遍数据库,不然没法确保组件加载时,已经链接上了 IndexedDB

设计二:隐藏链接的异步特性

我最终采用了这个方案,对开发者而言,甚至感受不到「链接数据库」和「链接表」这两个操做是异步的

const testDB = new GoDB('testDB');
const user = testDB.table('user');

user.add({
    name: 'luke',
    age: 22
})
  .then(luke => console.log(luke));
复制代码

这样使用上很是天然,开发者并不须要关心操做时有没有连上数据库和表,只须要在操做后的回调内写好本身的逻辑就能够

可是,这个方案的缺点就是开发起来比较麻烦(嘿嘿,麻烦本身,方便用户)

由于 new Codb('testDB') 内部的链接数据库的操做,其实是异步的(由于 IndexedDB 的原生 API 就是异步的设计)

在链接数据库的操做发出去后,即便还没链接上,下面的 testDB.table('user')user.add() 也会先开始执行

也就是说,以后的「获取 user 表」 和 「添加一条数据」实际上会先于「连上数据库」这个过程执行,若是实现该 API 设计时未处理这个问题,上面的示例代码确定会报错

而要处理这个问题,我用到了下面两个方法:

  • 在每次须要连上数据库的操做中(好比 add()),先拿到数据库的链接,再进行操做
  • 使用队列 Queue,在还未链接时,把须要链接数据库的操做放进队列,等链接完成,再执行该队列

具体而言,就是

  • GoDB 的 class 中定义一个 getDB(callback),用来获取 IndexedDB 链接实例
  • 增删改查中,都调用 getDB,在 callback 获取到 IndexedDB 的链接实例后再进行操做
  • getDB 中使用一个队列,若是数据库还没链接上,就把 callback 放进队列,在链接上后,执行这个队列中的函数
  • 链接完成时,直接把 IndexedDB 链接实例传进 callback 执行便可

在调用 getDB 时,可能有三种状态(其实还有个数据库已关闭的状态,这里不讨论):

  1. 刚初始化,未发起和 IndexedDB 的链接
  2. 正在链接 IndexedDB,但还未连上
  3. 已经连上,此时已经有 IndexedDB 的链接实例

第一种状态只在第一次执行 getDB 时触发,由于一旦尝试创建链接就进入下一个状态了;第一次执行被我放到了 GoDB 类的构造函数中

第三种状态时,也就是已经连上数据库后,直接把链接实例传进 callback 执行便可

关键是处理第二种状态,此时正在链接数据库,但还未连上,没法进行增删改查:

const testDB = new GoDB('testDB');
const user = testDB.table('user');

user.add({ name: 'luke' }); // 此时数据库正在链接,还未连上
user.add({ name: 'elaine' }); // 此时数据库正在链接,还未连上

testDB.onOpened = () => { // 数据库链接成功的回调
    user.add({ name: 'lucas' }); // 此时已链接
}
复制代码

上面的例子,头两个 add 执行时其实数据库并未链接上

那要如何操做,才能保证正常添加,而且 lukeelainelucas 进入数据库的顺序,和代码顺序一致呢?

答案是使用队列 Queue,把数据库还未连上时的 add 操做加进队列,在链接成功时,按先进先出的顺序执行

这样,用户就不须要关心,操做时数据库是否已经连上了(只须要关注异步回调便可),GoDB 帮你在幕后作好了这一切

注意之因此使用 callback 而不是 Promise,是由于 JS 中的回调既能够是异步的,也能够是同步的

而链接成功,已经有链接实例后,直接同步返回链接实例更好,不必再使用异步

仍是以 Vue 为例,若是咱们在 Vuex(全局变量)中添加链接实例:

import GoDB from 'godb';

new Vuex.Store({
    state: {
        godb: new GoDB('testDB')
    }
});
复制代码

这样,在全部组件中,咱们均可以使用同一个链接实例:

new Vue({
    computed: {
        // 把全局实例变为组件属性
        godb() {
            return this.$store.state.godb;
        }
    },
    mounted() {
        this.godb.table('user').add({
            name: 'luke',
            age: 22
        })
          .then(luke => console.log(luke));
    }
});
复制代码

总结这个方案的优势:

  • 性能更高(能够全局共享一个链接实例)
  • 代码更简洁
  • 最关键的,心智负担低了不少 -- 直接操做便可,无需关注数据库的链接状态

缺点:对 GoDB.js 的开发更麻烦,不是简单把 IndexedDB 封装一层 Promise 就行

所以,我最终采用了这个方案,毕竟麻烦我一个,方便你我他,优势远远盖过了缺点

若是对实现好奇的话,能够去阅读源码,当前只是实现了基本的 CRUD,源码暂时还不复杂

总结

若是想了解更多,能够访问项目官网

godb-js.github.io

若是你有任何建议或意见,请在评论区留言,我会认证读每个反馈

若是以为这个项目有意思,欢迎给文章点赞,欢迎来 GitHub 点个 star~

github.com/chenstarx/G…

相关文章
相关标签/搜索