本文从理论和实战的两个方面讲述前端离线日志系统是如何构建的,由于内容比较多,我将文章分为 3 部分来说述整个日志系统的设计。javascript
随着前端项目愈加复杂,前端日志的重要性也愈加凸显,一般咱们使用网络请求上报的方式记录日志,好比 badjs,友盟的 cnzz等等。采用网络请求上报的方式存在如下痛点:html
在 badjs 开发中,为了解决以上问题,咱们使用用白名单的方式上报一些非错误日志(方便查询问题),即只有知足必定条件的用户才上报数据。白名单的方式也带来了一些问题,好比某个用户反馈页面白屏了,可是咱们在 badjs 后台没有查到当前用户的日志,可能仅仅是由于用户网络不佳致使某个 js 加载失败,可是你没法给出使人信服的证据。前端
因而离线日志应运而生,离线日志几乎解决了以上全部痛点,在客户端中也早已经普遍应用。为何前端一直没有合适的离线应用平台呢?一是由于以前技术存在缺陷,在 IndexedDB 以前,前端几乎没有好用的方式来存储离线日志。localstorage 虽然能够必定程度上知足需求,可是其存在的问题也是显而易见的。java
直到 IndexedDB 的诞生加上广大浏览器对其的支持,才给了前端离线日志带来了成熟的时机。node
简单的说,IndexedDB 是一个基于浏览器实现的支持事型务的键值对数据库,支持索引。IndexedDB 虽然不能使用 SQL 语句,可是存储要求数据结构化(既能够存文本,又能够存文件以及blobs),经过索引产生的指针来完成查询操做。git
IndexedDB 有如下优势:github
因为 IndexedDB 是低级 API,因此想要使用 IndexedDB 还须要先理解一些基本概念。web
const request = window.indexedDB.open('test', 1)
复制代码
test 表示数据库的名字,若是数据库不存在则主动建立。第二个参数表示数据库的版本,用整数表示,默认是 1。chrome
indexedDB.open()
返回一个 IDBOpenDBRequest 对象,经过三个事件 onerror
, onsuccess
, onupgradeneeded
来处理打开数据库的操做。数据库
let db
const request = indexedDB.open('test')
request.onerror = function(event) {
console.error('open indexedDB error')
}
request.onsuccess = function(event) {
db = event.target.result
console.log('open indexedDB success')
}
request.onupgradeneeded = function(event) {
db = event.target.result
console.log('upgrade indexedDB success')
}
复制代码
在建立一个新的数据库或者增长已存在的数据库的版本号(当打开数据库时,指定一个比以前更大的版本号), onupgradeneeded 事件会被触发。
在 onsuccess
和 onupgradeneeded
中经过 event.target.result
来获取数据库的实例。
在使用 indexedDB.open()
方法后,数据库就已经新建了,不过里面尚未任何内容。咱们经过 db.createObjectStore()
来建立表。
request.onupgradeneeded = function(event) {
db = event.target.result
console.log('upgrade indexedDB success')
if (!db.objectStoreNames.contains('logs')) {
const objectStore = db.createObjectStore('logs', { keyPath: 'id' })
}
}
复制代码
上述代码会建立一个叫作 logs 的表,主键是 id,若是想要让自动生成主键,也能够这样写:
const objectStore = db.createObjectStore('logs', { autoIncrement: true })
复制代码
keyPath & autoIncrement
keyPath | autoIncrement | 描述 |
---|---|---|
No | No | objectStore 中能够存储任意类型的值,可是想要新增一个值的时候,必须提供一个单独的键参数。 |
Yes | No | 只能存储 JavaScript 对象,而且对象必须具备一个和 key path 同名的属性。 |
No | Yes | 能够存储任意类型的值。键会自动生成。 |
Yes | Yes | 只能存储 JavaScript 对象,一般一个键被生成的同时,生成的键的值被存储在对象中的一个和 key path 同名的属性中。然而,若是这样的一个属性已经存在的话,这个属性的值被用做键而不会生成一个新的键。 |
经过 objectStore 来建立索引:
// 建立一个索引来经过时间搜索,时间多是重复的,因此不能使用 unique 索引。
objectStore.createIndex('time_idx', 'time', { unique: false })
// 使用邮箱创建索引,为了确保邮箱不会重复,使用 unique 索引
objectStore.createIndex("email", "email", { unique: true })
复制代码
IDBObject.createIndex() 的三个参数分别为“索引名称”、“索引对应的属性”、索引属性(是否 unique 索引)。
IndexedDB 中插入数据必须经过事务来完成。
// 使用事务的 oncomplete 事件确保在插入数据前对象仓库已经建立完毕
objectStore.transaction.oncomplete = function(event) {
// 将数据保存到新建立的对象仓库
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
store.add({
id: 18,
level: 20,
time: new Date().getTime(),
uin: 380034641,
msg: 'xxxx',
version: 1
})
}
复制代码
在初始化 IndexedDB 的时候,会触发 onupgradeneeded 事件,而在之后的对 DB 的调用中,都只会触发 onsuccess 事件。所以咱们将对数据库的 CURD 操做作如下封装。
假如前面已经建立了一个 keyPath 为 'id' 的名为 logs 数据库。
function addLog (db, data) {
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
const request = store.add(data)
request.onsuccess = function (e) {
console.log('write log success')
}
request.onerror = function (e) {
console.error('write log fail')
}
}
addLog(db, {
id: 1,
level: 20,
time: new Date().getTime(),
uin: 380034641,
msg: 'add new log',
version: 1
})
复制代码
写数据的时候须要制定表名,而后建立事务,经过 objectStore 获取 IDBObjectStore 对象,再经过 add 方法进行插入。
经过 IDBObjectStore 对象的 put 方法,能够完成对数据的更新操做。
function updateLog (db, data) {
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
const request = store.put(data)
request.onsuccess = function (e) {
console.log('update log success')
}
request.onerror = function (e) {
console.error('update log fail')
}
}
updateLog(db, {
id: 1,
level: 20,
time: new Date().getTime(),
uin: 380034641,
msg: 'this is new log',
version: 1
})
复制代码
IndexeDB 使用 put 方法更新数据,不过 put 的前提是必须有 unique 索引,IndexeDB 根据 unique 索引做为 key 更新数据。put 方法相似于 upsert,若是 unique 索引对应的值不存在,则直接插入新的数据。
经过 IDBObjectStore 对象的 get 方法,能够完成对数据的读取操做。与更新数据相同,经过 get 方法读取数据也须要 unique 索引。读取的数据在 onsuccess 事件中查看。
function getLog (db, key) {
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
const request = store.get(key)
request.onsuccess = function (e) {
console.log('get log success')
console.log(e.target.result)
}
request.onerror = function (e) {
console.error('get log fail')
}
}
getLog(db, 1)
复制代码
function deleteLog (db, key) {
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
const request = store.delete(key)
request.onsuccess = function (e) {
console.log('delete log success')
}
request.onerror = function (e) {
console.error('delete log fail')
}
}
复制代码
删除数据的时候即便数据不存在也会正常进入 onsuccess 事件中。
因为 IndexedDB 中并无提供 SQL 的能力,因此不少时候咱们想要查找一些数据,只能经过遍历的方式。
function getAllLogs (db) {
const transaction = db.transaction('logs', 'readwrite')
const store = transaction.objectStore('logs')
const request = store.openCursor()
request.onsuccess = function (e) {
console.log('open cursor success')
const cursor = event.target.result
if (cursor && cursor.value) {
console.log(cursor.value)
cursor.continue()
}
}
request.onerror = function (e) {
console.error('oepn cursor fail')
}
}
复制代码
cursor 使用相似递归的方式对表进行遍历,经过 cursor.continue() 方法进入下一次循环。
在前面的例子中,咱们都是经过主键去获取数据的,经过索引的方式,可让咱们用别的属性查找数据。
假设在新建表的时候就建立了 uin 索引。
objectStore.createIndex('uin_index', 'uin', { unique: false })
复制代码
在查询数据的时候就能够经过 uin 索引的方式:
function getLogByIndex (db) {
const transaction = db.transaction('logs', 'readonly')
const store = transaction.objectStore('logs')
const index = store.index('uin_index')
const request = index.get(380034641) // 注意这里数据类型要一致
request.onsuccess = function (e) {
const result = e.target.result
console.log(result)
}
}
复制代码
使用上述索引查询的方式,只能查到第一个知足条件的数据,若是要查到更多的数据,还须要结合 cursor 来操做。
function getAllLogsByIndex (db) {
const transaction = db.transaction('logs', 'readonly')
const store = transaction.objectStore('logs')
const index = store.index('uin_index')
const request = index.openCursor(IDBKeyRange.only(380034641)) // 这里能够直接写值
request.onsuccess = function (e) {
const cursor = event.target.result
if (cursor && cursor.value) {
console.log(cursor.value)
cursor.continue()
}
}
}
复制代码
通常经过 IDBKeyRange 对象上的上述几个方法来进行多模式的查询操做。
在前面的例子中已经能够看到数据库的雏形了,表结构以下:
这些是上报内容,那接口如何设计呢?
在一个前端离线日志系统中,至少要提供如下五个接口:
具体能够查看 wardjs-report 项目中的 offline 模块。
小程序中由于有 wx.getStorage(Object object)
接口,所以也能够模拟离线日志的存储功能。 这个分支 feat_miniprogram 是咱们小程序离线上报的解决方案。
IndexedDB 的性能很是好,并且基本都是异步操做,因此虽然做用于浏览器,可是常规的读写操做基本不会对产品有额外的影响。
iMac 4GHz i7/16GB DDR3 macOS Majave 10.14.2 Chrome 72.0
插入 1w 条日志数据,每条日志长度为500。
链接DB耗时(10次求平均):3.5ms 插入1条数据(10次求平均):不到 1 ms 链接DB -> 插入数据 -> 释放链接(10次求平均):4.3ms 同时插入10条数据(10次求平均):不到 1 ms
iPhone 6sp iOS 12.1.4 safari
链接DB耗时(10次求平均):2.3ms 插入1条数据(10次求平均):不到 1 ms 链接DB插入数据释放链接(10次求平均):2.3ms 同时插入10条数据(10次求平均):不到 1 ms
测试结果比较奇怪,居然手机端的成绩要优于 PC 端的成绩。可能跟浏览器有关吧,还有当时测试的时候,电脑中有不少 app 和 chrome tab 没有关,这个可能也有影响吧。
不过我没有再去作测试了,由于上面的数据已经足够惊艳了,你能够理解为,咱们简单地插入数据是基本不耗时的。没错,IndexedDB 就是这么强悍。
因为内容太多,将文章分为多篇写。本篇简单介绍 IndexedDB 的用法以及性能测试。在前端离线日志系统的构建中,这是最关键的一环,数据存储,既要保证数据的可靠性,又要保持必定的性能,同时不能对用户正常的操做产生反作用,经过咱们简单的测试,我以为 IndexedDB 彻底有能力胜任这份工做。
关注公众号:【IVWEB社区】,每周推送精品技术周刊 。