前言

当一个 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.namethis.db = nullthis.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 = dbconsole.log('db onupgradeneeded')// 在这里创建 storethis.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);})}// deletedelete (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 - 1cursor.continue()} else {cursor = nullresolve(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.namethis.db = nullthis.version = options.version || 1// this.upgradeFunction = option.upgradeFunction || function () {}this.modelsOptions = options.modelsOptionsthis.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 = databasethis.tableName = tableNameif (!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 {// ... 省略 constructbulkPut(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)// 是否已经存在 leaderthis.hasLeader = false// 是否自己作为 leaderthis.isLeader = false// token 数,用于无 leader 时同时有多个 apply 的情况,来比对 maxTokenNumber 确定最大的作为 leaderthis.tokenNumber = Math.random()// 最大的 token,用于无 leader 时同时有多个 apply 的情况,来选举一个最大的作为 leaderthis.maxTokenNumber = 0this.channel.onmessage = (evt) => {console.log('channel onmessage', evt.data)const action = evt.data.actionswitch (action) {// 收到申请拒绝,或者是其他人已成为 leader 的宣告,则标记 this.hasLeader = truecase 'applyReject':this.hasLeader = truebreak;case 'leader':// todo, 可能会产生另一个 leaderthis.hasLeader = truebreak;// leader 已死亡,则需要重新推举case 'death':this.hasLeader = falsethis.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()}// 两次尝试后无人阻止,晋升为 leaderthis.beLeader()})}beLeader () {this.postMessage('leader')this.isLeader = truethis.hasLeader = trueclearInterval(this.timeout)window.addEventListener('beforeunload', () => this.die());window.addEventListener('unload', () => this.die());}die () {this.isLeader = falsethis.hasLeader = falsethis.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/indexeddb-test

近期活动推荐 

前端开发作为当下热门技术之一,受到不少开发者和学习者的关注。网易智企联合 CCF YOCSEF 武汉共同打造了《前端有话说系列公开课》,为广大开发者提供学习与交流的机会。本次前端有话说系列公开课能够满足开发者从基础入门到企业实践再到未来就业三个板块的不同需求,深入浅出帮助开发者了解和掌握前端知识。本次系列公开课的安排如下,欢迎大家报名参加:

IndexedDB 代码封装、性能摸索以及多标签支持相关推荐

  1. 简化业务代码开发:看Lambda表达式如何将代码封装为数据

    摘要:在云服务业务开发中,善于使用代码新特性,往往能让开发效率大大提升,这里简单介绍下lambad表达式及函数式接口特性. 1.Lambda 表达式 Lambda表达式也被称为箭头函数.匿名函数.闭包 ...

  2. php和jquery ui弹出框,JavaScript_jQuery弹出框代码封装DialogHelper,看了jQueryUI Dialog的例子,效果 - phpStudy...

    jQuery弹出框代码封装DialogHelper 看了jQueryUI Dialog的例子,效果还不错,就是用起来有点儿别扭,写出的代码有点拧巴,需要再封装一下!于是就有了下面这个简单的Dialog ...

  3. 【数据结构与算法】高级排序(希尔排序、归并排序、快速排序)完整思路,并用代码封装排序函数

    本系列文章[数据结构与算法]所有完整代码已上传 github,想要完整代码的小伙伴可以直接去那获取,可以的话欢迎点个Star哦~下面放上跳转链接 https://github.com/Lpyexplo ...

  4. php根据不同的条件替换一段html代码中的不同的img标签

    一.需求 这次的需求是获取到一段html代码,这段代码里面含有多个img标签.需求就是先获取到这些img标签的src属性,然后进行业务编写.业务编写之后,把新的src内容分别替换到不同的img标签中. ...

  5. python学习——把计算GC含量的代码封装成函数

    把代码封装成函数的好处是可以重复使用该段代码,并且会使代码结构清晰 例如要计算chr1以及chr2染色体的GC含量,代码如下: 1 # 将代码封装为函数并重复使用,例如计算染色体的GC含量 2 chr ...

  6. HttpRequest Java原生代码封装

    HttpRequest Java原生代码封装  get提交 post提交 name1=value1&name2=value2 的形式  json形式两种形式 package com.beisu ...

  7. ajax代码原理,关于Ajax的原理以及代码封装详解

    前言 其实AJAX内部实现并不麻烦,主要通过一个叫XMLHttpRequest的对象,而这个对象在现有的浏览器均被支持. 可以说,它是整个AJAX实现的基础,是浏览器用于后台与服务器交换数据的对象,有 ...

  8. 命名空间:不只是代码封装

    命名空间 命名空间并不是新事物,在很多面向对象的编程语言中,都得到了很好的支持,它有效的解决了同一个脚本中的成员命名冲突问题.所以说,命名空间是一种代码封装技术,代码中的每个成员,都是自己的活动空间, ...

  9. 程序性能分析php,php代码的性能分析

    php代码的性能分析. 你可以用xdbug去分析. 但是更好的选择是facebook的性能分析工具xhprof. 它可以图形化.前提是你安装了gd库,你也可能遇到一些小问题.我记得要更新linux的图 ...

最新文章

  1. python3.7.2安装-ubuntu下编译安装Python3.7.2
  2. mysql dba系统学习(1)mysql各版本编译安装
  3. DotNetBar 中 SuperGridControl 加载数据、获取数据、设置样式
  4. 矩阵是怎样变换向量的
  5. .net EF监控 MiniProfiler
  6. 【MATLAB统计分析与应用100例】案例007:matlab数据的极差归一化变换
  7. vue里实现同步执行方法_vue中的watch方法 实时同步存储数据
  8. 清华大学 现代软件工程 学生特别想学的领域
  9. linux redis-4.0,Linux Redis 4.0.2 安装部署
  10. 如何用ARKit将太阳系装进iPhone(二)
  11. sphinx结合scws的mysql全文检索
  12. 软考系统分析师备考详细介绍
  13. eclipse配色方案
  14. 一加8 pro 刷入 kali Hunter
  15. Python练习题——第六题:编写函数计算弧长的计算公式。弧长计算公式是一个数学公式,为L=n(圆心角度数)× π×2 r(半径)/360(角度制)。其中n是圆心角度数,r是半径,L是圆心角弧长。
  16. Sumatra PDF软件基本使用和快捷键
  17. 卧槽,迅雷的代码结构被扒了精光
  18. Mybatis使用之分页
  19. Git(用在IDEA中)
  20. php解密出售,有会php解密的来一位

热门文章

  1. 记号笔写在白板上引起的尴尬而又无奈的事件
  2. 传智高校平台python答案_传智播客高校教辅平台学生端下载-传智播客高校教辅平台app学生版v4.13.0官网最新版_新绿资源网...
  3. 万物互联的时代,如何利用多网聚合路由器完成智能家居链接?
  4. 防ios的抽屉效果,防qq的抽屉效果
  5. Devexpress控件使用皮肤,设置默认皮肤及动态换肤
  6. lol1.7更新服务器维护,lol2017年1月11日更新公告 lol1月11日7.1版本更新内容大全
  7. 摩托罗拉edge s pro使用体验 缺点 时不时漏接电话
  8. thymeleaf全局变量定义
  9. 基于MDKA5D31-EK_T70开发板的QT示例-demo06:软键盘
  10. nbtstat命令linux_NBTSTAT和Tracert_命令的原理与作用