vue3 專用 indexedDB 封裝庫,基於Promise告別回撥地獄

金色海洋(jyk)發表於2021-10-14

IndexedDB 的官網

https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API
這個大概是官網咖,原始是英文的,現在陸續是出中文版。有空的話還是多看看官網。

簡介

IndexedDB 是一種底層 API,用於在客戶端儲存大量的結構化資料(也包括檔案/二進位制大型物件(blobs))。該 API 可以使用索引實現對資料的高效能搜尋。

簡單的說就是 —— 能裝!indexedDB 是前端的一種“事務型物件資料庫”,可以存放很多很多的物件(當然也可以是其他型別),功能很強大,可以用作資料(物件)在前端的快取容器。或者其他用途。

使用也是很簡單的,網上可以找到很多教程,官網也推薦了幾個封裝類庫。
只是我比較懶,得看別人的類庫(好吧,我看不懂),而是想按照自己的想法封裝一個自己用著習慣的類庫。

最近在使用 Vue3,所以想針對 Vue3 做一套 indexedDB 的類庫,實現客戶端快取資料的功能。

其實這裡介紹的應該算是第二版了,第一版在專案裡面試用一段時間後,發現了幾個問題,所以想在新版本里面一起解決。

indexedDB 的操作思路

一開始看 indexedDB 的時候各種懵逼,只會看大神寫的文章,然後照貓畫虎,先不管原理,把程式碼copy過來能執行起來,能讀寫資料即可。

現在用了一段時間,有了一點理解,整理如下:

 使用思路

  • 獲取 indexedDB 的物件
  • open (開啟/建立)資料庫。
  • 如果沒有資料庫,或者版本升級:
    • 呼叫 onupgradeneeded(建立/修改資料庫),然後呼叫 onsuccess。
  • 如果已有資料庫,且版本不變,那麼直接呼叫 onsuccess。
  • 在 onsuccess 裡得到連線物件後:
    • 開啟事務。
      • 得到物件倉庫。
        • 執行各種操作:新增、修改、刪除、獲取等。
        • 用索引和遊標實現查詢。
      • 得到結果

思路明確之後,我們就好封裝了。

做一個 help,封裝初始化的程式碼

前端資料庫和後端資料庫對比一下,就會發現一個很明顯的區別,後端資料庫是先配置好資料庫,建立需要的表,然後新增初始資料,最後才開始執行專案。

在專案裡面不用考慮資料庫是否已經建立好了,直接用就行。

但是前端資料庫就不行了,必須先考慮資料庫有沒有建立好,初始資料有沒有新增進去,然後才可以開始常規的操作。

所以第一步就是要封裝一下初始化資料庫的部分。

我們先建立一個 help.js 檔案,在裡面寫一個 ES6 的 class。


/**
 * indexedDB 的 help,基礎功能的封裝
 * * 開啟資料庫,建立物件倉庫,獲取連線物件,實現增刪改查
 * * info 的結構:
 * * * dbFlag: '' // 資料庫標識,區別不同的資料庫
 * * * dbConfig: { // 連線資料庫
 * * * * dbName: '資料庫名稱',
 * * * * ver: '資料庫版本',
 * * * },
 * * * stores: {
 * * * * storeName: { // 物件倉庫名稱
 * * * * * id: 'id', // 主鍵名稱
 * * * * * index: { // 可以不設定索引
 * * * * * * name: ture, // key:索引名稱;value:是否可以重複
 * * * * * }
 * * * * }
 * * * },
 * * * init: (help) => {} // 完全準備好之後的回撥函式
 */
