IndexedDB 程式碼封裝、效能摸索以及多標籤支援

網易雲信發表於2022-04-22

前言

當一個 Javascript 程式需要在瀏覽器端儲存資料時,你有以下幾個選擇:

  • Cookie:通常用於 HTTP 請求,並且有 64 kb 的大小限制。
  • LocalStorage:儲存 key-value 格式的鍵值對,通常有 5MB 的限制。
  • WebSQL:並不是 HTML5 標準,已被廢棄。
  • FileSystem & FileWriter API:相容性極差,目前只有 Chrome 瀏覽器支援。
  • IndexedDB:是一個 NOSQL 資料庫,可以非同步操作,支援事務,可儲存 JSON 資料並且用索引迭代,相容性好。

很明顯,只有 IndexedDB 適用於做大量的資料儲存。但是直接使用 IndexedDB 也會碰到幾個問題:

  • IndexedDB API 基於事務,偏向底層,操作繁瑣,需要簡化封裝。
  • IndexedDB 效能瓶頸主要在哪兒?
  • IndexedDB 在 瀏覽器多 tab 頁的情況下可能會對同一條資料記錄進行多次操作。

本篇文章將結合筆者的實踐經驗,就以上問題來進行相關探索。

Log 日誌儲存場景

有這樣一個場景,客戶端產生大量的日誌並存放若干日誌。在發生某些錯誤時(或者長連線得到伺服器的指令時)可拉取本地全部日誌內容併發請求上報。

如圖所示:

這是一個很好的設計到了 IndexedDB CRUD 場景的操作,在這裡,我們只關注 IndexedDB 儲存這部分。有關於 IndexedDB 的基礎概念,如倉庫 IDBObjectStore、索引 IDBIndex、遊標 IDBCursor、事務 IDBTransaction,限於篇幅請參照 IndexedDB-MDN

建立資料庫

我們知道 IndexedDB 是事務驅動的,開啟一個資料庫 db_test,建立 store log,並以 time 為索引。


class Database {
  constructor(options = {}) {
    if (typeof indexedDB === 'undefined') {
      throw new Error('indexedDB is unsupported!')
      return
    }
    this.name = options.name
    this.db = null
    this.version = options.version || 1
  }

  createDB () {
    return new Promise((resolve, reject) => {
      // 為了本地除錯,資料庫先刪除後建立
      indexedDB.deleteDatabase(this.name);
      const request = indexedDB.open(this.name);
      // 當資料庫升級時,觸發 onupgradeneeded 事件。
      // 升級是指該資料庫首次被建立,或呼叫 open() 方法時指定的資料庫的版本號高於本地已有的版本。
      request.onupgradeneeded = () => {
        const db = request.result;
        window.db = db
        console.log('db onupgradeneeded')
        // 在這裡建立 store
        this.createStore(db)
      };

      // 開啟成功的回撥函式
      request.onsuccess = () => {
        resolve(request.result)
        this.db = request.result
      };
      // 開啟失敗的回撥函式
      request.onerror = function(event) {
        reject(event)
      }
    })
  }

  createStore(db) {
    if (!db.objectStoreNames.contains('log')) {
      // 建立表
      const objectStore = db.createObjectStore('log', {
        keyPath: 'id',
        autoIncrement: true
      });
      // time 為索引
      objectStore.createIndex('time', 'time');
    }
  }
}

呼叫語句如下:


(async function() {
  const database = new Database({ name: 'db_test' })
  await database.createDB()
  console.log(database)
  // Database {name: 'db_test', db: IDBDatabase, version: 1}
  //   db: IDBDatabase
  //     name: "db_test"
  //     objectStoreNames: DOMStringList {0: 'log', length: 1}
  //     onabort: null
  //     onclose: null
  //     onerror: null
  //     onversionchange: null
  //     version: 1
  //     [[Prototype]]: IDBDatabase
  //   name: "db_test"
  //   version: 1
  //   [[Prototype]]: Object
})()

增刪改操作

當日志插入一條資料,我們需要提交一個事務,事務裡對 store 進行 add 操作。

const db = window.db;
const transaction = db.transaction('log', 'readwrite')
const store = transaction.objectStore('log')

const storeRequest = store.add(data);

storeRequest.onsuccess = function(event) {
  console.log('add onsuccess, affect rows ', event.target.result);
  resolve(event.target.result)
};

