• 编程狂人| IndexedDB 代码封装、性能摸索以及多标签支持


    导读:IndexedDB 是一个 NOSQL 数据库,可以异步操作,支持事务,可存储 JSON 数据并且用索引迭代,兼容性好,适用于做大量的数据存储,本文主要探讨直接使用 IndexedDB 来存储数据时的一些情况以及遇到的问题。

    前言

    当一个 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 是事务驱动的,打开一个数据库 db_test,创建 store log,并以 time 为索引。

    1. class Database {
    2. constructor(options = {}) {
    3. if (typeof indexedDB === 'undefined') {
    4. throw new Error('indexedDB is unsupported!')
    5. return
    6. }
    7. this.name = options.name
    8. this.db = null
    9. this.version = options.version || 1
    10. }
    11. createDB () {
    12. return new Promise((resolve, reject) => {
    13. // 为了本地调试,数据库先删除后建立
    14. indexedDB.deleteDatabase(this.name);
    15. const request = indexedDB.open(this.name);
    16. // 当数据库升级时,触发 onupgradeneeded 事件。
    17. // 升级是指该数据库首次被创建,或调用 open() 方法时指定的数据库的版本号高于本地已有的版本。
    18. request.onupgradeneeded = () => {
    19. const db = request.result;
    20. window.db = db
    21. console.log('db onupgradeneeded')
    22. // 在这里创建 store
    23. this.createStore(db)
    24. };
    25. // 打开成功的回调函数
    26. request.onsuccess = () => {
    27. resolve(request.result)
    28. this.db = request.result
    29. };
    30. // 打开失败的回调函数
    31. request.onerror = function(event) {
    32. reject(event)
    33. }
    34. })
    35. }
    36. createStore(db) {
    37. if (!db.objectStoreNames.contains('log')) {
    38. // 创建表
    39. const objectStore = db.createObjectStore('log', {
    40. keyPath: 'id',
    41. autoIncrement: true
    42. });
    43. // time 为索引
    44. objectStore.createIndex('time', 'time');
    45. }
    46. }
    47. }

    调用语句如下:

    1. (async function() {
    2. const database = new Database({ name: 'db_test' })
    3. await database.createDB()
    4. console.log(database)
    5. // Database {name: 'db_test', db: IDBDatabase, version: 1}
    6. // db: IDBDatabase
    7. // name: "db_test"
    8. // objectStoreNames: DOMStringList {0: 'log', length: 1}
    9. // onabort: null
    10. // onclose: null
    11. // onerror: null
    12. // onversionchange: null
    13. // version: 1
    14. // [[Prototype]]: IDBDatabase
    15. // name: "db_test"
    16. // version: 1
    17. // [[Prototype]]: Object
    18. })()

    增删改操作

    当日志插入一条数据,我们需要提交一个事务,事务里对 store 进行 add 操作。

    1. const db = window.db;
    2. const transaction = db.transaction('log', 'readwrite')
    3. const store = transaction.objectStore('log')
    4. const storeRequest = store.add(data);
    5. storeRequest.onsuccess = function(event) {
    6. console.log('add onsuccess, affect rows ', event.target.result);
    7. resolve(event.target.result)
    8. };
    9. storeRequest.onerror = function(event) {
    10. reject(event);
    11. };

    由于每次的增删改查都需要打开一个 transaction,这样的调用不免显得繁琐,我们需要一些步骤来简化,提供 ES6 promise 形式的 API。

    1. class Database {
    2. // ... 省略打开数据库的过程
    3. // constructor(options = {}) {}
    4. // createDB() {}
    5. // createStore() {}
    6. add (data) {
    7. return new Promise((resolve, reject) => {
    8. const db = this.db;
    9. const transaction = db.transaction('log', 'readwrite')
    10. const store = transaction.objectStore('log')
    11. const request = store.add(data);
    12. request.onsuccess = event => resolve(event.target.result);
    13. request.onerror = event => reject(event);
    14. })
    15. }
    16. put (data) {
    17. return new Promise((resolve, reject) => {
    18. const db = this.db;
    19. const transaction = db.transaction('log', 'readwrite')
    20. const store = transaction.objectStore('log')
    21. const request = store.put(data);
    22. request.onsuccess = event => resolve(event.target.result);
    23. request.onerror = event => reject(event);
    24. })
    25. }
    26. // delete
    27. delete (id) {
    28. return new Promise((resolve, reject) => {
    29. const db = this.db;
    30. const transaction = db.transaction('log', 'readwrite')
    31. const store = transaction.objectStore('log')
    32. const request = store.delete(id)
    33. request.onsuccess = event => resolve(event.target.result);
    34. request.onerror = event => reject(event);
    35. })
    36. }
    37. }

    调用代码如下:

    1. (async function() {
    2. const db = new Database({ name: 'db_test' })
    3. await db.createDB()
    4. const row1 = await db.add({time: new Date().getTime(), body: 'log 1' })
    5. // {id: 1, time: new Date().getTime(), body: 'log 2' }
    6. await db.add({time: new Date().getTime(), body: 'log 2' })
    7. await db.put({id: 1, time: new Date().getTime(), body: 'log AAAA' })
    8. await db.delete(1)
    9. })()

    查询

    查询有很多种情况,常见的 ORM 里提供范围查询和索引查询两种方法,范围查询中还可以分页查询。在 IndexedDB 中我们简化为 getByIndex。
    查询需要使用到 IDBCursor 游标和 IDBIndex 索引。

    1. class Database {
    2. // ... 省略打开数据库的过程
    3. // constructor(options = {}) {}
    4. // createDB() {}
    5. // createStore() {}
    6. // 查询第一个 value 相匹对的值
    7. get (value, indexName) {
    8. return new Promise((resolve, reject) => {
    9. const db = this.db;
    10. const transaction = db.transaction('log', 'readwrite')
    11. const store = transaction.objectStore('log')
    12. let request
    13. // 有索引则打开索引来查找,无索引则当作主键查找
    14. if (indexName) {
    15. let index = store.index(indexName);
    16. request = index.get(value)
    17. } else {
    18. request = store.get(value)
    19. }
    20. request.onsuccess = evt => evt.target.result ?
    21. resolve(evt.target.result) : resolve(null)
    22. request.onerror = evt => reject(evt)
    23. });
    24. }
    25. /**
    26. * 条件查询,带分页
    27. *
    28. * @param {string} keyPath 索引名称
    29. * @param {string} keyRange 索引对象
    30. * @param {number} offset 分页偏移量
    31. * @param {number} limit 分页页码
    32. */
    33. getByIndex (keyPath, keyRange, offset = 0, limit = 100) {
    34. return new Promise((resolve, reject) => {
    35. const db = this.db;
    36. const transaction = db.transaction('log', 'readonly')
    37. const store = transaction.objectStore('log')
    38. const index = store.index(keyPath)
    39. let request = index.openCursor(keyRange)
    40. const result = []
    41. request.onsuccess = function (evt) {
    42. let cursor = evt.target.result
    43. // 偏移量大于 0,代表需要跳过一些记录
    44. if (offset > 0) {
    45. cursor.advance(offset);
    46. }
    47. if (cursor && limit > 0) {
    48. console.log(1)
    49. result.push(cursor.value)
    50. limit = limit - 1
    51. cursor.continue()
    52. } else {
    53. cursor = null
    54. resolve(result)
    55. }
    56. }
    57. request.onerror = function (evt) {
    58. console.err('getLogByIndex onerror', evt)
    59. reject(evt.target.error)
    60. }
    61. transaction.onerror = function(evt) {
    62. reject(evt.target.error)
    63. };
    64. })
    65. }
    66. }
    67. (async function() {
    68. const db = new Database({ name: 'db_test' })
    69. await db.createDB()
    70. await db.add({time: new Date().getTime(), body: 'log 1' })
    71. // {id: 1, time: new Date().getTime(), body: 'log 2' }
    72. await db.add({time: new Date().getTime(), body: 'log 2' })
    73. const time = new Date().getTime()
    74. await db.put({id: 1, time: time, body: 'log AAAA' })
    75. await db.add({time: new Date().getTime(), body: 'log 3' })
    76. // 查询最小是这个时间的的记录
    77. const test = await db.getByIndex('time', IDBKeyRange.lowerBound(time))
    78. // multi index query
    79. // await db.getByIndex('time, test_id', IDBKeyRange.bound([0, 99],[Date.now(), 2100]);)
    80. console.log(test)
    81. // 0: {id: 1, time: 1648453268858, body: 'log AAAA'}
    82. // 1: {time: 1648453268877, body: 'log 3', id: 3}
    83. })()

    查询当然还有更多可能,比如查询一张表全部的数据,或者是 count 获取这张表的记录数量等,留待读者们自行扩展。

    优化

    我们需要将 Model 和 Database 拆开来,上文 createDB 的时候做一些改进,类似 ORM 一样提供映射,以及基础的增删改查方法。

    1. class Database {
    2. constructor(options = {}) {
    3. if (typeof indexedDB === 'undefined') {
    4. throw new Error('indexedDB is unsupported!')
    5. }
    6. this.name = options.name
    7. this.db = null
    8. this.version = options.version || 1
    9. // this.upgradeFunction = option.upgradeFunction || function () {}
    10. this.modelsOptions = options.modelsOptions
    11. this.models = {}
    12. }
    13. createDB () {
    14. return new Promise((resolve, reject) => {
    15. indexedDB.deleteDatabase(this.name);
    16. const request = indexedDB.open(this.name);
    17. // 当数据库升级时,触发 onupgradeneeded 事件。升级是指该数据库首次被创建,或调用 open() 方法时指定的数据库的版本号高于本地已有的版本。
    18. request.onupgradeneeded = () => {
    19. const db = request.result;
    20. console.log('db onupgradeneeded')
    21. Object.keys(this.modelsOptions).forEach(key => {
    22. this.models[key] = new Model(db, key, this.modelsOptions[key])
    23. })
    24. };
    25. // 打开成功
    26. request.onsuccess = () => {
    27. console.log('db open onsuccess')
    28. console.log('addLog, deleteLog, clearLog, putLog, getAllLog, getLog')
    29. resolve(request.result)
    30. this.db = request.result
    31. };
    32. // 打开失败
    33. request.onerror = function(event) {
    34. console.log('db open onerror', event);
    35. reject(event)
    36. }
    37. })
    38. }
    39. }
    40. class Model {
    41. constructor(database, tableName, options) {
    42. this.db = database
    43. this.tableName = tableName
    44. if (!this.db.objectStoreNames.contains(tableName)) {
    45. const objectStore = this.db.createObjectStore(tableName, {
    46. keyPath: options.keyPath,
    47. autoIncrement: options.autoIncrement || false
    48. });
    49. options.index && Object.keys(options.index).forEach(key => {
    50. objectStore.createIndex(key, options.index[key]);
    51. })
    52. }
    53. }
    54. add(data) {
    55. // ... 省略上文的 add 函数
    56. }
    57. delete(id) {
    58. // ... 省略
    59. }
    60. put(data) {
    61. // ... 省略
    62. }
    63. getByIndex(keyPath, keyRange) {
    64. // ... 省略
    65. }
    66. get(indexName, value) {
    67. // ... 省略
    68. }
    69. }

    调用如下:

    1. (async function() {
    2. const db = new Database({
    3. name: 'db_test',
    4. modelsOptions: {
    5. log: {
    6. keyPath: 'id',
    7. autoIncrement: true,
    8. rows: {
    9. id: 'number',
    10. time: 'number',
    11. body: 'string',
    12. },
    13. index: {
    14. time: 'time'
    15. }
    16. }
    17. }
    18. })
    19. await db.createDB()
    20. await db.models.log.add({time: new Date().getTime(), body: 'log 1' })
    21. await db.models.log.add({time: new Date().getTime(), body: 'log 2' })
    22. await db.models.log.get(null, 1)
    23. const time = new Date().getTime()
    24. await db.models.log.put({id: 1, time: time, body: 'log AAAA' })
    25. await db.models.log.getByIndex('time', IDBKeyRange.only(time))
    26. })()

    当然这只是一个很简陋的模型,它还有一些不足。比如查询时,开发者调用时不需要接触 IDBKeyRange,类似是 sequelize 风格的,映射为 time: { $gt: new Date().getTime() },用 $gt 来替代 IDBKeyRange.lowerbound。

    批量操作

    值得一提的,IndexedDB 的操作性能和提交给它的事务多少有着紧密的关系,推荐尽可能使用批量插入。
    批量操作,可以采取事件委托来避免产生许多的 request 的 onsuccess、onerror 事件。

    1. class Model {
    2. // ... 省略 construct
    3. bulkPut(datas) {
    4. if (!(datas && datas.length > 0)) {
    5. return Promise.reject(new Error('no data'))
    6. }
    7. return new Promise((resolve, reject) => {
    8. const db = this.db;
    9. const transaction = db.transaction('log', 'readwrite')
    10. const store = transaction.objectStore('log')
    11. datas.forEach(data => store.put(data))
    12. // Event delegation
    13. // IndexedDB events bubble: request → transaction → database.
    14. transaction.oncomplete = function() {
    15. console.log('add transaction complete');
    16. resolve()
    17. };
    18. transaction.onabort = function (evt) {
    19. console.error('add transaction onabort', evt);
    20. reject(evt.target.error)
    21. }
    22. })
    23. }
    24. }

    性能探索

    IndexedDB 的 插入耗时 与提交给它的 事务数量 有显著的关联。我们设置一组对照实验:

    • 提交 1000 个事务,每个事务插入 1 条数据。
    • 提交 1 个事务,事务中插入 1000 条数据。

    测试代码如下:

    1. const promises = []
    2. for (let index = 0; index < 1000; index++) {
    3. promises.push(db.models.log.add({time: new Date().getTime(), body: `log ${index}` }))
    4. }
    5. console.time('promises')
    6. Promise.all(promises).then(() => {
    7. console.timeEnd('promises')
    8. })
    9. // promises: 20837.403076171875 ms

    1. const arr = []
    2. for (let index = 0; index < 1000; index++) {
    3. arr.push({time: new Date().getTime(), body: `log ${index}` })
    4. }
    5. console.time('promises')
    6. await db.models.log.bulkPut(arr)
    7. console.timeEnd('promises')
    8. // promises: 250.491943359375 ms

    减少事务提交非常重要,以至于需要有大量存入的操作时,都推荐日志在内存中尽可能合并下,再批量写入。

    值得一提的是,body 在上面的对照实验中只写入了个位数的字符,假设每次写 5000 个字符,批量写入的时间也只是从 250ms 提升到 300ms,提升的并不明显。

    让我们再来对比一组情况,我们会提交 1 个事务,插入 1000 条数据,在 0 到 500 万存量数据间进行测试,我们得到以下数据:

    1. for (let i = 0; i < 10000; i++) {
    2. let date = new Date()
    3. let datas = []
    4. for (let j = 0; j < 1000; j++) {
    5. datas.push({ time: new Date().getTime(), body: `log ${j}`})
    6. }
    7. await db.models.log.bulkPut(datas)
    8. datas = []
    9. if (i === 10 || i === 50
    10. || i === 100 || i === 500 || i === 1000 || i === 2000
    11. || i === 5000) {
    12. console.warn(`success for bulkPut ${i}: `, new Date() - date)
    13. } else {
    14. console.log(`success for bulkPut ${i}: `, new Date() - date)
    15. }
    16. }
    17. // success for bulkPut 10: 283
    18. // success for bulkPut 50: 310
    19. // success for bulkPut 100: 302
    20. // success for bulkPut 500: 296
    21. // success for bulkPut 1000: 290
    22. // success for bulkPut 2000: 150
    23. // 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。

    1. class LeaderElection {
    2. constructor(name) {
    3. this.channel = new BroadcastChannel(name)
    4. // 是否已经存在 leader
    5. this.hasLeader = false
    6. // 是否自己作为 leader
    7. this.isLeader = false
    8. // token 数,用于无 leader 时同时有多个 apply 的情况,来比对 maxTokenNumber 确定最大的作为 leader
    9. this.tokenNumber = Math.random()
    10. // 最大的 token,用于无 leader 时同时有多个 apply 的情况,来选举一个最大的作为 leader
    11. this.maxTokenNumber = 0
    12. this.channel.onmessage = (evt) => {
    13. console.log('channel onmessage', evt.data)
    14. const action = evt.data.action
    15. switch (action) {
    16. // 收到申请拒绝,或者是其他人已成为 leader 的宣告,则标记 this.hasLeader = true
    17. case 'applyReject':
    18. this.hasLeader = true
    19. break;
    20. case 'leader':
    21. // todo, 可能会产生另一个 leader
    22. this.hasLeader = true
    23. break;
    24. // leader 已死亡,则需要重新推举
    25. case 'death':
    26. this.hasLeader = false
    27. this.maxTokenNumber = 0
    28. // this.awaitLeadership()
    29. break;
    30. // leader 已死亡,则需要重新推举
    31. case 'apply':
    32. if (this.isLeader) {
    33. this.postMessage('applyReject')
    34. } else if (this.hasLeader) {
    35. } else if (evt.data.tokenNumber > this.maxTokenNumber) {
    36. // 还没有 leader 时,若自己 tokenNumber 比较小,那么记录 maxTokenNumber,
    37. // 将在 applyOnce 的过程中,撤销成为 leader 的申请。
    38. this.maxTokenNumber = evt.data.tokenNumber
    39. }
    40. break;
    41. default:
    42. break;
    43. }
    44. }
    45. }
    46. awaitLeadership() {
    47. return new Promise((resolve) => {
    48. const intervalApply = () => {
    49. return this.sleep(4000)
    50. .then(() => {
    51. return this.applyOnce()
    52. })
    53. .then(() => resolve())
    54. .catch(() => intervalApply())
    55. }
    56. this.applyOnce()
    57. .then(() => resolve())
    58. .catch(err => intervalApply())
    59. })
    60. }
    61. applyOnce(timeout = 1000) {
    62. return this.postMessage('apply').then(() => this.sleep(timeout))
    63. .then(() => {
    64. if (this.isLeader) {
    65. return
    66. }
    67. if (this.hasLeader === true || this.maxTokenNumber > this.tokenNumber) {
    68. throw new Error()
    69. }
    70. return this.postMessage('apply').then(() => this.sleep(timeout))
    71. })
    72. .then(() => {
    73. if (this.isLeader) {
    74. return
    75. }
    76. if (this.hasLeader === true || this.maxTokenNumber > this.tokenNumber) {
    77. throw new Error()
    78. }
    79. // 两次尝试后无人阻止,晋升为 leader
    80. this.beLeader()
    81. })
    82. }
    83. beLeader () {
    84. this.postMessage('leader')
    85. this.isLeader = true
    86. this.hasLeader = true
    87. clearInterval(this.timeout)
    88. window.addEventListener('beforeunload', () => this.die());
    89. window.addEventListener('unload', () => this.die());
    90. }
    91. die () {
    92. this.isLeader = false
    93. this.hasLeader = false
    94. this.postMessage('death')
    95. }
    96. postMessage(action) {
    97. return new Promise((resolve) => {
    98. this.channel.postMessage({
    99. action,
    100. tokenNumber: this.tokenNumber
    101. })
    102. resolve()
    103. })
    104. }
    105. sleep(time) {
    106. if (!time) time = 0;
    107. return new Promise(res => setTimeout(res, time));
    108. }
    109. }

    调用代码如下:

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

    总结

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

    • IndexedDB API 基于事务,偏向底层,操作繁琐,需要做个封装。
    • IndexedDB 性能最大的瓶颈在于事务数量,使用时注意减少事务的提交。
    • IndexedDB 并不在意事务是从哪个 tab 页提交,浏览器多 tab 页的情况下可能会对同一条数据记录进行多次操作,可以选举一个 leader 才允许写入,规避这个问题。
  • 相关阅读:
    B-神经网络模型复杂度分析
    jsArray数组复制方法性能测试2207292307
    Sqlserver如何调试存储过程
    [Git入门]---gitee注册及代码提交
    评测管理的业务逻辑
    链式存储的特点与设计由来
    Android学习笔记 47. Intent
    合宙Air724UG LuatOS-Air LVGL API控件-页面 (Page)
    Numpy学习
    Neo4j数据和Cypher查询语法笔记
  • 原文地址:https://blog.csdn.net/Q54665642ljf/article/details/126172580