造一個 idb-keyval 輪子

項目源碼:https://github.com/Haixiang6123/learn-idb-keyval

預覽鏈接:http://yanhaixiang.com/learn-idb-keyval/

參考輪子:https://github.com/jakearchibald/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('出錯啦')
    })
}

目前這個函數已經可以做到:

  1. 返回 value,你會說:哦,原來剛剛那樣寫還很難返回值呢
  2. 減少了一層回調

再優化一下,使用 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 這兩步還是很冗餘,因爲不管以後的 setdelclear 都需要這兩步,需要改的只是創建 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
}

再來改造 uglyGetuglySet

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)
}

總結

  1. 數據庫裏的所有操作本質上都是 request,而 requeset 又有對應的 onsuccessonerroroncompleteonabort 回調
  2. 將 request promisify 可以避免回調地獄的問題,上面 4 個回調每對回調都完美對應 Promise resolvereject,所以 promisify 過程基本是無痛的
  3. indexedDB 的公共邏輯是:打開數據庫、創建對象倉庫(如果沒有的話)、創建事務,這裏用 createStore 進行封裝
  4. indexedDB 每個操作不同地方在於 transaction 的 mode 和 API 的調用 store.xxx(),所以 createStore 不再返回 store 頁是一個函數,參數就是 mode 和提供 store 的回調
  5. getAllKeys() 在 Safari、IE 會有兼容性問題,因此,要遍歷所有 cursor 的方法來獲取 keys, values, entries
  6. 遍歷完 cursor 後會調用 oncomplete

感想

呼 ~ 寫完覺得好累呀。

其實,一直都知道 indexedDB 的存在和它的 API。但是如果要我馬上實現類似 localStorage.getItem 的 API 屬實很難。今天看了 idb-keyval 的源碼,真的覺得寫得太好了(不過 async-await 語法是我自己加的),真正做到了小而美。

學習這些小庫對自己收益是十分巨大的。每次一遍看下來,對API 設計、邏輯封裝、工程組織、TS類型規範、註釋這些東西會有重新的認識。

對於已經厭倦了寫玩具項目的同學,真的十分推薦去看、抄、改進這些小庫,比在公司寫業務提高不知多少倍!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章