一行代碼,搞定瀏覽器數據庫 IndexedDB

前言

2021 年,若是你的前端應用,須要在瀏覽器上保存數據,有三個主流方案能夠選擇:前端

  • Cookie:上古時代就已存在,但能應用的業務場景很是有限
  • LocalStorage:使用簡單靈活,可是容量只有 10Mb,且不適合儲存結構化數據
  • IndexedDB:算得上真正意義上的數據庫,但坑異常多,使用麻煩,古老的 API 設計放在現代前端工程中總有種格格不入的感受

我在大三的時候,曾經用 IndexedDB 寫過一個背單詞 App,當時就有把 IndexedDB 封裝一遍的想法,可是因爲學業緊張,後來就擱置了vue

最近,我終於有了空閒時間,因而撿起了當年的想法,開始嘗試用 TypeScript 把IndexedDB 封裝一遍,把坑一個個填上,作成一個開發者友好的庫,並開源出來,上傳至 npmreact

拍腦殼後,我決定把這個項目命名爲 Godb.jswebpack

Godb.js

Godb.js 的出現,讓你即便你不瞭解瀏覽器數據庫 IndexedDB,也能把它用的行雲流水,從而把關注點放到業務上面去git

畢竟要用好 IndexedDB,你須要翻無數遍 MDN,而 Godb 替你吃透了 MDN,從而讓你把 IndexedDB 用的更好的同時,操做還更簡單了github

當前項目處於 Alpha 階段(版本 0.3.x),意味着以後隨時可能會有 breaking changes,在正式版(1.0.0 及之後)發佈以前,不要把這個項目用到任何嚴肅的場景下web

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

若是以爲不錯的話就點個 Star 吧~數據庫

項目完整文檔與官網正在緊張開發中,現階段能夠經過下面的 demo 來嚐鮮npm

安裝

首先須要安裝,這裏默認你使用了 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',
  age22
};

