2021 年,若是你的前端應用,須要在瀏覽器上保存數據,有三個主流方案能夠選擇:javascript
Cookie
:上古時代就已存在,但能應用的業務場景比較有限LocalStorage
:使用簡單靈活,可是容量只有 10Mb,且儲存 JS 對象存在問題IndexedDB
:算得上真正意義上的數據庫,功能強大,但坑異常多,使用麻煩,古老的 API 設計放在現代前端工程中總有種格格不入的感受關於這三者的區別與應用場景,能夠參考我寫的 深刻淺出前端本地儲存前端
我在大三的時候,曾經用 IndexedDB
寫過一個背單詞 App,當時就有把 IndexedDB
封裝一遍的想法,可是因爲學業緊張,後來就擱置了vue
最近,我終於有了空閒時間,因而撿起了當年的想法,開始嘗試用 TypeScript
把 IndexedDB
封裝一遍,把坑一個個填上,作成一個開發者友好的庫,並開源出來,上傳至 npmjava
拍腦殼後,我決定把這個項目命名爲 GoDB.js
react
GoDB
的出現,讓你即便你不瞭解瀏覽器數據庫 IndexedDB,也能把它用的行雲流水,從而把關注點放到業務上面去webpack
畢竟要用好 IndexedDB,你須要翻無數遍 MDN,而 GoDB
替你吃透了 MDN,從而讓你把 IndexedDB 用的更好的同時,操做還更簡單了git
當前項目處於 Alpha 階段(版本 0.4.x),意味着以後隨時可能會有 breaking changes,在正式版(1.0.0 及之後)發佈以前,不要把這個項目用到任何嚴肅的場景下github
項目GitHub: github.com/chenstarx/G…web
若是以爲不錯的話就點個 Star 吧~chrome
項目完整文檔與官網正在緊張開發中,現階段能夠經過下面的 demo 來嚐鮮
首先須要安裝,這裏默認你使用了 webpack、gulp 等打包工具,或在 vue、react 等項目中
npm install godb
複製代碼
在第一個正式版發佈後,還會提供 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(luke => user.get(luke.id)) // 查
.then(luke => user.put({ ...luke, age: 23 })) // 改
.then(luke => user.delete(luke.id)); // 刪
複製代碼
Table.get()
,Table.add()
和 Table.put()
都返回完整數據Table.delete()
不返回數據(返回 undefined
)須要注意的就是,put(obj)
方法中的 obj
須要包含 id
,不然就等價於 add(obj)
上面的 demo 中,get()
獲得的 luke
對象包含 id
,所以是修改操做
以後會引入一個 update
方法來改進這個問題
也能夠一次性添加多條數據:
const data = [
{ name: 'luke', age: 22 },
{ name: 'elaine', age: 23 }
];
user.addMany(data)
.then(() => user.consoleTable());
複製代碼
Table.consoleTable()
這裏用了一個 Table.consoleTable()
的方法,它會在瀏覽器的控制檯打印出下面的內容:
這裏的 (index) 就是 id
雖然 chrome 開發者工具內就能看到表內全部數據,但這個方法好處是能夠在須要的時候打印出數據,方便 debug
注意:這個方法是異步的,由於須要在數據庫裏把數據庫取出來;異步意味着緊接在它後面的代碼,可能會在打印出結果以前執行,若是不但願出現這種狀況,使用 await
或 Promise.then
便可
addMany(data)
方法:
data
的順序添加data
順序一致之因此單獨寫個 addMany
,而不在 add
里加一個判斷數組的邏輯,是由於用戶想要的,可能就是添加一個數組到數據庫中
注意:addMany
和 add
不要同步調用,若是在 addMany
正在執行時調用 add
,可能會致使數據庫裏的順序不符合預期,請在 addMany
的回調完成後再調用 add
(將來可能會引入一個隊列來修復這個問題)
Table.find()
若是你想在數據庫中查找數據,還可使用 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)
接受一個函數 fn
做爲參數,這個函數的返回值應當爲 true
和 false
用法其實和 JS 數組方法 Array.find()
一模一樣
這個方法在內部會從頭遍歷整個表(使用 IndexedDB 的 Cursor),而後把每一次的結果放進 fn
執行,若是 fn
的返回值爲 true
(內部使用 if(fn())
判斷),就返回當前的結果,中止遍歷
這個方法只會返回第一個知足條件的值,若是須要返回全部知足條件的值,請使用 Table.findAll()
,用法與 Table.find()
一致,可是會返回一個數組,包含全部知足條件的值
若是你但願數據庫的結構更嚴格一點,也能夠添加 schema
GoDB
會根據 schema
創建 IndexedDB
數據庫索引,給字段添加特性
import GoDB from 'godb';
// 定義數據庫結構
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) // 沒問題
.then(() => user.get({ name: 'luke' })) // 定義schema後,就能夠用id之外的字段獲取數據
.then(luke => user.add(luke)) // 報錯,name 重複了
複製代碼
如上面的例子,指定了 schema
後
get()
和 delete()
中可使用 id
之外的字段搜索了,不然只能傳入 id
user.name
這一項是惟一的,所以沒法添加劇復的 name
固然,你也能夠在 table
那定義 schema
:
const testDB = new GoDB('testDB');
const user = testDB.table('user', {
name: {
type: String,
unique: true
},
age: Number
});
複製代碼
但這種方式的缺點是,若是定義 table
發生在鏈接數據庫以後,GoDB
會先發起一個 IDBVersionChange
的事件,致使 IndexedDB
數據庫版本升級,此時若是有不止一個 GoDB
實例鏈接了一樣的數據庫,版本升級將會被 block,致使建表失敗
要避免這個問題卻是很簡單,把全部獲取 table
的操做緊接在 new GoDB()
以後(保證這兩操做是同步而非異步執行的)就能夠,這樣能夠確保全部 table
都在鏈接完成以前獲取到(JS 的事件循環特性)
固然,最佳實踐仍是在鏈接數據庫時就定義好全部的 schema
,這樣 GoDB
會在應用初始化時就創建好全部的數據庫和表,並創建字段的索引
Table.get() 與 Table.find() 區別
get()
使用數據庫索引搜索,性能更高,可是須要定義 schema
,才能使用 id
之外的索引進行搜索
而 find()
利用函數判斷遍歷全表,使用上更靈活,可是性能相對沒有 get()
好
關於 schema:
部分同窗或許會發現,上面定義 schema
的方式有點眼熟,沒錯,正是參考了 mongoose
age: Number
type
,也指明這個字段是否是惟一的(unique: true
),以後會添加更多可選屬性,如用來指定字段默認值的 default
,和指向別的表的索引 ref
不定義 schema
時,GoDB
使用起來就像 MongoDB 同樣,能夠靈活添加數據;區別是 Mongodb 中,每條數據的惟一標識符是 _id
,而 GoDB
是 id
雖然這樣作的問題是,用戶使用不規範的話(如每次添加的數據結構都不同),長此以往可能會使得數據庫的字段特別多,維護和使用起來很是麻煩
定義 schema
後,你將沒法給 schema
內沒有的字段添加數據(在寫文檔的時候尚未實現這個功能,即便 schema
不符合也能加,下個版本會安排上)
所以推薦在項目中,首先定義好 schema
,這樣不論是維護性上,仍是性能上,都要更勝一籌
因爲 GoDB
的 API 都是 Promise
的,所以在不少場景下可使用 await
,使代碼更簡潔,同時拓寬使用場景(await
能夠很方便用在循環內,而 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 非必須,這裏爲了防止打印順序出錯
// 查:
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,會在控制檯打印出下面的內容:
由於「鏈接數據庫」和「鏈接表」這兩個操做是異步的,在設計之初,曾經有兩個 API 方案,區別在於:要不要把這兩個操做,作爲異步 API 提供給用戶
這裏討論的不是「API 如何命名」這樣的細節,而是「API 的使用方式」,由於這會直接影響到用戶使用 GoDB
時的業務代碼編寫方式
以鏈接數據庫 -> 添加一條數據的過程爲例
設計一:提供異步特性
GitHub 上大多數開源的 IndexedDB 封裝庫都是這麼作的
import GoDB from 'godb';
// 鏈接數據庫是異步的
GoDB.open('testDB')
.then(testDB => testDB.table('user')) // 鏈接表也須要異步
.then(user => {
user.add({
name: 'luke',
age: 22
});
});
});
複製代碼
這樣的優勢是,工做流程一目瞭然,畢竟對數據庫的操做,要放在鏈接數據庫以後
可是,這種設計不適合工程化的前端項目!
由於,全部增刪改查等操做,都須要用戶,手動放到鏈接完成的異步回調以後,不然沒法知道操做時有沒有連上數據庫和表
致使每次須要操做數據庫時,都要先打開數據庫一遍數據庫,才能繼續
即便你預先定義一個全局的鏈接,你在以後想要使用它時,若是不包一層 Promise,是沒法肯定數據庫和表,在使用時有沒有鏈接上的
以 Vue 爲例,若是你在全局環境(好比 Vuex)定義了一個鏈接:
import GoDB from 'godb';
new Vuex.Store({
state: {
godb: await GoDB.open('testDB') // 不加 await 返回的就是 Promise 了
}
});
複製代碼
這樣,在 Vue 的任何一個組件中,咱們都能訪問到 GoDB
實例
問題來了,在你的組件中,若是你想在組件初始化時,好比 created
和 mounted
這樣的鉤子函數中(React 中就是 ComponentDidMount
),去訪問數據庫:
new Vue({
mounted() {
const godb = this.$store.state.godb; // 從全局環境取出鏈接
godb.table('user')
.then(user => {
user.add({
name: 'luke',
age: 22
}); // user is undefined!
});
}
});
複製代碼
你會發現,若是這個組件在 App 初始化時就被加載,在組件 mounted
函數觸發時,本地數據庫可能根本就沒有鏈接上!(鏈接數據庫這樣的操做,最典型的執行場景就是在組件加載時)
解決辦法是,在每個須要操做數據庫的地方,都定義一個鏈接:
import GoDB from 'godb';
new Vue({
mounted() {
GoDB.open('testDB')
.then(testDB => testDB.table('user'))
.then(user => {
user.add({
name: 'luke',
age: 22
});
});
}
});
複製代碼
這樣不只代碼又臭又長,性能低下(每次操做都須要先鏈接),在須要鏈接本地數據庫的組件多了後,維護起來更是一場噩夢
簡而言之,就是這個方案,在工程化前端的不一樣組件中,須要在每次操做以前,都連一遍數據庫,不然沒法確保組件加載時,已經鏈接上了 IndexedDB
設計二:隱藏鏈接的異步特性
我最終採用了這個方案,對開發者而言,甚至感受不到「鏈接數據庫」和「鏈接表」這兩個操做是異步的
const testDB = new GoDB('testDB');
const user = testDB.table('user');
user.add({
name: 'luke',
age: 22
})
.then(luke => console.log(luke));
複製代碼
這樣使用上很是天然,開發者並不須要關心操做時有沒有連上數據庫和表,只須要在操做後的回調內寫好本身的邏輯就能夠
可是,這個方案的缺點就是開發起來比較麻煩(嘿嘿,麻煩本身,方便用戶)
由於 new Codb('testDB')
內部的鏈接數據庫的操做,其實是異步的(由於 IndexedDB 的原生 API 就是異步的設計)
在鏈接數據庫的操做發出去後,即便還沒鏈接上,下面的 testDB.table('user')
和 user.add()
也會先開始執行
也就是說,以後的「獲取 user 表」 和 「添加一條數據」實際上會先於「連上數據庫」這個過程執行,若是實現該 API 設計時未處理這個問題,上面的示例代碼確定會報錯
而要處理這個問題,我用到了下面兩個方法:
add()
),先拿到數據庫的鏈接,再進行操做具體而言,就是
GoDB
的 class 中定義一個 getDB(callback)
,用來獲取 IndexedDB 鏈接實例getDB
,在 callback
獲取到 IndexedDB 的鏈接實例後再進行操做getDB
中使用一個隊列,若是數據庫還沒鏈接上,就把 callback
放進隊列,在鏈接上後,執行這個隊列中的函數callback
執行便可在調用 getDB
時,可能有三種狀態(其實還有個數據庫已關閉的狀態,這裏不討論):
第一種狀態只在第一次執行 getDB
時觸發,由於一旦嘗試創建鏈接就進入下一個狀態了;第一次執行被我放到了 GoDB
類的構造函數中
第三種狀態時,也就是已經連上數據庫後,直接把鏈接實例傳進 callback
執行便可
關鍵是處理第二種狀態,此時正在鏈接數據庫,但還未連上,沒法進行增刪改查:
const testDB = new GoDB('testDB');
const user = testDB.table('user');
user.add({ name: 'luke' }); // 此時數據庫正在鏈接,還未連上
user.add({ name: 'elaine' }); // 此時數據庫正在鏈接,還未連上
testDB.onOpened = () => { // 數據庫鏈接成功的回調
user.add({ name: 'lucas' }); // 此時已鏈接
}
複製代碼
上面的例子,頭兩個 add
執行時其實數據庫並未鏈接上
那要如何操做,才能保證正常添加,而且 luke
和 elaine
在 lucas
進入數據庫的順序,和代碼順序一致呢?
答案是使用隊列 Queue,把數據庫還未連上時的 add
操做加進隊列,在鏈接成功時,按先進先出的順序執行
這樣,用戶就不須要關心,操做時數據庫是否已經連上了(只須要關注異步回調便可),GoDB
幫你在幕後作好了這一切
注意之因此使用 callback
而不是 Promise
,是由於 JS 中的回調既能夠是異步的,也能夠是同步的
而鏈接成功,已經有鏈接實例後,直接同步返回鏈接實例更好,不必再使用異步
仍是以 Vue 爲例,若是咱們在 Vuex(全局變量)中添加鏈接實例:
import GoDB from 'godb';
new Vuex.Store({
state: {
godb: new GoDB('testDB')
}
});
複製代碼
這樣,在全部組件中,咱們均可以使用同一個鏈接實例:
new Vue({
computed: {
// 把全局實例變爲組件屬性
godb() {
return this.$store.state.godb;
}
},
mounted() {
this.godb.table('user').add({
name: 'luke',
age: 22
})
.then(luke => console.log(luke));
}
});
複製代碼
總結這個方案的優勢:
缺點:對 GoDB.js
的開發更麻煩,不是簡單把 IndexedDB 封裝一層 Promise 就行
所以,我最終採用了這個方案,畢竟麻煩我一個,方便你我他,優勢遠遠蓋過了缺點
若是對實現好奇的話,能夠去閱讀源碼,當前只是實現了基本的 CRUD,源碼暫時還不復雜
若是想了解更多,能夠訪問項目官網
若是你有任何建議或意見,請在評論區留言,我會認證讀每個反饋
若是以爲這個項目有意思,歡迎給文章點贊,歡迎來 GitHub 點個 star~