一行代碼,搞定瀏覽器數(shù)據(jù)庫 IndexedDB

前言

2021 年,如果你的前端應(yīng)用,需要在瀏覽器上保存數(shù)據(jù),有三個主流方案可以選擇:

  • Cookie:上古時代就已存在,但能應(yīng)用的業(yè)務(wù)場景非常有限
  • LocalStorage:使用簡單靈活,但是容量只有 10Mb,且不適合儲存結(jié)構(gòu)化數(shù)據(jù)
  • IndexedDB:算得上真正意義上的數(shù)據(jù)庫,但坑異常多,使用麻煩,古老的 API 設(shè)計放在現(xiàn)代前端工程中總有種格格不入的感覺

我在大三的時候,曾經(jīng)用 IndexedDB 寫過一個背單詞 App,當(dāng)時就有把 IndexedDB 封裝一遍的想法,但是由于學(xué)業(yè)緊張,后來就擱置了

最近,我終于有了空閑時間,于是撿起了當(dāng)年的想法,開始嘗試用 TypeScriptIndexedDB 封裝一遍,把坑一個個填上,做成一個開發(fā)者友好的庫,并開源出來,上傳至 npm

拍腦袋后,我決定把這個項目命名為 Godb.js

Godb.js

Godb.js 的出現(xiàn),讓你即使你不了解瀏覽器數(shù)據(jù)庫 IndexedDB,也能把它用的行云流水,從而把關(guān)注點放到業(yè)務(wù)上面去

畢竟要用好 IndexedDB,你需要翻無數(shù)遍 MDN,而 Godb 替你吃透了 MDN,從而讓你把 IndexedDB 用的更好的同時,操作還更簡單了

本文發(fā)布時,項目處于 Alpha 階段(版本 0.3.x),意味著之后隨時可能會有 breaking changes,在正式版(1.0.0 及以后)發(fā)布之前,建議不要把這個項目用到嚴(yán)肅的場景下

項目GitHub:
https://github.com/chenstarx/Godb.js

如果覺得不錯的話就點個 Star 吧~

項目完整文檔與官網(wǎng)正在緊張開發(fā)中,現(xiàn)階段可以通過下面的 demo 來嘗鮮

安裝

首先需要安裝,這里默認(rèn)你使用了 webpack、gulp 等打包工具,或在 vue、react 等項目中

npm install godb

在第一個正式版發(fā)布后,還會提供 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(id => user.get(id)) // 查,等價于 user.get({ id: id })
  .then(luke => user.put({ ...luke, age: 23 })) // 改
  .then(id => user.delete(id)); // 刪

這里注意增刪改查四個方法在 Promise.then 的返回值:

  • Table.get() 返回的是完整數(shù)據(jù)
  • Table.add()Table.put() 返回的是 id(也可以返回完整數(shù)據(jù),評論區(qū)留言討論吧~)
  • Table.delete() 不返回數(shù)據(jù)(返回 undefined

第二點需要注意的就是,put(obj) 方法中的 obj 需要包含 id,否則就等價于 add(obj)

上面的 demo 中,get 得到的 luke 對象包含 id,因此是修改操作

之后會引入一個 update 方法來改進這個問題

也可以一次性添加多條數(shù)據(jù)

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

user.addMany(data)
  .then(() => user.consoleTable());

addMany(data) 方法:

  • 嚴(yán)格按照 data 的順序添加
  • 返回 id 的數(shù)組,與 data 順序一致

之所以單獨寫個 addMany,而不在 add 里加一個判斷數(shù)組的邏輯,是因為用戶想要的可能就是添加一個數(shù)組到數(shù)據(jù)庫

注意:addManyadd 不要同步調(diào)用,如果在 addMany 正在執(zhí)行時調(diào)用 add,可能會導(dǎo)致數(shù)據(jù)庫里的順序不符合預(yù)期,請在 addMany 的回調(diào)完成后再調(diào)用 add

Table.consoleTable()

這里用了一個 Table.consoleTable() 的方法,它會在瀏覽器的控制臺打印出下面的內(nèi)容:

add-many.png

這里的 (index) 就是 id

雖然 chrome 開發(fā)者工具內(nèi)就能看到表內(nèi)所有數(shù)據(jù),但這個方法好處是可以在需要的時候打印出數(shù)據(jù),方便 debug

注意:這個方法是異步的,因為需要在數(shù)據(jù)庫里把數(shù)據(jù)庫取出來;異步意味著緊接在它后面的代碼,可能會在打印出結(jié)果之前執(zhí)行,如果不希望出現(xiàn)這種情況,使用 awaitPromise.then 即可

Table.find()

如果你想在數(shù)據(jù)庫中查找數(shù)據(jù),還可以使用 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: 'luke', age: 23 }
  });

