01 前言
当一个 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 页的情况下可能会对同一条数据记录进行多次操作。本篇文章将结合笔者的实践经验,就以上问题来进行相关探索。
02 Log 日志存储场景
有这样一个场景,客户端产生大量的日志并存放若干日志。在发生某些错误时(或者长连接得到服务器的指令时)可拉取本地全部日志内容并发请求上报。
如图所示:
这是一个很好的设计到了 IndexedDB CRUD 场景的操作,在这里,我们只关注 IndexedDB 存储这部分。有关于 IndexedDB 的基础概念,如仓库 IDBObjectStore、索引 IDBIndex、游标 IDBCursor、事务 IDBTransaction,限于篇幅请参照 IndexedDB-MDN。
(https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API)
创建数据库我们知道 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 的数据范围内,插入耗时没有明显的提升。当然查询取决的因素更多,其耗时留待读者们自行验证。
04 多 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/indexeddb-test)
原文地址:https://mp.weixin.qq.com/s?__biz=MzI1NTMwNDg3MQ==&mid=2247490921&idx=1&sn=3359bc50d441df9ce58bec68e340a87d&utm_source=tuicool&utm_medium=referral
查看更多关于IndexedDB 代码封装、性能摸索以及多标签支持的详细内容...