一行代碼,搞定瀏覽器數(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)年的想法,開始嘗試用 TypeScript 把 IndexedDB 封裝一遍,把坑一個個填上,做成一個開發(fā)者友好的庫,并開源出來,上傳至 npm
拍腦袋后,我決定把這個項目命名為 Godb.js
Godb.js
Godb.js 的出現(xiàn),讓你即使你不了解瀏覽器數(shù)據(jù)庫 IndexedDB,也能把它用的行云流水,從而把關(guān)注點放到業(yè)務(wù)上面去
畢竟要用好 IndexedDB,你需要翻無數(shù)遍 MDN,而 Godb 替你吃透了 MDN,從而讓你把 IndexedDB 用的更好的同時,操作還更簡單了
當(dāng)前項目處于 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 方法來改進(jìn)這個問題
也可以一次性添加多條數(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ù)庫
注意:addMany 和 add 不要同步調(diào)用,如果在 addMany 正在執(zhí)行時調(diào)用 add,可能會導(dǎo)致數(shù)據(jù)庫里的順序不符合預(yù)期,請在 addMany 的回調(diào)完成后再調(diào)用 add
Table.consoleTable()
這里用了一個 Table.consoleTable() 的方法,它會在瀏覽器的控制臺打印出下面的內(nèi)容:

這里的 (index) 就是 id
雖然 chrome 開發(fā)者工具內(nèi)就能看到表內(nèi)所有數(shù)據(jù),但這個方法好處是可以在需要的時候打印出數(shù)據(jù),方便 debug
注意:這個方法是異步的,因為需要在數(shù)據(jù)庫里把數(shù)據(jù)庫取出來;異步意味著緊接在它后面的代碼,可能會在打印出結(jié)果之前執(zhí)行,如果不希望出現(xiàn)這種情況,使用 await 或 Promise.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ù)的返回值為 true 和 false
這個方法在內(nèi)部會從頭遍歷整個表(使用 IndexedDB 的 Cursor),然后把每一次的結(jié)果放進(jìn) 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
Table.get() 與 Table.find() 區(qū)別
get() 使用數(shù)據(jù)庫索引搜索,性能更高,而 find() 是遍歷全表,使用上靈活
關(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,而 Godb 是 id
雖然這樣做的問題是,IndexedDB 畢竟還是結(jié)構(gòu)化的,用戶使用不規(guī)范的話(如每次添加的數(shù)據(jù)結(jié)構(gòu)都不一樣),久而久之可能會使得數(shù)據(jù)庫的字段特別多,且不同數(shù)據(jù)中沒用到的字段都是空的,導(dǎo)致浪費,影響性能
定義 Schema 后,Godb 使用起來就像 MySQL 一樣,如果添加 Schema 沒有的字段,或者是字段類型不符合定義,會報錯(在寫文檔的時候還沒有實現(xiàn)這個功能,即使 Schema 不符合也能加,下個版本會安排上)
因此推薦在項目中,定義好 schema,這樣不管是維護(hù)性上,還是性能上,都要更勝一籌
另一個使用 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)容:

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 實例
問題來了,在你的組件中,如果你想在組件初始化時,比如 created 和 mounted 這樣的鉤子函數(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ù)庫的組件多了后,維護(hù)起來更是一場噩夢
簡而言之,就是這個方案,在工程化前端的不同組件中,需要在每次操作之前,都連一遍數(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ù)庫的連接,再進(jìn)行操作使用隊列 Queue,在還未連接時,把需要連接數(shù)據(jù)庫的操作放進(jìn)隊列,等連接完成,再執(zhí)行該隊列
具體而言,就是
在 Godb的 class 中定義一個getDB(callback),用來獲取 IndexedDB 連接實例增刪改查中,都調(diào)用 getDB,在callback獲取到 IndexedDB 的連接實例后再進(jìn)行操作getDB中使用一個隊列,如果數(shù)據(jù)庫還沒連接上,就把callback放進(jìn)隊列,在連接上后,執(zhí)行這個隊列中的函數(shù)連接完成時,直接把 IndexedDB 連接實例傳進(jìn) callback執(zhí)行即可
在調(diào)用 getDB 時,可能有三種狀態(tài)(其實還有個數(shù)據(jù)庫已關(guān)閉的狀態(tài),這里不討論):
剛初始化,未發(fā)起和 IndexedDB 的連接 正在連接 IndexedDB,但還未連上 已經(jīng)連上,此時已經(jīng)有 IndexedDB 的連接實例
第一種狀態(tài)只在第一次執(zhí)行 getDB 時觸發(fā),因為一旦嘗試建立連接就進(jìn)入下一個狀態(tài)了;第一次執(zhí)行被我放到了 Godb 類的構(gòu)造函數(shù)中
第三種狀態(tài)時,也就是已經(jīng)連上數(shù)據(jù)庫后,直接把連接實例傳進(jìn) callback 執(zhí)行即可
關(guān)鍵是處理第二種狀態(tài),此時正在連接數(shù)據(jù)庫,但還未連上,無法進(jìn)行增刪改查:
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ù)庫并未連接上
那要如何操作,才能保證正常添加,并且 luke 和 elaine 在 lucas 進(jìn)入數(shù)據(jù)庫的順序和代碼一致呢?
答案是使用隊列 Queue,把兩個 add 操作加進(jìn)隊列,在連接成功時,按先進(jìn)先出的順序執(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