Table.find(fn) 接受一個函數(shù) fn 作為參數(shù),這個函數(shù)的返回值為 truefalse

這個方法在內(nèi)部會從頭遍歷整個表(使用 IndexedDB 的 Cursor),然后把每一次的結(jié)果放進 fn 執(zhí)行,如果 fn 的返回值為 true(也可以是 1 這樣的等價于 true 的值),就返回當(dāng)前的結(jié)果,停止遍歷

這個方法只會返回第一個滿足條件的值,如果需要返回所有滿足條件的值,請使用 Table.findAll(),用法與 Table.find() 一致,但是會返回一個數(shù)組,包含所有滿足條件的值

Schema

如果你希望數(shù)據(jù)庫的結(jié)構(gòu)更嚴(yán)格一點,也可以添加 schema

import Godb from 'godb';

// 定義數(shù)據(jù)庫結(jié)構(gòu)
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 luke1 = {
    name: 'luke'
    age: 22
};

const luke2 = {
    name: 'luke'
    age: 19
};

user.add(luke1) // 沒問題
  .then(() => user.get({ name: 'luke' })) // 定義schema后,就可以用 id 以外的字段獲取到數(shù)據(jù)了
  .then(() => user.add(luke2)) // 報錯,name 重復(fù)了

如上面的例子

  • 定義了 schema,因此 get() 可以傳入 id 以外的字段了,否則只能傳入 id
  • 指定了 user.name 這一項是唯一的,因此無法添加重復(fù)的 name

get() vs find():

注意 get()find() 的區(qū)別,如果 schema 中定義了字段,get() 的查找效率會高于 find(),且數(shù)據(jù)量越大差距越大,因為 find() 的實現(xiàn)方式是遍歷整個表,而 get() 是使用索引進行查找

只有預(yù)先定義了 schemaGodb 才會給字段建立索引,因此建議在工程實踐中,盡量先定義好數(shù)據(jù)庫 schema

關(guān)于 schema:

部分同學(xué)或許會發(fā)現(xiàn),上面定義 schema 的方式有點眼熟,沒錯,正是參考了 mongoose

  • 定義數(shù)據(jù)庫的字段時,可以只指明數(shù)據(jù)類型,如上面的 age: Number
  • 也可以使用一個對象,里面除了定義數(shù)據(jù)類型 type,也指明這個字段是不是唯一的(unique: true),之后會添加更多可選屬性,如用來指定字段默認(rèn)值的 default,和指向別的表的索引 ref

不定義 Schema 時,Godb 使用起來就像 MongoDB 一樣,可以靈活添加數(shù)據(jù);區(qū)別是 Mongodb 中,每條數(shù)據(jù)的唯一標(biāo)識符是 _id,而 Godbid

雖然這樣做的問題是,IndexedDB 畢竟還是結(jié)構(gòu)化的,用戶使用不規(guī)范的話(如每次添加的數(shù)據(jù)結(jié)構(gòu)都不一樣),久而久之可能會使得數(shù)據(jù)庫的字段特別多,且不同數(shù)據(jù)中沒用到的字段都是空的,導(dǎo)致浪費,影響性能

定義 Schema 后,Godb 使用起來就像 MySQL 一樣,如果添加 Schema 沒有的字段,或者是字段類型不符合定義,會報錯(在寫文檔的時候還沒有實現(xiàn)這個功能,即使 Schema 不符合也能加,下個版本會安排上)

因此推薦在項目中,定義好 schema,這樣不管是維護性上,還是性能上,都要更勝一籌

另一個使用 await 的 CRUD demo:

import Godb from 'godb';

const schema = {
  user: {
    name: {
      type: String,
      unique: true
    },
    age: Number
  }
};

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

crud();

async function crud() {

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

  console.log('add user: luke');
  // await 非必須,這里是為了防止打印順序不出錯
  await user.consoleTable();

  // 查:
  const luke = await user.get({ name: 'luke' });
  // const luke = await user.get(2); // 等價于:
  // const luke = await user.get({ id: 2 });

  // 改:
  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,會在控制臺打印出下面的內(nèi)容:

crud-test.png

API 設(shè)計

因為「連接數(shù)據(jù)庫」和「連接表」這兩個操作是異步的,在設(shè)計之初,曾經(jīng)有兩個 API 方案,區(qū)別在于:要不要把這兩個操作,做為異步 API 提供給用戶

這里討論的不是「API 如何命名」這樣的細(xì)節(jié),而是「API 的使用方式」,因為這會直接影響到用戶使用 Godb 時的業(yè)務(wù)代碼編寫方式

以連接數(shù)據(jù)庫 -> 添加一條數(shù)據(jù)的過程為例

設(shè)計一:提供異步特性

GitHub 上大多數(shù)開源的 IndexedDB 封裝庫都是這么做的

import Godb from 'godb';

// 連接數(shù)據(jù)庫是異步的
Godb.open('testDB')
    .then(testDB => testDB.table('user')) // 連接表也需要異步
    .then(user => {
        user.add({
            name: 'luke',
            age: 22
        });
    });
});

