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

作者 | 星塵starx
2021 年,如果你的前端應(yīng)用,需要在瀏覽器上保存數(shù)據(jù),有三個(gè)主流方案可以選擇:
Cookie:上古時(shí)代就已存在,但能應(yīng)用的業(yè)務(wù)場(chǎng)景比較有限LocalStorage:使用簡(jiǎn)單靈活,但是容量只有 10Mb,且儲(chǔ)存 JS 對(duì)象存在問(wèn)題IndexedDB:算得上真正意義上的數(shù)據(jù)庫(kù),功能強(qiáng)大,但坑異常多,使用麻煩,古老的 API 設(shè)計(jì)放在現(xiàn)代前端工程中總有種格格不入的感覺(jué)
我在大三的時(shí)候,曾經(jīng)用 IndexedDB 寫過(guò)一個(gè)背單詞 App,當(dāng)時(shí)就有把 IndexedDB 封裝一遍的想法,但是由于學(xué)業(yè)緊張,后來(lái)就擱置了
最近,我終于有了空閑時(shí)間,于是撿起了當(dāng)年的想法,開(kāi)始嘗試用 TypeScript 把 IndexedDB 封裝一遍,把坑一個(gè)個(gè)填上,做成一個(gè)開(kāi)發(fā)者友好的庫(kù),并開(kāi)源出來(lái),上傳至 npm
拍腦袋后,我決定把這個(gè)項(xiàng)目命名為 GoDB.js
GoDB.js
GoDB 的出現(xiàn),讓你即使你不了解瀏覽器數(shù)據(jù)庫(kù) IndexedDB,也能把它用的行云流水,從而把關(guān)注點(diǎn)放到業(yè)務(wù)上面去
畢竟要用好 IndexedDB,你需要翻無(wú)數(shù)遍 MDN,而 GoDB 替你吃透了 MDN,從而讓你把 IndexedDB 用的更好的同時(shí),操作還更簡(jiǎn)單了
當(dāng)前項(xiàng)目處于 Alpha 階段(版本 0.4.x),意味著之后隨時(shí)可能會(huì)有 breaking changes,在正式版(1.0.0 及以后)發(fā)布之前,不要把這個(gè)項(xiàng)目用到任何嚴(yán)肅的場(chǎng)景下
安裝
首先需要安裝,這里默認(rèn)你使用了 webpack、gulp 等打包工具,或在 vue、react 等項(xiàng)目中
npm install godb
在第一個(gè)正式版發(fā)布后,還會(huì)提供 CDN 的引入方式,敬請(qǐng)期待~
簡(jiǎn)單上手
操作非常簡(jiǎn)單,增、刪、改、查各只需要一行代碼:
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()都返回完整數(shù)據(jù)Table.delete()不返回?cái)?shù)據(jù)(返回undefined)
需要注意的就是,put(obj) 方法中的 obj 需要包含 id,否則就等價(jià)于 add(obj)
上面的 demo 中,get() 得到的 luke 對(duì)象包含 id,因此是修改操作
之后會(huì)引入一個(gè) update 方法來(lái)改進(jìn)這個(gè)問(wèn)題
也可以一次性添加多條數(shù)據(jù):
const data = [
{ name: 'luke', age: 22 },
{ name: 'elaine', age: 23 }
];
user.addMany(data)
.then(() => user.consoleTable());
Table.consoleTable()
這里用了一個(gè) Table.consoleTable() 的方法
雖然 chrome 開(kāi)發(fā)者工具內(nèi)就能看到表內(nèi)所有數(shù)據(jù),但這個(gè)方法好處是可以在需要的時(shí)候打印出數(shù)據(jù),方便 debug
注意:這個(gè)方法是異步的,因?yàn)樾枰跀?shù)據(jù)庫(kù)里把數(shù)據(jù)庫(kù)取出來(lái);異步意味著緊接在它后面的代碼,可能會(huì)在打印出結(jié)果之前執(zhí)行,如果不希望出現(xiàn)這種情況,使用 await 或 Promise.then 即可
addMany(data) 方法:
嚴(yán)格按照 data的順序添加返回 id 的數(shù)組,與 data順序一致
之所以單獨(dú)寫個(gè) addMany,而不在 add 里加一個(gè)判斷數(shù)組的邏輯,是因?yàn)橛脩粝胍?,可能就是添加一個(gè)數(shù)組到數(shù)據(jù)庫(kù)中
注意:addMany 和 add 不要同步調(diào)用,如果在 addMany 正在執(zhí)行時(shí)調(diào)用 add,可能會(huì)導(dǎo)致數(shù)據(jù)庫(kù)里的順序不符合預(yù)期,請(qǐng)?jiān)?nbsp;addMany 的回調(diào)完成后再調(diào)用 add(未來(lái)可能會(huì)引入一個(gè)隊(duì)列來(lái)修復(fù)這個(gè)問(wèn)題)
Table.find()
如果你想在數(shù)據(jù)庫(kù)中查找數(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: 'elaine', age: 23 }
});
Table.find(fn)接受一個(gè)函數(shù)fn作為參數(shù),這個(gè)函數(shù)的返回值應(yīng)當(dāng)為true和false
用法其實(shí)和 JS 數(shù)組方法 Array.find() 如出一轍
這個(gè)方法在內(nèi)部會(huì)從頭遍歷整個(gè)表(使用 IndexedDB 的 Cursor),然后把每一次的結(jié)果放進(jìn) fn 執(zhí)行,如果 fn 的返回值為 true(內(nèi)部使用 if(fn()) 判斷),就返回當(dāng)前的結(jié)果,停止遍歷
這個(gè)方法只會(huì)返回第一個(gè)滿足條件的值,如果需要返回所有滿足條件的值,請(qǐng)使用 Table.findAll(),用法與 Table.find() 一致,但是會(huì)返回一個(gè)數(shù)組,包含所有滿足條件的值
Schema
如果你希望數(shù)據(jù)庫(kù)的結(jié)構(gòu)更嚴(yán)格一點(diǎn),也可以添加 schema
GoDB 會(huì)根據(jù) schema 建立 IndexedDB 數(shù)據(jù)庫(kù)索引,給字段添加特性
import GoDB from 'godb';
// 定義數(shù)據(jù)庫(kù)結(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 data = {
name: 'luke'
age: 22
};
user.add(data) // 沒(méi)問(wèn)題
.then(() => user.get({ name: 'luke' })) // 定義schema后,就可以用id以外的字段獲取數(shù)據(jù)
.then(luke => user.add(luke)) // 報(bào)錯(cuò),name 重復(fù)了
如上面的例子,指定了 schema 后
定義了 schema,因此 get()和delete()中可以使用id以外的字段搜索了,否則只能傳入id指定了 user.name這一項(xiàng)是唯一的,因此無(wú)法添加重復(fù)的name
當(dāng)然,你也可以在 table 那定義 schema:
const testDB = new GoDB('testDB');
const user = testDB.table('user', {
name: {
type: String,
unique: true
},
age: Number
});
但這種方式的缺點(diǎn)是,如果定義 table 發(fā)生在連接數(shù)據(jù)庫(kù)之后,GoDB 會(huì)先發(fā)起一個(gè) IDBVersionChange 的事件,導(dǎo)致 IndexedDB 數(shù)據(jù)庫(kù)版本升級(jí),此時(shí)如果有不止一個(gè) GoDB 實(shí)例連接了同樣的數(shù)據(jù)庫(kù),版本升級(jí)將會(huì)被 block,導(dǎo)致建表失敗
要避免這個(gè)問(wèn)題倒是很簡(jiǎn)單,把所有獲取 table 的操作緊接在 new GoDB() 之后(保證這兩操作是同步而非異步執(zhí)行的)就可以,這樣可以確保所有 table 都在連接完成之前獲取到(JS 的事件循環(huán)特性)
當(dāng)然,最佳實(shí)踐還是在連接數(shù)據(jù)庫(kù)時(shí)就定義好所有的 schema,這樣 GoDB 會(huì)在應(yīng)用初始化時(shí)就建立好所有的數(shù)據(jù)庫(kù)和表,并建立字段的索引
Table.get() 與 Table.find() 區(qū)別
get() 使用數(shù)據(jù)庫(kù)索引搜索,性能更高,但是需要定義 schema,才能使用 id 以外的索引進(jìn)行搜索
而 find() 利用函數(shù)判斷遍歷全表,使用上更靈活,但是性能相對(duì)沒(méi)有 get() 好
關(guān)于 schema:
部分同學(xué)或許會(huì)發(fā)現(xiàn),上面定義 schema 的方式有點(diǎn)眼熟,沒(méi)錯(cuò),正是參考了 mongoose
定義數(shù)據(jù)庫(kù)的字段時(shí),可以只指明數(shù)據(jù)類型,如上面的 age: Number也可以使用一個(gè)對(duì)象,里面除了定義數(shù)據(jù)類型 type,也指明這個(gè)字段是不是唯一的(unique: true),之后會(huì)添加更多可選屬性,如用來(lái)指定字段默認(rèn)值的default,和指向別的表的索引ref
不定義 schema 時(shí),GoDB 使用起來(lái)就像 MongoDB 一樣,可以靈活添加數(shù)據(jù);區(qū)別是 Mongodb 中,每條數(shù)據(jù)的唯一標(biāo)識(shí)符是 _id,而 GoDB 是 id
雖然這樣做的問(wèn)題是,用戶使用不規(guī)范的話(如每次添加的數(shù)據(jù)結(jié)構(gòu)都不一樣),久而久之可能會(huì)使得數(shù)據(jù)庫(kù)的字段特別多,維護(hù)和使用起來(lái)非常麻煩
定義 schema 后,你將無(wú)法給 schema 內(nèi)沒(méi)有的字段添加數(shù)據(jù)(在寫文檔的時(shí)候還沒(méi)有實(shí)現(xiàn)這個(gè)功能,即使 schema 不符合也能加,下個(gè)版本會(huì)安排上)
因此推薦在項(xiàng)目中,首先定義好 schema,這樣不管是維護(hù)性上,還是性能上,都要更勝一籌
使用 await
由于 GoDB 的 API 都是 Promise 的,因此在很多場(chǎng)景下可以使用 await,使代碼更簡(jiǎn)潔,同時(shí)拓寬使用場(chǎng)景(await 可以很方便用在循環(huán)內(nèi),而 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 非必須,這里為了防止打印順序出錯(cuò)
// 查:
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,會(huì)在控制臺(tái)打印出下面的內(nèi)容:

API 設(shè)計(jì)
因?yàn)椤高B接數(shù)據(jù)庫(kù)」和「連接表」這兩個(gè)操作是異步的,在設(shè)計(jì)之初,曾經(jīng)有兩個(gè) API 方案,區(qū)別在于:要不要把這兩個(gè)操作,做為異步 API 提供給用戶
這里討論的不是「API 如何命名」這樣的細(xì)節(jié),而是「API 的使用方式」,因?yàn)檫@會(huì)直接影響到用戶使用 GoDB 時(shí)的業(yè)務(wù)代碼編寫方式
以連接數(shù)據(jù)庫(kù) -> 添加一條數(shù)據(jù)的過(guò)程為例
設(shè)計(jì)一:提供異步特性
GitHub 上大多數(shù)開(kāi)源的 IndexedDB 封裝庫(kù)都是這么做的
import GoDB from 'godb';
// 連接數(shù)據(jù)庫(kù)是異步的
GoDB.open('testDB')
.then(testDB => testDB.table('user')) // 連接表也需要異步
.then(user => {
user.add({
name: 'luke',
age: 22
});
});
});
這樣的優(yōu)點(diǎn)是,工作流程一目了然,畢竟對(duì)數(shù)據(jù)庫(kù)的操作,要放在連接數(shù)據(jù)庫(kù)之后
但是,這種設(shè)計(jì)不適合工程化的前端項(xiàng)目!
因?yàn)?,所有增刪改查等操作,都需要用戶,手動(dòng)放到連接完成的異步回調(diào)之后,否則無(wú)法知道操作時(shí)有沒(méi)有連上數(shù)據(jù)庫(kù)和表
導(dǎo)致每次需要操作數(shù)據(jù)庫(kù)時(shí),都要先打開(kāi)數(shù)據(jù)庫(kù)一遍數(shù)據(jù)庫(kù),才能繼續(xù)
即使你預(yù)先定義一個(gè)全局的連接,你在之后想要使用它時(shí),如果不包一層 Promise,是無(wú)法確定數(shù)據(jù)庫(kù)和表,在使用時(shí)有沒(méi)有連接上的
以 Vue 為例,如果你在全局環(huán)境(比如 Vuex)定義了一個(gè)連接:
import GoDB from 'godb';
new Vuex.Store({
state: {
godb: await GoDB.open('testDB') // 不加 await 返回的就是 Promise 了
}
});
這樣,在 Vue 的任何一個(gè)組件中,我們都能訪問(wèn)到 GoDB 實(shí)例
問(wèn)題來(lái)了,在你的組件中,如果你想在組件初始化時(shí),比如 created 和 mounted 這樣的鉤子函數(shù)中(React 中就是 ComponentDidMount),去訪問(wèn)數(shù)據(jù)庫(kù):
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!
});
}
});
你會(huì)發(fā)現(xiàn),如果這個(gè)組件在 App 初始化時(shí)就被加載,在組件 mounted 函數(shù)觸發(fā)時(shí),本地?cái)?shù)據(jù)庫(kù)可能根本就沒(méi)有連接上!(連接數(shù)據(jù)庫(kù)這樣的操作,最典型的執(zhí)行場(chǎng)景就是在組件加載時(shí))
解決辦法是,在每一個(gè)需要操作數(shù)據(jù)庫(kù)的地方,都定義一個(gè)連接:
import GoDB from 'godb';
new Vue({
mounted() {
GoDB.open('testDB')
.then(testDB => testDB.table('user'))
.then(user => {
user.add({
name: 'luke',
age: 22
});
});
}
});
這樣不僅代碼又臭又長(zhǎng),性能低下(每次操作都需要先連接),在需要連接本地?cái)?shù)據(jù)庫(kù)的組件多了后,維護(hù)起來(lái)更是一場(chǎng)噩夢(mèng)
簡(jiǎn)而言之,就是這個(gè)方案,在工程化前端的不同組件中,需要在每次操作之前,都連一遍數(shù)據(jù)庫(kù),否則無(wú)法確保組件加載時(shí),已經(jīng)連接上了 IndexedDB
設(shè)計(jì)二:隱藏連接的異步特性
我最終采用了這個(gè)方案,對(duì)開(kāi)發(fā)者而言,甚至感覺(jué)不到「連接數(shù)據(jù)庫(kù)」和「連接表」這兩個(gè)操作是異步的
const testDB = new GoDB('testDB');
const user = testDB.table('user');
user.add({
name: 'luke',
age: 22
})
.then(luke => console.log(luke));
這樣使用上非常自然,開(kāi)發(fā)者并不需要關(guān)心操作時(shí)有沒(méi)有連上數(shù)據(jù)庫(kù)和表,只需要在操作后的回調(diào)內(nèi)寫好自己的邏輯就可以
但是,這個(gè)方案的缺點(diǎn)就是開(kāi)發(fā)起來(lái)比較麻煩(嘿嘿,麻煩自己,方便用戶)
因?yàn)?nbsp;new Codb('testDB') 內(nèi)部的連接數(shù)據(jù)庫(kù)的操作,實(shí)際上是異步的(因?yàn)?IndexedDB 的原生 API 就是異步的設(shè)計(jì))
在連接數(shù)據(jù)庫(kù)的操作發(fā)出去后,即使還沒(méi)連接上,下面的 testDB.table('user') 和 user.add() 也會(huì)先開(kāi)始執(zhí)行
也就是說(shuō),之后的「獲取 user 表」 和 「添加一條數(shù)據(jù)」實(shí)際上會(huì)先于「連上數(shù)據(jù)庫(kù)」這個(gè)過(guò)程執(zhí)行,如果實(shí)現(xiàn)該 API 設(shè)計(jì)時(shí)未處理這個(gè)問(wèn)題,上面的示例代碼肯定會(huì)報(bào)錯(cuò)
而要處理這個(gè)問(wèn)題,我用到了下面兩個(gè)方法:
在每次需要連上數(shù)據(jù)庫(kù)的操作中(比如 add()),先拿到數(shù)據(jù)庫(kù)的連接,再進(jìn)行操作使用隊(duì)列 Queue,在還未連接時(shí),把需要連接數(shù)據(jù)庫(kù)的操作放進(jìn)隊(duì)列,等連接完成,再執(zhí)行該隊(duì)列
具體而言,就是
在 GoDB的 class 中定義一個(gè)getDB(callback),用來(lái)獲取 IndexedDB 連接實(shí)例增刪改查中,都調(diào)用 getDB,在callback獲取到 IndexedDB 的連接實(shí)例后再進(jìn)行操作getDB中使用一個(gè)隊(duì)列,如果數(shù)據(jù)庫(kù)還沒(méi)連接上,就把callback放進(jìn)隊(duì)列,在連接上后,執(zhí)行這個(gè)隊(duì)列中的函數(shù)連接完成時(shí),直接把 IndexedDB 連接實(shí)例傳進(jìn) callback執(zhí)行即可
在調(diào)用 getDB 時(shí),可能有三種狀態(tài)(其實(shí)還有個(gè)數(shù)據(jù)庫(kù)已關(guān)閉的狀態(tài),這里不討論):
剛初始化,未發(fā)起和 IndexedDB 的連接 正在連接 IndexedDB,但還未連上 已經(jīng)連上,此時(shí)已經(jīng)有 IndexedDB 的連接實(shí)例
第一種狀態(tài)只在第一次執(zhí)行 getDB 時(shí)觸發(fā),因?yàn)橐坏﹪L試建立連接就進(jìn)入下一個(gè)狀態(tài)了;第一次執(zhí)行被我放到了 GoDB 類的構(gòu)造函數(shù)中
第三種狀態(tài)時(shí),也就是已經(jīng)連上數(shù)據(jù)庫(kù)后,直接把連接實(shí)例傳進(jìn) callback 執(zhí)行即可
關(guān)鍵是處理第二種狀態(tài),此時(shí)正在連接數(shù)據(jù)庫(kù),但還未連上,無(wú)法進(jìn)行增刪改查:
const testDB = new GoDB('testDB');
const user = testDB.table('user');
user.add({ name: 'luke' }); // 此時(shí)數(shù)據(jù)庫(kù)正在連接,還未連上
user.add({ name: 'elaine' }); // 此時(shí)數(shù)據(jù)庫(kù)正在連接,還未連上
testDB.onOpened = () => { // 數(shù)據(jù)庫(kù)連接成功的回調(diào)
user.add({ name: 'lucas' }); // 此時(shí)已連接
}
上面的例子,頭兩個(gè) add 執(zhí)行時(shí)其實(shí)數(shù)據(jù)庫(kù)并未連接上
那要如何操作,才能保證正常添加,并且 luke 和 elaine 在 lucas 進(jìn)入數(shù)據(jù)庫(kù)的順序,和代碼順序一致呢?
答案是使用隊(duì)列 Queue,把數(shù)據(jù)庫(kù)還未連上時(shí)的 add 操作加進(jìn)隊(duì)列,在連接成功時(shí),按先進(jìn)先出的順序執(zhí)行
這樣,用戶就不需要關(guān)心,操作時(shí)數(shù)據(jù)庫(kù)是否已經(jīng)連上了(只需要關(guān)注異步回調(diào)即可),GoDB 幫你在幕后做好了這一切
注意之所以使用 callback 而不是 Promise,是因?yàn)?JS 中的回調(diào)既可以是異步的,也可以是同步的
而連接成功,已經(jīng)有連接實(shí)例后,直接同步返回連接實(shí)例更好,沒(méi)必要再使用異步
還是以 Vue 為例,如果我們?cè)?Vuex(全局變量)中添加連接實(shí)例:
import GoDB from 'godb';
new Vuex.Store({
state: {
godb: new GoDB('testDB')
}
});
這樣,在所有組件中,我們都可以使用同一個(gè)連接實(shí)例:
new Vue({
computed: {
// 把全局實(shí)例變?yōu)榻M件屬性
godb() {
return this.$store.state.godb;
}
},
mounted() {
this.godb.table('user').add({
name: 'luke',
age: 22
})
.then(luke => console.log(luke));
}
});
總結(jié)這個(gè)方案的優(yōu)點(diǎn):
性能更高(可以全局共享一個(gè)連接實(shí)例) 代碼更簡(jiǎn)潔 最關(guān)鍵的,心智負(fù)擔(dān)低了很多 -- 直接操作即可,無(wú)需關(guān)注數(shù)據(jù)庫(kù)的連接狀態(tài)
缺點(diǎn):對(duì) GoDB.js 的開(kāi)發(fā)更麻煩,不是簡(jiǎn)單把 IndexedDB 封裝一層 Promise 就行
因此,我最終采用了這個(gè)方案,畢竟麻煩我一個(gè),方便你我他,優(yōu)點(diǎn)遠(yuǎn)遠(yuǎn)蓋過(guò)了缺點(diǎn)
如果對(duì)實(shí)現(xiàn)好奇的話,可以去閱讀源碼,當(dāng)前只是實(shí)現(xiàn)了基本的 CRUD,源碼暫時(shí)還不復(fù)雜
總結(jié)
如果想了解更多,可以訪問(wèn)項(xiàng)目官網(wǎng)
godb-js.github.io
如果你有任何建議或意見(jiàn),請(qǐng)?jiān)谠u(píng)論區(qū)留言,我會(huì)認(rèn)證讀每一個(gè)反饋
點(diǎn)擊領(lǐng)?。篔ava基礎(chǔ)核心知識(shí)大總結(jié).pdf
喜歡的這里報(bào)道
↘↘↘
