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