這樣的優(yōu)點是,工作流程一目了然,畢竟對數(shù)據(jù)庫的操作,要放在連接數(shù)據(jù)庫之后

但是,這種設(shè)計不適合工程化的前端項目!

因為,所有增刪改查等操作,都需要用戶,手動放到連接完成的異步回調(diào)之后,否則無法知道操作時有沒有連上數(shù)據(jù)庫和表

導(dǎo)致每次需要操作數(shù)據(jù)庫時,都要先打開數(shù)據(jù)庫一遍數(shù)據(jù)庫,才能繼續(xù)

即使你預(yù)先定義一個全局的連接,你在之后想要使用它時,如果不包一層 Promise,是無法確定數(shù)據(jù)庫和表,在使用時有沒有連接上的

以 Vue 為例,如果你在全局環(huán)境(比如 Vuex)定義了一個連接:

import Godb from 'godb';

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

這樣,在 Vue 的任何一個組件中,我們都能訪問到 Godb 實例

問題來了,在你的組件中,如果你想在組件初始化時,比如 createdmounted 這樣的鉤子函數(shù)中(React 中就是 ComponentDidMount),去訪問數(shù)據(jù)庫:

new Vue({
   mounted() {
       const godb = this.$store.state.godb; // 從全局環(huán)境取出連接
       godb.table('user')
           .then(user => {
               user.add({
                   name: 'luke',
                   age: 22
               }); // user is undefined!
           });
   }
});

你會發(fā)現(xiàn),如果這個組件在 App 初始化時就被加載,在組件 mounted 函數(shù)觸發(fā)時,本地數(shù)據(jù)庫可能根本就沒有連接上?。ㄟB接數(shù)據(jù)庫這樣的操作,最典型的執(zhí)行場景就是在組件加載時)

解決辦法是,在每一個需要操作數(shù)據(jù)庫的地方,都定義一個連接:

import Godb from 'godb';

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

這樣不僅代碼又臭又長,性能低下(每次操作都需要先連接),在需要連接本地數(shù)據(jù)庫的組件多了后,維護起來更是一場噩夢

簡而言之,就是這個方案,在工程化前端的不同組件中,需要在每次操作之前,都連一遍數(shù)據(jù)庫,否則無法確保組件加載時,已經(jīng)連接上了 IndexedDB

設(shè)計二:隱藏連接的異步特性

我最終采用了這個方案,對開發(fā)者而言,甚至感覺不到「連接數(shù)據(jù)庫」和「連接表」這兩個操作是異步的

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

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

這樣使用上非常自然,開發(fā)者并不需要關(guān)心操作時有沒有連上數(shù)據(jù)庫和表,只需要在操作后的回調(diào)內(nèi)寫好自己的邏輯就可以

但是,這個方案的缺點就是開發(fā)起來比較麻煩(嘿嘿,麻煩自己,方便用戶)

因為 new Codb('testDB') 內(nèi)部的連接數(shù)據(jù)庫的操作,實際上是異步的(因為 IndexedDB 的原生 API 就是異步的設(shè)計)

在連接數(shù)據(jù)庫的操作發(fā)出去后,即使還沒連接上,下面的 testDB.table('user')user.add() 也會先開始執(zhí)行

也就是說,之后的「獲取 user 表」 和 「添加一條數(shù)據(jù)」實際上會先于「連上數(shù)據(jù)庫」這個過程執(zhí)行,如果實現(xiàn)該 API 設(shè)計時未處理這個問題,上面的示例代碼肯定會報錯

而要處理這個問題,我用到了下面兩個方法:

  • 在每次需要連上數(shù)據(jù)庫的操作中(比如 add()),先拿到數(shù)據(jù)庫的連接,再進行操作
  • 使用隊列 Queue,在還未連接時,把需要連接數(shù)據(jù)庫的操作放進隊列,等連接完成,再執(zhí)行該隊列

