前言

当一个 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 = electorelector.awaitLeadership().then(() => {  document.title = 'leader!'})

成果如 broadcast-channel 这样:

总结

在浏览器中离线寄存大量数据,咱们目前只能应用 IndexedDB,应用 IndexedDB 会碰到几个问题:

  • IndexedDB API 基于事务,偏差底层,操作繁琐,须要做个封装。
  • IndexedDB 性能最大的瓶颈在于事务数量,应用时留神缩小事务的提交。
  • IndexedDB 并不在意事务是从哪个 tab 页提交,浏览器多 tab 页的状况下可能会对同一条数据记录进行屡次操作,能够选举一个 leader 才容许写入,躲避这个问题。

本仓库应用代码见 github:https://github.com/everlose/i...

近期流动举荐

前端开发作为当下热门技术之一,受到不少开发者和学习者的关注。网易智企联结 CCF YOCSEF 武汉独特打造了《前端有话说系列公开课》,为宽广开发者提供学习与交换的机会。本次前端有话说系列公开课可能满足开发者从根底入门到企业实际再到将来待业三个板块的不同需要,深入浅出帮忙开发者理解和把握前端常识。本次系列公开课的安顿如下,欢送大家报名加入: