本文從理論和實戰的兩個方面講述前端離線日誌系統是如何構建的,由於內容比較多,我將文章分爲 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社區】,每週推送精品技術週刊 。