具體而言,就是

  • Godb 的 class 中定義一個 getDB(callback),用來獲取 IndexedDB 連接實例
  • 增刪改查中,都調(diào)用 getDB,在 callback 獲取到 IndexedDB 的連接實例后再進行操作
  • getDB 中使用一個隊列,如果數(shù)據(jù)庫還沒連接上,就把 callback 放進隊列,在連接上后,執(zhí)行這個隊列中的函數(shù)
  • 連接完成時,直接把 IndexedDB 連接實例傳進 callback 執(zhí)行即可

在調(diào)用 getDB 時,可能有三種狀態(tài)(其實還有個數(shù)據(jù)庫已關(guān)閉的狀態(tài),這里不討論):

  1. 剛初始化,未發(fā)起和 IndexedDB 的連接
  2. 正在連接 IndexedDB,但還未連上
  3. 已經(jīng)連上,此時已經(jīng)有 IndexedDB 的連接實例

第一種狀態(tài)只在第一次執(zhí)行 getDB 時觸發(fā),因為一旦嘗試建立連接就進入下一個狀態(tài)了;第一次執(zhí)行被我放到了 Godb 類的構(gòu)造函數(shù)中

第三種狀態(tài)時,也就是已經(jīng)連上數(shù)據(jù)庫后,直接把連接實例傳進 callback 執(zhí)行即可

關(guān)鍵是處理第二種狀態(tài),此時正在連接數(shù)據(jù)庫,但還未連上,無法進行增刪改查:

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

user.add({ name: 'luke' }); // 此時數(shù)據(jù)庫正在連接,還未連上
user.add({ name: 'elaine' }); // 此時數(shù)據(jù)庫正在連接,還未連上

testDB.onOpened = () => { // 數(shù)據(jù)庫連接成功的回調(diào)
    user.add({ name: 'lucas' }); // 此時已連接
}

上面的例子,頭兩個 add 操作時其實數(shù)據(jù)庫并未連接上

那要如何操作,才能保證正常添加,并且 lukeelainelucas 進入數(shù)據(jù)庫的順序和代碼一致呢?

答案是使用隊列 Queue,把兩個 add 操作加進隊列,在連接成功時,按先進先出的順序執(zhí)行

這樣,用戶就不需要關(guān)心,操作時數(shù)據(jù)庫是否已經(jīng)連上了(注意增刪改查有異步回調(diào),在回調(diào)里可以知道是否操作成功),Godb 幫你在幕后做好了這一切

注意之所以使用 callback 而不是 Promise,是因為 JS 中的回調(diào)既可以是異步的,也可以是同步的

而連接成功,已經(jīng)有連接實例后,直接同步返回連接實例更好,沒必要再使用異步

還是以 Vue 為例,如果我們在 Vuex(全局變量)中添加連接實例:

import Godb from 'godb';

new Vuex.Store({
    state: {
        godb: new Godb('testDB')
    }
});

這樣,在所有組件中,我們都可以使用同一個連接實例:

new Vue({
    computed: {
        // 把全局實例變?yōu)榻M件屬性
        godb() {
            return this.$store.state.godb;
        }
    },
    mounted() {
        this.godb.table('user').add({
            name: 'luke',
            age: 22
        }).then(id => console.log(id));
    }
});

總結(jié)這個方案的優(yōu)點:

  • 性能更高(可以全局共享一個連接實例)
  • 代碼更簡潔
  • 最關(guān)鍵的,心智負(fù)擔(dān)低了很多!

缺點:Godb 開發(fā)更麻煩,不是簡單把 IndexedDB 包一層 Promise 就行

因此,我最終采用了這個方案,畢竟麻煩我一個,方便你我他,優(yōu)點遠(yuǎn)遠(yuǎn)蓋過了缺點

如果對實現(xiàn)好奇的話,可以去閱讀源碼,當(dāng)前只是實現(xiàn)了基本的 CRUD,源碼暫時還不復(fù)雜

近期待辦

在把基本的 CRUD 完成后,我就寫下了這篇文章,讓大家來嘗嘗鮮

而接下來要做的事其實非常多,近期我會完成下面的開發(fā):

  • Table.update():更好的更新數(shù)據(jù)的方案
  • 全局錯誤處理,目前代碼里 throw 的 Error 其實是沒被處理的
  • 如果定義了 Schema,那就在所有 Table 的方法執(zhí)行前都檢查 Schema
  • 如果定義了 Schema,保證數(shù)據(jù)庫的結(jié)構(gòu)和 Schema 一致

如果你有任何建議或意見,請在評論區(qū)留言,我會認(rèn)證讀每一個反饋

如果覺得這個項目有意思,歡迎給文章點贊,歡迎來 GitHub 點個 star~

https://github.com/chenstarx/Godb.js

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容