export default class IndexedDBHelp {
  constructor (info) {
    this.myIndexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB
    if (!this.myIndexedDB) {
      console.log('您的瀏覽器不支援IndexedDB')
    }
    // 資料庫名稱和版本號
    this._info = {
      dbName: info.dbConfig.dbName,
      ver: info.dbConfig.ver
    }
    // 記錄連線資料庫的物件, IDBDatabase 型別,因為open是非同步操作,所以不能立即獲得。
    this._db = null

    // 記錄倉庫狀態。new:新庫或者版本升級後;old:有物件倉庫了。
    this._storeState = 'pending'

    /**
     * 註冊回撥事件。
     * * 如果元件讀寫 indexedDB 的時還沒有準備好的話,
     * * 可以來註冊一個事件,等準備好了之後回撥。
     */
    this._regCallback = []

    // 開啟資料庫,非同步操作,大概需要幾毫秒的時間。
    this.dbRequest = this.myIndexedDB.open(this._info.dbName, this._info.ver)

    // 第一次,或者版本升級時執行,根據配置資訊建立表
    this.dbRequest.onupgradeneeded = (event) => {
      this._storeState = 'new'
      const db = event.target.result
      // console.log('【2】新建或者升級資料庫 onupgradeneeded --- ', db)

      for (const key in info.stores) {
        const store = info.stores[key]
        if (db.objectStoreNames.contains(key)) {
          // 已經有倉庫,驗證一下是否需要刪除原來的倉庫
          if (store.isClear) {
            // 刪除原物件倉庫,沒有儲存資料
            db.deleteObjectStore(key)
            // 建立新物件倉庫
            const objectStore = db.createObjectStore(key, { keyPath: store.id })
            // 建立索引
            for (const key2 in store.index) {
              const unique = store.index[key2]
              objectStore.createIndex(key2, key2, { unique: unique })
            }
          }
        } else {
          // 沒有物件倉庫,建立
          const objectStore = db.createObjectStore(key, { keyPath: store.id }) /* 自動建立主鍵 autoIncrement: true */
          // 建立索引
          for (const key2 in store.index) {
            const unique = store.index[key2]
            objectStore.createIndex(key2, key2, { unique: unique })
          }
        }
      }
    }

    // 資料庫開啟成功,記錄連線物件
    this.dbRequest.onsuccess = (event) => {
      this._db = event.target.result // dbRequest.result
      // console.log('【1】成功開啟資料庫 onsuccess --- ', this._db)
      // 修改狀態
      if (this._storeState === 'pending') {
        this._storeState = 'old'
      }
      // 呼叫初始化的回撥
      if (typeof info.init === 'function') {
        info.init(this)
      }
      // 呼叫元件註冊的回撥
      this._regCallback.forEach(fn => {
        if (typeof fn === 'function') {
          fn()
        }
      })
    }

    // 處理出錯資訊
    this.dbRequest.onerror = (event) => {
      // 出錯
      console.log('開啟資料庫出錯:', event.target.error)
    }
  }
  // 掛載其他操作,後面介紹。。。
}

這裡要做幾個主要的事情:

  • 判斷瀏覽器是否支援 indexedDB
  • 開啟資料庫
  • 設定物件倉庫
  • 儲存連線物件,備用

另外使用 jsDoc 進行引數說明,有的時候是可以出現提示,就算不出提示,也是可以有說明的作用,避免過幾天自己都想不起來怎麼用引數了。

引數提示

掛載事務

拿到資料庫的連線物件之後,我們可以(必須)開啟一個事務,然後才能執行其他操作。

所以我們需要先把事務封裝一下,那麼為啥要單獨封裝事務呢?

因為這樣可以實現開啟一個事務,然後傳遞事務例項,從而實現連續操作的目的,雖然這種的情況不是太多,但是感覺還是應該支援一下這種功能。

begin-tran.js

/**
 * 開啟一個讀寫的事務
 * @param {*} help indexedDB 的 help
 * @param {Array} storeName 字串的陣列,物件倉庫的名稱
 * @param {string} type readwrite:讀寫事務;readonly:只讀事務;versionchange:允許執行任何操作,包括刪除和建立物件儲存和索引。
 * @returns 讀寫事務
 */
const beginTran = (help, storeName, type = 'readwrite') => {
  return new Promise((resolve, reject) => {
    const _tran = () => {
      const tranRequest = help._db.transaction(storeName, type)
      tranRequest.onerror = (event) => {
        console.log(type + ' 事務出錯:', event.target.error)
        reject(`${type} 事務出錯:${event.target.error}`)

      }
      resolve(tranRequest)
      tranRequest.oncomplete = (event) => {
        // console.log('beginReadonly 事務完畢:', window.performance.now())
      }
    }

    if (help._db) {
      _tran() // 執行事務
    } else {
      // 註冊一個回撥事件
      help._regCallback.push(() => _tran())
    }
  })
}
export default beginTran
  • 支援多個物件倉庫
    storeName 是字串陣列,所以可以針對多個物件倉庫同時開啟事務,然後通過 tranRequest.objectStore(storeName) 來獲取具體的物件倉庫。

  • 掛載到 help
    因為是寫在單獨的js檔案裡面,所以還需要在 help 裡面引入這個js檔案,然後掛到 help 上面,以便實現 help.xxx 的呼叫形式,這樣拿到help即可,不用 import
    各種函式了。

import _beginTran from './begin-tran.js' // 事務
... help 的其他程式碼
  // 讀寫的事務
  beginWrite (storeName) {
    return _beginTran(this, storeName, 'readwrite')
  }

  // 只讀的事務
  beginReadonly (storeName) {
    return _beginTran(this, storeName, 'readonly')
  }