storeRequest.onerror = function(event) {
  reject(event);
};

由於每次的增刪改查都需要開啟一個 transaction,這樣的呼叫不免顯得繁瑣,我們需要一些步驟來簡化,提供 ES6 promise 形式的 API。

class Database {
  // ... 省略開啟資料庫的過程
  // constructor(options = {}) {}
  // createDB() {}
  // createStore() {}
  add (data) {
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readwrite')
      const store = transaction.objectStore('log')
      const request = store.add(data);

      request.onsuccess = event => resolve(event.target.result);
      request.onerror = event => reject(event);
    })
  }
  put (data) {
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readwrite')
      const store = transaction.objectStore('log')
      const request = store.put(data);

      request.onsuccess = event => resolve(event.target.result);
      request.onerror = event => reject(event);
    })
  }
  // delete
  delete (id) {
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readwrite')
      const store = transaction.objectStore('log')
      const request = store.delete(id)

      request.onsuccess = event => resolve(event.target.result);
      request.onerror = event => reject(event);
    })
  }
}

呼叫程式碼如下:


(async function() {
  const db = new Database({ name: 'db_test' })
  await db.createDB()
  
  const row1 = await db.add({time: new Date().getTime(), body: 'log 1' })
  // {id: 1, time: new Date().getTime(), body: 'log 2' }

  await db.add({time: new Date().getTime(), body: 'log 2' })

  await db.put({id: 1, time: new Date().getTime(), body: 'log AAAA' })

  await db.delete(1)
})()

查詢

查詢有很多種情況,常見的 ORM 裡提供範圍查詢和索引查詢兩種方法,範圍查詢中還可以分頁查詢。在 IndexedDB 中我們簡化為 getByIndex。

查詢需要使用到 IDBCursor 遊標和 IDBIndex 索引。

class Database {
  // ... 省略開啟資料庫的過程
  // constructor(options = {}) {}
  // createDB() {}
  // createStore() {}
  
  // 查詢第一個 value 相匹對的值
  get (value, indexName) {
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readwrite')
      const store = transaction.objectStore('log')
      let request
      // 有索引則開啟索引來查詢,無索引則當作主鍵查詢
      if (indexName) {
        let index = store.index(indexName);
        request = index.get(value)
      } else {
        request = store.get(value)
      }

      request.onsuccess = evt => evt.target.result ?
        resolve(evt.target.result) : resolve(null)
      request.onerror = evt => reject(evt)
    });
  }

  /**
   * 條件查詢,帶分頁
   * 
   * @param {string} keyPath 索引名稱
   * @param {string} keyRange 索引物件
   * @param {number} offset 分頁偏移量
   * @param {number} limit 分頁頁碼
   */
  getByIndex (keyPath, keyRange, offset = 0, limit = 100) {
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readonly')
      const store = transaction.objectStore('log')
      const index = store.index(keyPath)
      let request = index.openCursor(keyRange)
      const result = []
      request.onsuccess = function (evt) {
        let cursor = evt.target.result
        // 偏移量大於 0,代表需要跳過一些記錄
        if (offset > 0) {
          cursor.advance(offset);
        }
        if (cursor && limit > 0) {
          console.log(1)
          result.push(cursor.value)
          limit = limit - 1
          cursor.continue()
        } else {
          cursor = null
          resolve(result)
        }
      }
      request.onerror = function (evt) {
        console.err('getLogByIndex onerror', evt)
        reject(evt.target.error)
      }

      transaction.onerror = function(evt) {
        reject(evt.target.error)
      };
    })
  }
}

(async function() {
  const db = new Database({ name: 'db_test' })
  await db.createDB()
  
  await db.add({time: new Date().getTime(), body: 'log 1' })
  // {id: 1, time: new Date().getTime(), body: 'log 2' }

  await db.add({time: new Date().getTime(), body: 'log 2' })

  const time = new Date().getTime()

  await db.put({id: 1, time: time, body: 'log AAAA' })

  await db.add({time: new Date().getTime(), body: 'log 3' })

  // 查詢最小是這個時間的的記錄
  const test = await db.getByIndex('time', IDBKeyRange.lowerBound(time))
  // multi index query
  // await db.getByIndex('time, test_id', IDBKeyRange.bound([0, 99],[Date.now(), 2100]);)

  console.log(test)
  // 0: {id: 1, time: 1648453268858, body: 'log AAAA'}
  // 1: {time: 1648453268877, body: 'log 3', id: 3}
})()

查詢當然還有更多可能,比如查詢一張表全部的資料,或者是 count 獲取這張表的記錄數量等,留待讀者們自行擴充套件。

優化

我們需要將 Model 和 Database 拆開來,上文 createDB 的時候做一些改進,類似 ORM 一樣提供對映,以及基礎的增刪改查方法。

class Database {
  constructor(options = {}) {
    if (typeof indexedDB === 'undefined') {
      throw new Error('indexedDB is unsupported!')
    }
    this.name = options.name
    this.db = null
    this.version = options.version || 1
    // this.upgradeFunction = option.upgradeFunction || function () {}
    this.modelsOptions = options.modelsOptions
    this.models = {}
  }

  createDB () {
    return new Promise((resolve, reject) => {
      indexedDB.deleteDatabase(this.name);
      const request = indexedDB.open(this.name);
      // 當資料庫升級時,觸發 onupgradeneeded 事件。升級是指該資料庫首次被建立,或呼叫 open() 方法時指定的資料庫的版本號高於本地已有的版本。
      request.onupgradeneeded = () => {
        const db = request.result;

        console.log('db onupgradeneeded')

        Object.keys(this.modelsOptions).forEach(key => {
          this.models[key] = new Model(db, key, this.modelsOptions[key])
        })
      };

      // 開啟成功
      request.onsuccess = () => {
        console.log('db open onsuccess')
        console.log('addLog, deleteLog, clearLog, putLog, getAllLog, getLog')
        resolve(request.result)
        this.db = request.result
      };
      // 開啟失敗
      request.onerror = function(event) {
        console.log('db open onerror', event);
        reject(event)
      }
    })
  }
}

class Model {
  constructor(database, tableName, options) {
    this.db = database
    this.tableName = tableName

    if (!this.db.objectStoreNames.contains(tableName)) {
      const objectStore = this.db.createObjectStore(tableName, {
        keyPath: options.keyPath,
        autoIncrement: options.autoIncrement || false
      });
      options.index && Object.keys(options.index).forEach(key => {
        objectStore.createIndex(key, options.index[key]);
      })
    }
  }

  add(data) {
    // ... 省略上文的 add 函式
  }
  delete(id) {
    // ... 省略
  }
  put(data) {
    // ... 省略
  }
  getByIndex(keyPath, keyRange) {
    // ... 省略
  }
  get(indexName, value) {
    // ... 省略
  }
}

呼叫如下:

(async function() {
  const db = new Database({
    name: 'db_test',
    modelsOptions: {
      log: {
        keyPath: 'id',
        autoIncrement: true,
        rows: {
          id: 'number',
          time: 'number',
          body: 'string',
        },
        index: {
          time: 'time'
        }
      }
    }
  })
  await db.createDB()

  await db.models.log.add({time: new Date().getTime(), body: 'log 1' })
  
  await db.models.log.add({time: new Date().getTime(), body: 'log 2' })
  
  await db.models.log.get(null, 1)

  const time = new Date().getTime()

  await db.models.log.put({id: 1, time: time, body: 'log AAAA' })

  await db.models.log.getByIndex('time', IDBKeyRange.only(time))
})()

當然這只是一個很簡陋的模型,它還有一些不足。比如查詢時,開發者呼叫時不需要接觸 IDBKeyRange,類似是 sequelize 風格的,對映為 time: { $gt: new Date().getTime() },用 $gt 來替代 IDBKeyRange.lowerbound。

批量操作

值得一提的,IndexedDB 的操作效能和提交給它的事務多少有著緊密的關係,推薦儘可能使用批量插入。

批量操作,可以採取事件委託來避免產生許多的 request 的 onsuccess、onerror 事件。

class Model {
  // ... 省略 construct

  bulkPut(datas) {
    if (!(datas && datas.length > 0)) {
      return Promise.reject(new Error('no data'))
    }
    return new Promise((resolve, reject) => {
      const db = this.db;
      const transaction = db.transaction('log', 'readwrite')
      const store = transaction.objectStore('log')

      datas.forEach(data => store.put(data))

      // Event delegation
      // IndexedDB events bubble: request → transaction → database.
      transaction.oncomplete = function() {
        console.log('add transaction complete'); 
        resolve()
      };
      transaction.onabort = function (evt) {
        console.error('add transaction onabort', evt);
        reject(evt.target.error)
      }
    })
  }
}

