項目源碼:https://github.com/Haixiang6123/learn-idb-keyval
你真的會使用 indexdb 麼
相信不少人看過阮一峯的 《瀏覽器數據庫 IndexedDB 入門教程》。我自己的感覺是依然不會使用 indexedDB,感覺每一步操作都很簡單但是就是不會把整個流程跑通。
正好最近用到了 idb-keyval 這個庫,閱讀了一下源碼後終於是有點感覺了。下來就從一個簡單的例子開始,一步步來造一個 idb-keyval 庫吧。
一個簡單的需求
我們都知道 localStorage 的用法,現在就用 indexedDB 來實現 localStorage 的用法。
醜陋的實現
根據阮一峯老師的教程,假如我們要實現 getItem
方法,用最最最原生的方法就是:
const dbName = 'key-val'
const storeName = 'keyval'
export function uglyGet(key: string) {
// 打開數據庫
const openDBRequest = indexedDB.open(dbName)
// 創建表
openDBRequest.onupgradeneeded = function () {
openDBRequest.result.createObjectStore(storeName)
}
// 失敗回調
openDBRequest.onerror = () => console.log('出錯啦')
// 成功回調
openDBRequest.onsuccess = () => {
// 獲取數據庫
const db = openDBRequest.result
// 獲取數據庫裏的 store
const store = db.transaction(storeName, 'readonly').objectStore(storeName)
// 獲取值操作
const getRequest = store.get(key);
getRequest.onsuccess = function() {
// 獲取到值
console.log(`獲取 ${key} 成功`, this.result)
}
getRequest.onerror = function() {
console.log(`獲取 ${key} 失敗`)
}
}
}
上面做了以下操作:
- 打開 key-val 數據庫
- 添加 keyval 對象倉庫(如果沒有的話)
- 獲取 key 對應的 value 值,並顯示
this.result
看看看看,現在取個 value 還有沒有點規矩了?
咱們的訴求是什麼呀?是通過一個簡單的 API 來獲取一個 value,裏面的邏輯應該只要調幾個接口就夠了。你讓我造這麼多個回調和監聽,只爲拿一個 value 值。丟不丟人?惡不噁心?難看不難看吶?
下面就來一步一步改造上面的代碼。
promisify
看到回調,很容易就想到了利用 Promise 來進行封裝,封裝之後就可以用 await-async 來寫代碼了,避免回調地獄。上面主要是 request 來執行一些操作,所以我們應該將這些操作進行 promisify:
/**
* 將 request 變爲 Promise 對象
* indexeddb 操作成功後會調用 onsuccess,因此綁定到 resolve
* indexeddb 操作失敗後會調用 onerror,因此綁定到 reject
* @param request
*/
export function promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T> {
return new Promise<T>((resolve, reject) => {
// @ts-ignore
request.onsuccess = () => resolve(request.result)
// @ts-ignore
request.onerror = () => reject(request.error)
});
}
然後可以改寫成 promise.then 的寫法:
export async function uglyGet(key) {
// 打開數據庫
const openDBRequest = indexedDB.open(dbName)
// 創建表
openDBRequest.onupgradeneeded = () => openDBRequest.result.createObjectStore(storeName)
// 失敗回調
return promisifyRequest(openDBRequest)
.then(db => {
// 獲取數據庫裏的 store
const store = db.transaction(storeName, 'readonly').objectStore(storeName)
// 獲取值操作
const getRequest = store.get(key);
return promisifyRequest(getRequest)
})
.then((value) => {
console.log(`獲取 ${key} 成功`, value)
return value;
})
.catch(() => {
console.log('出錯啦')
})
}
目前這個函數已經可以做到:
- 返回 value,你會說:哦,原來剛剛那樣寫還很難返回值呢
- 減少了一層回調
再優化一下,使用 async-await 的寫法:
export async function uglyGet(key) {
// 打開數據庫
const openDBRequest = indexedDB.open(dbName)
// 創建表
openDBRequest.onupgradeneeded = () => openDBRequest.result.createObjectStore(storeName)
const db = await promisifyRequest(openDBRequest).catch(() => console.log('出錯啦'))
// 獲取不到數據庫的情況
if (!db) {
return console.log('出錯啦');
}
// 獲取數據庫裏的 store
const store = db.transaction(storeName, 'readonly').objectStore(storeName)
// 獲取值操作
const value = await promisifyRequest(store.get(key));
console.log(`獲取 ${key} 成功`, value)
return value;
}
是不是感覺一下子就清爽了呢?
封裝公共邏輯
OK,我們不妨再寫個 uglySet 函數,你會發現從打開數據庫到獲取數據庫裏的對象倉庫這裏又要抄一遍。我與重複不共戴天,所以這裏應該把公共的部分抽離出來。
/**
* 創建/獲取數據庫
* @param dbName
* @param storeName
*/
export async function getDB(dbName: string, storeName: string) {
// 打開/創建數據庫
const request = indexedDB.open(dbName)
// 新建數據庫與打開數據庫是同一個操作。如果指定的數據庫不存在,就會新建。
request.onupgradeneeded = () => request.result.createObjectStore(storeName)
// 將 request Promisify,解決回調地獄的問題
const db = await promisifyRequest(request)
if (!db) {
throw new Error('出錯啦')
}
return db;
}
使用的時候就可以這樣了:
export async function uglyGet(key) {
const db = await getDB(dbName, storeName)
// 獲取數據庫裏的 store
const store = db.transaction(storeName, 'readonly').objectStore(storeName)
// 獲取值操作
return await promisifyRequest(store.get(key));
}
但是我們發現getDB()
和db.transaction
這兩步還是很冗餘,因爲不管以後的 set
,del
,clear
都需要這兩步,需要改的只是創建 transaction 時的 mode 和調用的 API store.xxx()
,所以還要以再抽取邏輯:
/**
* 創建數據庫,並提供操作入口
* @param dbName
* @param storeName
*/
export async function createStore(dbName: string, storeName: string) {
// 打開/創建數據庫
const request = indexedDB.open(dbName)
// 新建數據庫與打開數據庫是同一個操作。如果指定的數據庫不存在,就會新建。
request.onupgradeneeded = () => request.result.createObjectStore(storeName)
// 將 request Promisify,解決回調地獄的問題
const db = await promisifyRequest(request);
// 第一個參數爲事務的模式,第二個參數爲開發者的回調
return async (txMode, callback) => {
// 增、刪、改、查都用事務處理,需要的入參有:
// storeName:操作對象,txMode:事務模式
return callback(db.transaction(storeName, txMode).objectStore(storeName))
}
}
這個時候更簡潔了,來看看調用 get() 的時候是怎麼樣的:
export async function uglyGet(key) {
// 獲取數據庫裏的 store
const store = await createStore(dbName, storeName);
// 執行獲取 value
return await store('readonly', store => promisifyRequest(store.get(key)))
}
看起來好爽呀,兩行代碼就OK了。
單例 Store
現在嘗試把 set() 函數也寫出來(注意:這裏的 put 函數第一個參數要爲 value,第二個纔是 key,理解上有點反人類):
export async function uglySet(key, value) {
// 獲取數據庫裏的 store
const store = await createStore(dbName, storeName);
// 執行獲取 value
return await store('readonly', store => promisifyRequest(store.put(value, key)))
}
我們又發現有重複了:createStore
,難道我們每次都要打開數據庫,創建事務?顯然不科學。這裏最好將 store 變成單例,只在第一次的時候就造好,以後一直用這個 store 就好了。
// 單例
let defaultStore: Store | null = null
/**
* 獲取單例 default store
*/
export async function getDefaultStore() {
if (!defaultStore) {
defaultStore = await createStore('key-val', 'keyval')
}
return defaultStore
}
再來改造 uglyGet
和 uglySet
:
export async function uglyGet(key) {
// 獲取數據庫裏的 store
const store = await getDefaultStore()
// 執行獲取 value
return await store('readonly', store => promisifyRequest(store.get(key)))
}
export async function uglySet(key, value) {
// 獲取數據庫裏的 store
const store = await getDefaultStore()
// 執行獲取 value
return await store('readonly', store => promisifyRequest(store.put(value, key)))
}
還是有重複,我連 getDefaultStore()
都不想要了,所以最好的方法是在函數里加一個默認參數:
export async function get<T>(key: IDBValidKey, customStore = getDefaultStore()): Promise<T | undefined> {
return (await customStore)('readonly', store => promisifyRequest(store.get(key)))
}
需要注意的是,這裏的 customStore 的類型其實是一個 Promise<async function>,所以要先 (await customStore)
才能正常調用函數。
增、刪、改、查
現在所有重複代碼都優化完了,直接寫增、刪、改、查吧:
export async function get<T>(key: IDBValidKey, customStore = getDefaultStore()): Promise<T | undefined> {
return (await customStore)('readonly', store => promisifyRequest(store.get(key)))
}
export async function set(key: IDBValidKey, value: any, customStore = getDefaultStore()): Promise<IDBValidKey> {
// 注意:這裏參數的順序:第一個是 value,第二個纔是 key
return (await customStore)('readwrite', store => promisifyRequest(store.put(value, key)))
}
export async function del(key: IDBValidKey, customStore = getDefaultStore()) {
return (await customStore)('readwrite', store => promisifyRequest(store.delete(key)))
}
export async function clear(customStore = getDefaultStore()) {
return (await customStore)('readwrite', store => promisifyRequest(store.clear()))
}
有時候,我們可能會一次獲取和設置一堆的 key-val,所以要提供批量操作的接口:
export async function getMany(keys: IDBValidKey[], customStore = getDefaultStore()): Promise<any[]> {
return (await customStore)('readonly', store => {
return Promise.all(keys.map(k => promisifyRequest(store.get(k))))
})
}
export async function setMany(entries: [IDBValidKey, any][], customStore = getDefaultStore()): Promise<void> {
return (await customStore)('readwrite', store => {
entries.forEach(([k, v]) => store.put(v, k))
return promisifyRequest(store.transaction)
})
}
遍歷所有 key-val
現在回過頭來看我們的數據庫,本質上我們把它當成了一個大 Object 而已,對於 Object 最重要的 API 莫過於 keys()
,values()
和 entries()
了。
對於 keys()
的實現,可以用 getAllKeys()
來獲取,但是這個 API 在 IE 和 Safari 上有兼容性的問題。這裏我們使用遍歷 cursor 的方法來實現,而且 cursor 天生就有 key 和 value,對這三個 API 的實現有很大的幫助。
獲取所有 cursor 很簡單:
function eachCursor(customStore: Store, callback: (cursor: IDBCursorWithValue) => void): Promise<void> {
return customStore('readonly', store => {
store.openCursor().onsuccess = function(this) {
if (!this.result) return
callback(this.result)
this.result.continue()
}
return promisifyRequest(store.transaction)
})
}
只要不 continue 就說明已經讀取完所有的 cursor 了。這裏還有個小坑,當讀完所有的 cursor 時候會調用 oncomplete 回調,其實 indexedDB 裏的 transaction 還有 onabort 和 oncomplete 兩個回調,所以當 promisify 的時候還要把這兩個回調綁定到 reject 和 resolve:
export function promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T> {
return new Promise<T>((resolve, reject) => {
// @ts-ignore
request.oncomplete = request.onsuccess = () => resolve(request.result)
// @ts-ignore
request.onabort = request.onerror = () => reject(request.error)
});
}
這樣 promisify 終於完美了。獲取所有 cursor 後,實現這 3 個 API 就太簡單了,下面直接給出實現:
export async function keys(customStore = getDefaultStore()): Promise<IDBValidKey[]> {
const keys: IDBValidKey[] = []
return eachCursor(
(await customStore),
cursor => keys.push(cursor.key)
).then(() => keys)
}
export async function values(customStore = getDefaultStore()): Promise<any[]> {
const values: any[] = []
return eachCursor(
(await customStore),
cursor => values.push(cursor.value)
).then(() => values)
}
export async function entries(customStore = getDefaultStore()): Promise<[IDBValidKey, any][]> {
const entries: [IDBValidKey, any][] = []
return eachCursor(
(await customStore),
cursor => entries.push([cursor.key, cursor.value])
).then(() => entries)
}
總結
- 數據庫裏的所有操作本質上都是 request,而 requeset 又有對應的
onsuccess
、onerror
、oncomplete
、onabort
回調 - 將 request promisify 可以避免回調地獄的問題,上面 4 個回調每對回調都完美對應 Promise
resolve
和reject
,所以 promisify 過程基本是無痛的 - indexedDB 的公共邏輯是:打開數據庫、創建對象倉庫(如果沒有的話)、創建事務,這裏用
createStore
進行封裝 - indexedDB 每個操作不同地方在於 transaction 的 mode 和 API 的調用
store.xxx()
,所以 createStore 不再返回 store 頁是一個函數,參數就是 mode 和提供 store 的回調 -
getAllKeys()
在 Safari、IE 會有兼容性問題,因此,要遍歷所有 cursor 的方法來獲取 keys, values, entries - 遍歷完 cursor 後會調用 oncomplete
感想
呼 ~ 寫完覺得好累呀。
其實,一直都知道 indexedDB 的存在和它的 API。但是如果要我馬上實現類似 localStorage.getItem
的 API 屬實很難。今天看了 idb-keyval 的源碼,真的覺得寫得太好了(不過 async-await 語法是我自己加的),真正做到了小而美。
學習這些小庫對自己收益是十分巨大的。每次一遍看下來,對API 設計、邏輯封裝、工程組織、TS類型規範、註釋這些東西會有重新的認識。
對於已經厭倦了寫玩具項目的同學,真的十分推薦去看、抄、改進這些小庫,比在公司寫業務提高不知多少倍!