是不是有一種“迴圈呼叫”的感覺?js 就是可以這麼放飛自我。然後需要我們寫程式碼的時候就要萬分小心,因為不小心的話很容易寫出來死迴圈。

掛載增刪改查

事務準備好了,我們就可以進行下一步操作。

先設計一個新增物件的 js檔案:

addModel.js

import _vueToObject from './_toObject.js'

/**
 * 新增物件
 * @param { IndexedDBHelp } help 訪問資料庫的例項
 * @param { string } storeName 倉庫名稱(表名)
 * @param { Object } model 物件
 * @param { IDBTransaction } tranRequest 如果使用事務的話,需要傳遞開啟事務時建立的連線物件
 * @returns 新物件的ID
 */
export default function addModel (help, storeName, model, tranRequest = null) {
  // 取物件的原型,便於儲存 reactive 
  const _model = _vueToObject(model)
  // 定義一個 Promise 的例項
  return new Promise((resolve, reject) => {
    // 定義個函式,便於呼叫
    const _add = (__tran) => {
      __tran
        .objectStore(storeName) // 獲取store
        .add(_model) // 新增物件
        .onsuccess = (event) => { // 成功後的回撥
          resolve(event.target.result) // 返回物件的ID
        }
    }
    if (tranRequest === null) {
      help.beginWrite([storeName]).then((tran) => {
        // 自己開一個事務
        _add(tran)
      })
    } else {
      // 使用傳遞過來的事務
      _add(tranRequest)
    }
  })
}

首先使用 Promise 封裝預設的回撥模式,然後可以傳遞進來一個事務進來,這樣可以實現開啟事務連續新增的功能。

如果不傳遞事務的話,內部會自己開啟一個事務,這樣新增單個物件的時候也會很方便。

然後在 help 裡面引入這個 js檔案,再設定一個函式:

import _addModel from './model-add.js' // 新增一個物件

  /**
   * 新增一個物件
   * @param {string} storeName 倉庫名稱
   * @param {object} model 要新增的物件
   * @param {*} tranRequest 事務,可以為null
   * @returns 
   */
  addModel (storeName, model, tranRequest = null) {
    return _addModel(this, storeName, model, tranRequest = null)
  }

這樣就可以掛載上來了。把程式碼分在多個 js檔案裡面,便於維護和擴充套件。

使用提示

修改、刪除和獲取的程式碼也類似,就不一一列舉了。

使用方式

看了上面的程式碼可能會感覺很暈,這麼複雜?不是說很簡單嗎?

對呀,把複雜封裝進去了,剩下的就是簡單的呼叫了。那麼如何使用呢?

準備建立資料庫的資訊

我們先定義一個物件,存放需要的各種資訊

const dbInfo = {
  dbFlag: 'project-meta-db', // 資料庫標識,區分不同的資料庫。如果專案裡只有一個,那麼不需要加這個標識
  dbConfig: {
    dbName: 'nf-project-meta', // 資料庫名稱
    ver: 2
  },
  stores: { // 資料庫裡的表(物件倉庫)
    moduleMeta: { // 模組的meta {按鈕,列表,分頁,查詢,表單若干}
      id: 'moduleId',
      index: {},
      isClear: false
    },
    menuMeta: { // 選單用的meta
      id: 'id',
      index: {},
      isClear: false
    },
    serviceMeta: { // 後端API的meta,線上演示用。
      id: 'moduleId',
      index: {},
      isClear: false
    }
  },
  init: (help) => {
    // 資料庫建立好了
    console.log('inti事件觸發:indexedDB 建立完成 ---- help:', help)
  }
}
  • dbFlag
    一個專案裡面可能需要同時使用多個 indexedDB 的資料庫,那麼就需要一個標識區分一下,dbFlag 就是區分標識。

  • stores
    物件倉庫的說明,在 onupgradeneeded 事件裡面依據這個資訊建立物件倉庫。

  • init
    indexedDB 都準備好之後的回撥函式。

直接使用

import IndexedDB from '../../../packages/nf-ws-indexeddb/help.js'

// 建立例項
const help = new IndexedDB(dbInfo)

// 新增物件的測試
const add = () => {
  // 定義一個物件
  const model = {
    id: new Date().valueOf(),
    name: 'test'
  }
  // 新增
  help.addModel('menuMeta', model).then((res) => {
    console.log('新增成功!', res) // 返回物件ID
  })
}
  • 定義一個資料庫描述資訊
  • 生成 help 的例項
  • 使用 help.addModel 新增物件