user.add(data) // 增
  .then(id => user.get(id)) // 查,等價於 user.get({ id: id })
  .then(luke => user.put({ ...luke, age23 })) // 改
  .then(id => user.delete(id)); // 刪

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

  • Table.get() 返回的是完整數據
  • Table.add() 和  Table.put() 返回的是  id(也能夠返回完整數據,評論區留言討論吧~)
  • Table.delete() 不返回數據(返回  undefined

第二點須要注意的就是,put(obj) 方法中的 obj 須要包含 id,不然就等價於 add(obj)

上面的 demo 中,get 獲得的 luke 對象包含 id,所以是修改操做

以後會引入一個 update 方法來改進這個問題

也能夠一次性添加多條數據

const data = [
    {
        name'luke',
        age22
    },
    {
        name'elaine',
        age23
    }
];

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

addMany(data) 方法:

  • 嚴格按照  data 的順序添加
  • 返回 id 的數組,與  data 順序一致

之因此單獨寫個 addMany,而不在 add 里加一個判斷數組的邏輯,是由於用戶想要的可能就是添加一個數組到數據庫

注意:addMany 和 add 不要同步調用,若是在 addMany 正在執行時調用 add,可能會致使數據庫裏的順序不符合預期,請在 addMany 的回調完成後再調用 add

Table.consoleTable()

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

這裏的 (index) 就是 id

雖然 chrome 開發者工具內就能看到表內全部數據,但這個方法好處是能夠在須要的時候打印出數據,方便 debug

注意:這個方法是異步的,由於須要在數據庫裏把數據庫取出來;異步意味着緊接在它後面的代碼,可能會在打印出結果以前執行,若是不但願出現這種狀況,使用 await 或 Promise.then 便可

Table.find()

若是你想在數據庫中查找數據,還可使用 Table.find() 方法:

const data = [
    {
        name'luke',
        age22
    },
    {
        name'elaine',
        age23
    }
];

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

Table.find(fn) 接受一個函數 fn 做爲參數,這個函數的返回值爲 true 和 false

這個方法在內部會從頭遍歷整個表(使用 IndexedDB 的 Cursor),而後把每一次的結果放進 fn 執行,若是 fn 的返回值爲 true(也能夠是 1 這樣的等價於 true 的值),就返回當前的結果,中止遍歷

這個方法只會返回第一個知足條件的值,若是須要返回全部知足條件的值,請使用 Table.findAll(),用法與 Table.find() 一致,可是會返回一個數組,包含全部知足條件的值

Schema

若是你但願數據庫的結構更嚴格一點,也能夠添加 schema

import Godb from 'godb';

// 定義數據庫結構
const schema = {
    // user 表:
    user: {
        // user 表的字段:
        name: {
            typeString,
            uniquetrue // 指定 name 字段在表裏惟一
        },
        ageNumber
    }
}

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

const luke1 = {
    name'luke'
    age22
};

const luke2 = {
    name'luke'
    age19
};

user.add(luke1) // 沒問題
  .then(() => user.get({ name'luke' })) // 定義schema後,就能夠用 id 之外的字段獲取到數據了
  .then(() => user.add(luke2)) // 報錯,name 重複了

如上面的例子

  • 定義了 schema,所以  get() 中可使用  id 之外的字段搜索了,不然只能傳入  id
  • 指定了  user.name 這一項是惟一的,所以沒法添加劇復的  name

Table.get() 與 Table.find() 區別

get() 使用數據庫索引搜索,性能更高,而 find() 是遍歷全表,使用上靈活

關於 schema:

部分同窗或許會發現,上面定義 schema 的方式有點眼熟,沒錯,正是參考了 mongoose

  • 定義數據庫的字段時,能夠只指明數據類型,如上面的  age: Number
  • 也可使用一個對象,裏面除了定義數據類型  type,也指明這個字段是否是惟一的( unique: true),以後會添加更多可選屬性,如用來指定字段默認值的  default,和指向別的表的索引  ref

不定義 Schema 時,Godb 使用起來就像 MongoDB 同樣,能夠靈活添加數據;區別是 Mongodb 中,每條數據的惟一標識符是 _id,而 Godb 是 id

雖然這樣作的問題是,IndexedDB 畢竟仍是結構化的,用戶使用不規範的話(如每次添加的數據結構都不同),長此以往可能會使得數據庫的字段特別多,且不一樣數據中沒用到的字段都是空的,致使浪費,影響性能

定義 Schema 後,Godb 使用起來就像 MySQL 同樣,若是添加 Schema 沒有的字段,或者是字段類型不符合定義,會報錯(在寫文檔的時候尚未實現這個功能,即便 Schema 不符合也能加,下個版本會安排上)

所以推薦在項目中,定義好 schema,這樣不論是維護性上,仍是性能上,都要更勝一籌

另外一個使用 await 的 CRUD demo:

import Godb from 'godb';

const schema = {
  user: {
    name: {
      typeString,
      uniquetrue
    },
    ageNumber
  }
};

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

crud();

async function crud({

  // 增:
  await user.addMany([
    {
      name'luke',
      age22
    },
    {
      name'elaine',
      age23
    }
  ]);

  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,會在控制檯打印出下面的內容:

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',
            age22
        });
    });
});

這樣的優勢是,工做流程一目瞭然,畢竟對數據庫的操做,要放在鏈接數據庫以後

可是,這種設計不適合工程化的前端項目!

由於,全部增刪改查等操做,都須要用戶,手動放到鏈接完成的異步回調以後,不然沒法知道操做時有沒有連上數據庫和表

致使每次須要操做數據庫時,都要先打開數據庫一遍數據庫,才能繼續

即便你預先定義一個全局的鏈接,你在以後想要使用它時,若是不包一層 Promise,是沒法肯定數據庫和表,在使用時有沒有鏈接上的