效能探索

IndexedDB 的插入耗時與提交給它的事務數量有顯著的關聯。我們設定一組對照實驗:

  • 提交 1000 個事務,每個事務插入 1 條資料。
  • 提交 1 個事務,事務中插入 1000 條資料。

測試程式碼如下:


const promises = []
for (let index = 0; index < 1000; index++) {
  promises.push(db.models.log.add({time: new Date().getTime(), body: `log ${index}` }))
}
console.time('promises')
Promise.all(promises).then(() => {
  console.timeEnd('promises')
})
// promises: 20837.403076171875 ms
const arr = []
for (let index = 0; index < 1000; index++) {
  arr.push({time: new Date().getTime(), body: `log ${index}` })
}
console.time('promises')
await db.models.log.bulkPut(arr)
console.timeEnd('promises')
// promises: 250.491943359375 ms

減少事務提交非常重要,以至於需要有大量存入的操作時,都推薦日誌在記憶體中儘可能合併下,再批量寫入。

值得一提的是,body 在上面的對照實驗中只寫入了個位數的字元,假設每次寫 5000 個字元,批量寫入的時間也只是從 250ms 提升到 300ms,提升的並不明顯。

讓我們再來對比一組情況,我們會提交 1 個事務,插入 1000 條資料,在 0 到 500 萬存量資料間進行測試,我們得到以下資料:

for (let i = 0; i < 10000; i++) {
  let date = new Date()
  let datas = []
  for (let j = 0; j < 1000; j++) {
    datas.push({ time: new Date().getTime(), body: `log ${j}`})
  }
  await db.models.log.bulkPut(datas)
  datas = []

  if (i === 10 || i === 50
    || i === 100 || i === 500 || i === 1000 || i === 2000
    || i === 5000) {
    console.warn(`success for bulkPut ${i}: `, new Date() - date)
  } else {
    console.log(`success for bulkPut ${i}:  `, new Date() - date)
  }
  
}

// success for bulkPut 10:  283
// success for bulkPut 50:  310
// success for bulkPut 100:  302
// success for bulkPut 500:  296
// success for bulkPut 1000:  290
// success for bulkPut 2000:  150
// success for bulkPut 5000:  201

上文資料表明波動並不大,給出結論在 500w 的資料範圍內,插入耗時沒有明顯的提升。當然查詢取決的因素更多,其耗時留待讀者們自行驗證。

多 tab 操作相同資料的情況

對於 IndexedDB 來說,它只負責接收一個又一個的事務進行處理,而不管這些事務是從哪個 tab 頁提交來的,就可能會產生多個 tab 頁的 JS 程式往資料庫裡試圖操作同一條資料的情況。

拿我們的 db 來舉例,若我們修改建立 store 時的索引 time 為:

objectStore.createIndex('time', 'time', { unique: true });

同時開啟 3 個 tab,每個 tab 都是每 20ms 往資料庫裡寫入一份資料,大概率會出現 error,解決這個問題的理想方法是 SharedWorker API, SharedWorker 類似於 WebWorker,不同點在於 SharedWorker 可以在多個上下文之間共享。我們可以在 SharedWorker 中建立資料庫,所有瀏覽器的 tab 都可以向 Worker 請求資料,而不是自己建立資料庫連線。

遺憾的是 SharedWorker API 在 Safari 中無法支援,沒有 polyfill。作為取代,我們可以使用 BroadcastChannel API,他可以在多 tab 間通訊,選舉出一個 leader,允許 leader 擁有寫入資料庫的能力,而其他 tab 只能讀不能寫。

下面是一個 leader 選舉過程的簡單程式碼,參照自 broadcast-channel。