做個“外殼”套個娃

檢查一下程式碼,發現有幾個小問題:

  • 每次使用都需要例項化一個help嗎?是不是有點浪費?
  • 物件倉庫名還需要寫字串,萬一寫錯了怎麼辦?
  • help.xxxModel(xxx,xxx,xxx) 是不是有點麻煩?

所以我們需要在套一個外殼,讓使用更方便。


import IndexedDB from './help.js'

/**
 * 把 indexedDB 的help 做成外掛的形式
 */
export default {
  _indexedDBFlag: Symbol('nf-indexedDB-help'),
  _help: {}, // 訪問資料庫的例項
  _store: {}, // 存放物件,實現 foo.addModel(obj)的功能 
  
  createHelp (info) {
    let indexedDBFlag = this._indexedDBFlag
    if (typeof info.dbFlag === 'string') {
      indexedDBFlag = Symbol.for(info.dbFlag)
    } else if (typeof info.dbFlag === 'symbol') {
      indexedDBFlag = info.dbFlag
    }
    // 連線資料庫,獲得例項。
    const help = new IndexedDB(info)
    // 存入靜態物件,以便於支援儲存多個不同的例項。
    this._help[indexedDBFlag] = help // help
    this._store[indexedDBFlag] = {} // 倉庫變物件

    // 把倉庫變成物件的形式,避免寫字串的倉庫名稱
    for (const key in info.stores) {
      this._store[indexedDBFlag][key] = {
        put: (obj) => {
          let _id = obj
          if (typeof obj === 'object') {
            _id = obj[info.stores[key].id]
          }
          return help.updateModel(key, obj, _id)
        },
        del: (obj) => {
          let _id = obj
          if (typeof obj === 'object') {
            _id = obj[info.stores[key].id]
          }
          return help.deleteModel(key, _id)
        },
        add: (obj) => help.addModel(key, obj),
        get: (id = null) => help.getModel(key, id)
      }
    }
  },

  // 獲取靜態物件裡的資料庫例項
  useDBHelp (_dbFlag) {
    let flag = this._indexedDBFlag
    if (typeof _dbFlag === 'string') {
      flag = Symbol.for(_dbFlag)
    } else if (typeof _dbFlag === 'symbol') {
      flag = _dbFlag
    }
    return this._help[flag]
  },
  useStore (_dbFlag) {
    let flag = this._indexedDBFlag
    if (typeof _dbFlag === 'string') {
      flag = Symbol.for(_dbFlag)
    } else if (typeof _dbFlag === 'symbol') {
      flag = _dbFlag
    }
    return this._store[flag]
  }
}

首先,這是一個靜態物件,可以存放 help 的例項,可以實現全域性訪問的效果。

以前是 使用 provide / inject 儲存的,但是發現有點不太方便,也不是十分必要,所以改成了靜態物件的方式。

然後根據建表的資訊,建立倉庫的物件,把字串的倉庫名稱變成物件的形式,這樣就方便多了。

為啥是 “useDBHelp”呢,因為要和 webSQL的 help 加以區分。

使用的時候就變成了這樣:


// 把倉庫當做“物件”
const  { menuMeta }  = dbInstall.useStore(dbInfo.dbFlag)

// 新增物件
const add = () => {
  const t1 = window.performance.now()
  console.log('\n -- 準備新增物件 --:', t1)
  const model = {
    id: new Date().valueOf(),
    name: 'test-。'
  }
  menuMeta.add(model).then((res) => {
    const t2 = window.performance.now()
    console.log('新增成功!', res, '用時:', t2 - t1, '\n')
  })
}

這樣的話,就方便多了。物件倉庫名.xxx(oo) 就可以,程式碼簡潔了很多。

進一步套娃

上面是把物件倉庫看做了“物件”,然後實現增刪改查,那麼能不能讓object 本身實現增刪改查呢?

既然封裝到這一步了,我們可以再前進一下,使用 js的原型 實現 object 的增刪改查。

// 給 model 加上增刪改查的函式
for (const key in info.stores) {
  this._store[indexedDBFlag][key] = {
    createModel: (model) => {
      function MyModel (_model) {
        for (const key in _model) {
          this[key] = _model[key]
        }
      }
      MyModel.prototype.add = function (tran = null) {
        return help.addModel(key, this, tran)
      }
      MyModel.prototype.save = function (tran = null) {
        const _id = this[info.stores[key].id]
        return help.setModel(key, this, _id, tran)
      }
      MyModel.prototype.load = function (tran = null) {
        return new Promise((resolve, reject) => {
          // 套個娃
          const _id = this[info.stores[key].id]
          help.getModel(key, _id, tran).then((res) => {
            Object.assign(this, res)
            resolve(res)
          })
        })
      }
      MyModel.prototype.del = function (tran = null) {
        const _id = this[info.stores[key].id]
        return help.delModel(key, _id, tran)
      }
      const re = new MyModel(model)
      return reactive(re)
    }
  }
}

