前言
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 用的更好的同時,操作還更簡單了
本文發(fā)布時星立,項目處于 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
get() vs find():
注意 get()
和 find()
的區(qū)別浦译,如果 schema
中定義了字段棒假,get()
的查找效率會高于 find()
,且數(shù)據(jù)量越大差距越大精盅,因為 find()
的實現(xiàn)方式是遍歷整個表帽哑,而 get()
是使用索引進(jìn)行查找
只有預(yù)先定義了 schema
,Godb
才會給字段建立索引叹俏,因此建議在工程實踐中妻枕,盡量先定義好數(shù)據(jù)庫 schema
關(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
电爹,這樣不管是維護性上蔫仙,還是性能上,都要更勝一籌
另一個使用 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ù)庫可能根本就沒有連接上U髯帧(連接數(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ù)庫的組件多了后氮昧,維護起來更是一場噩夢
簡而言之框杜,就是這個方案浦楣,在工程化前端的不同組件中,需要在每次操作之前霸琴,都連一遍數(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~