以 Vue 爲例,若是你在全局環境(好比 Vuex)定義了一個鏈接:

import Godb from 'godb';

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

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

問題來了,在你的組件中,若是你想在組件初始化時,好比 created 和 mounted 這樣的鉤子函數中(React 中就是 ComponentDidMount),去訪問數據庫:

new Vue({
   mounted() {
       const godb = this.$store.state.godb; // 從全局環境取出鏈接
       godb.table('user')
           .then(user => {
               user.add({
                   name'luke',
                   age22
               }); // 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',
                  age22
              });
          });
    }
});

這樣不只代碼又臭又長,性能低下(每次操做都須要先鏈接),在須要鏈接本地數據庫的組件多了後,維護起來更是一場噩夢

簡而言之,就是這個方案,在工程化前端的不一樣組件中,須要在每次操做以前,都連一遍數據庫,不然沒法確保組件加載時,已經鏈接上了 IndexedDB

設計二:隱藏鏈接的異步特性

我最終採用了這個方案,對開發者而言,甚至感受不到「鏈接數據庫」和「鏈接表」這兩個操做是異步的

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

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

這樣使用上很是天然,開發者並不須要關心操做時有沒有連上數據庫和表,只須要在操做後的回調內寫好本身的邏輯就能夠

可是,這個方案的缺點就是開發起來比較麻煩(嘿嘿,麻煩本身,方便用戶)

由於 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 操做時其實數據庫並未鏈接上

那要如何操做,才能保證正常添加,而且 luke 和 elaine 在 lucas 進入數據庫的順序和代碼一致呢?

答案是使用隊列 Queue,把兩個 add 操做加進隊列,在鏈接成功時,按先進先出的順序執行

這樣,用戶就不須要關心,操做時數據庫是否已經連上了(注意增刪改查有異步回調,在回調裏能夠知道是否操做成功),Godb 幫你在幕後作好了這一切

注意之因此使用 callback 而不是 Promise,是由於 JS 中的回調既能夠是異步的,也能夠是同步的

而鏈接成功,已經有鏈接實例後,直接同步返回鏈接實例更好,不必再使用異步

仍是以 Vue 爲例,若是咱們在 Vuex(全局變量)中添加鏈接實例:

import Godb from 'godb';

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

這樣,在全部組件中,咱們均可以使用同一個鏈接實例:

new Vue({
    computed: {
        // 把全局實例變爲組件屬性
        godb() {
            return this.$store.state.godb;
        }
    },
    mounted() {
        this.godb.table('user').add({
            name'luke',
            age22
        }).then(id => console.log(id));
    }
});

總結這個方案的優勢:

  • 性能更高(能夠全局共享一個鏈接實例)
  • 代碼更簡潔
  • 最關鍵的,心智負擔低了不少!

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

所以,我最終採用了這個方案,畢竟麻煩我一個,方便你我他,優勢遠遠蓋過了缺點

若是對實現好奇的話,能夠去閱讀源碼,當前只是實現了基本的 CRUD,源碼暫時還不復雜

近期待辦

在把基本的 CRUD 完成後,我就寫下了這篇文章,讓你們來嚐嚐鮮

而接下來要作的事其實很是多,近期我會完成下面的開發:

  • [ ]  Table.update():更好的更新數據的方案
  • [ ] 全局錯誤處理,目前代碼裏 throw 的 Error 實際上是沒被處理的
  • [ ] 若是定義了 Schema,那就在全部 Table 的方法執行前都檢查 Schema
  • [ ] 若是定義了 Schema,保證數據庫的結構和 Schema 一致

若是你有任何建議或意見,請在評論區留言,我會認證讀每個反饋

若是以爲這個項目有意思,歡迎給文章點贊,歡迎來 GitHub 點個 star~

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


原文連接:https://juejin.cn/post/6918705632757415950



本文分享自微信公衆號 - 編程微刊(wangxiaoting678)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索