首先給物件倉庫加一個 “createModel”函式,用於把 object 和物件倉庫掛鉤,然後用原型掛上增刪改查的函式,最後 new 一個例項返回。

使用方式:


// 物件倉庫,建立一個例項,reactive 形式
const testModel = menuMeta.createModel({
  id: 12345,
  name: '物件自己save'
})
 
// 物件直接儲存
const mSave = () => {
  testModel.name = '物件自己save' + window.performance.now()
  testModel.save().then((res) => {
    // 儲存完成
  })
}

因為加上了 reactive,所以可以自帶響應性。
這樣是不是很像“充血實體類”了?

id 值建議不要修改,雖然可以改,但是總感覺改了的話比較彆扭。

統一“出口”

雖然用 help 帶上了幾個常規操作,但是出口還是不夠統一,像 Vue 那樣,就一個出口是不是很方便呢?所以我們也要統一一下:

storage.js

// 引入各種函式,便於做成npm包
// indexedDB 部分
import dbHelp from './nf-ws-indexeddb/help.js'
import dbInstall from './nf-ws-indexeddb/install.js'

// indexedDB 部分
const dbCreateHelp = (info) => dbInstall.createHelp(info)
const useDBHelp = (_dbFlag) => dbInstall.useDBHelp(_dbFlag)
const useStores = (_dbFlag) => dbInstall.useStores(_dbFlag)

export {
  // indexedDB 部分
  dbHelp, // indexedDB 的 help
  dbCreateHelp, // 建立 help 例項,初始化設定
  useDBHelp, // 元件裡獲取 help 的例項
  useStores // 元件裡獲取物件倉庫,方便實現增刪改查
}

這樣也便於我們打包釋出到npm。

在 vue 裡面使用

基本工作都作好了,就剩最後一個問題了,在 Vue3 裡面如何使用呢?

我們可以仿造一下 vuex 的使用方式,先建立一個 js檔案,實現統一設定。

store-project/db.js

// 引入 indexedDB 的 help
import { dbCreateHelp } from '../../packages/storage.js'

// 引入資料庫資料
const db = {
  dbName: 'nf-project-meta',
  ver: 5
}

/**
 * 設定
 */
export default function setup (callback) {
  const install = dbCreateHelp({
    // dbFlag: 'project-meta-db',
    dbConfig: db,
    stores: { // 資料庫裡的表
      moduleMeta: { // 模組的meta {按鈕,列表,分頁,查詢,表單若干}
        id: 'moduleId',
        index: {},
        isClear: false
      },
      menuMeta: { // 選單用的meta
        id: 'id',
        index: {},
        isClear: false
      },
      serviceMeta: { // 後端API的meta,線上演示用。
        id: 'moduleId',
        index: {},
        isClear: false
      },
      testIndex: { // 測試索引和查詢。
        id: 'moduleId',
        index: {
          kind: false,
          type: false
        },
        isClear: false
      }
    },
    // 加入初始資料
    init (help) {
      if (typeof callback === 'function') {
        callback(help)
      }
    }
  })
  return install
}

然後在 main.js 裡面呼叫,因為這是最早執行程式碼的地方,可以第一時間建立資料庫。


// 引入 indexedDB 的help
import dbHelp from './store-project/db.js'

dbHelp((help) => {
  // indexedDB 準備好了
  console.log('main裡面獲取 indexedDB 的help', help)
})

同時可以把 help 的例項存入靜態物件裡面。

其實一開始是使用 provide 注入的,但是發現不是太適合,因為在main.js這個層級裡面無法使用inject讀取出來,這樣的話,和狀態等的操作就不太方便。

所以乾脆放在靜態物件裡面好了,任何地方都可以訪問到。

並不需要使用 use 掛載到 App 上面。

索引和查詢

由於篇幅有限,這裡就先不介紹了,如果大家感興趣的話,可以在寫一篇補充一下。

原始碼

封裝前端儲存
https://gitee.com/naturefw/nf-web-storage

線上演示

https://naturefw.gitee.io/vite2-vue3-demo

安裝方式

yarn add nf-web-storage

相關文章