class LeaderElection {
  constructor(name) {
    this.channel = new BroadcastChannel(name)
    // 是否已經存在 leader
    this.hasLeader = false
    // 是否自己作為 leader
    this.isLeader = false

    // token 數,用於無 leader 時同時有多個 apply 的情況,來比對 maxTokenNumber 確定最大的作為 leader
    this.tokenNumber = Math.random()
    // 最大的 token,用於無 leader 時同時有多個 apply 的情況,來選舉一個最大的作為 leader
    this.maxTokenNumber = 0
    this.channel.onmessage = (evt) => {
      console.log('channel onmessage', evt.data)
      const action = evt.data.action
      switch (action) {
        // 收到申請拒絕,或者是其他人已成為 leader 的宣告,則標記 this.hasLeader = true
        case 'applyReject':
          this.hasLeader = true
          break;
        case 'leader':
          // todo, 可能會產生另一個 leader
          this.hasLeader = true
          break;
        // leader 已死亡,則需要重新推舉
        case 'death':
          this.hasLeader = false
          this.maxTokenNumber = 0
          // this.awaitLeadership()
          break;
        // leader 已死亡,則需要重新推舉
        case 'apply':
          if (this.isLeader) {
            this.postMessage('applyReject')
          } else if (this.hasLeader) {
          } else if (evt.data.tokenNumber > this.maxTokenNumber) {
            // 還沒有 leader 時,若自己 tokenNumber 比較小,那麼記錄 maxTokenNumber,
            // 將在 applyOnce 的過程中,撤銷成為 leader 的申請。
            this.maxTokenNumber = evt.data.tokenNumber
          }
          break;
        default:
          break;
      }
    }
  }
  awaitLeadership() {
    return new Promise((resolve) => {
      const intervalApply = () => {
        return this.sleep(4000)
          .then(() => {
            return this.applyOnce()
          })
          .then(() => resolve())
          .catch(() => intervalApply())
      }
      this.applyOnce()
        .then(() => resolve())
        .catch(err => intervalApply())
    })
  }
  applyOnce(timeout = 1000) {

    return this.postMessage('apply').then(() => this.sleep(timeout))
      .then(() => {
        if (this.isLeader) {
          return
        }
        if (this.hasLeader === true || this.maxTokenNumber > this.tokenNumber) {
          throw new Error()
        }
        return this.postMessage('apply').then(() => this.sleep(timeout))
      })
      .then(() => {
        if (this.isLeader) {
          return
        }
        if (this.hasLeader === true || this.maxTokenNumber > this.tokenNumber) {
          throw new Error()
        }
        // 兩次嘗試後無人阻止,晉升為 leader
        this.beLeader()
      })
    
  }
  beLeader () {
    this.postMessage('leader')
    this.isLeader = true
    this.hasLeader = true
    clearInterval(this.timeout)
    window.addEventListener('beforeunload', () => this.die());
    window.addEventListener('unload', () => this.die());
  }
  die () {
    this.isLeader = false
    this.hasLeader = false
    this.postMessage('death')
  }
  postMessage(action) {
    return new Promise((resolve) => {
      this.channel.postMessage({
        action,
        tokenNumber: this.tokenNumber
      })
      resolve()
    })
  }
  sleep(time) {
    if (!time) time = 0;
    return new Promise(res => setTimeout(res, time));
  }
}

呼叫程式碼如下:

const elector = new LeaderElection('test_channel')
window.elector = elector
elector.awaitLeadership().then(() => {
  document.title = 'leader!'
})

效果如 broadcast-channel 這樣:

總結

在瀏覽器中離線存放大量資料,我們目前只能使用 IndexedDB,使用 IndexedDB 會碰到幾個問題:

  • IndexedDB API 基於事務,偏向底層,操作繁瑣,需要做個封裝。
  • IndexedDB 效能最大的瓶頸在於事務數量,使用時注意減少事務的提交。
  • IndexedDB 並不在意事務是從哪個 tab 頁提交,瀏覽器多 tab 頁的情況下可能會對同一條資料記錄進行多次操作,可以選舉一個 leader 才允許寫入,規避這個問題。

本倉庫使用程式碼見 github:https://github.com/everlose/i...

近期活動推薦

前端開發作為當下熱門技術之一,受到不少開發者和學習者的關注。網易智企聯合 CCF YOCSEF 武漢共同打造了《前端有話說系列公開課》,為廣大開發者提供學習與交流的機會。本次前端有話說系列公開課能夠滿足開發者從基礎入門到企業實踐再到未來就業三個板塊的不同需求,深入淺出幫助開發者瞭解和掌握前端知識。本次系列公開課的安排如下,歡迎大家報名參加:

相關文章