頁面若是表現不符合預期,前端工程師在沒有 javascript 日誌的狀況下,很難 debug。因此就須要針對必要的步驟記錄日誌,並上傳。可是每記錄一條日誌就上傳並非一個合適的選擇,譬如若是生成日誌的操做比較密集,會頻繁產生上傳日誌請求的狀況。那麼咱們能夠在頁面作一第二天志的緩存,把日誌先存在本地,當緩存達到必定數量的時候一次批量上傳,即節約了網絡資源,對服務器也不會帶來太重的負擔。javascript
頁面存儲方案悉數下大概有這些:cookie、localStorage/sessionStorage、IndexedDB、WebSQL、FileSystem。cookie 存儲量有限,顯然不適合。localStorage/sessionStorage 必須本身設計及維護存儲結構。WebSQL 已是一種淘汰的標準,由於和 IndexedDB 功能重複了。FileSystem 也是比較邊緣不太推薦的標準。那麼 IndexedDB 容量合適,且能按條存儲,不用本身維護存儲結構,相較其餘方案是我此次打算的選型。前端
這裏只介紹持久化所須要的基本操做,大而全的 API 操做見MDN文檔java
第1、新建數據庫及「表」chrome
IndexedDB 幾乎全部的 API 都設計成異步的形式:數據庫
const DATABASE_NAME = 'alita'; let db = null; let request = window.indexedDB.open( DATABASE_NAME ); request.onerror = function(event) { alert( '打開數據庫失敗' + event.target.error ); }; request.onsuccess = function( event ) { // 若是打開成功,把數據庫對象保存下來,之後增刪改查都須要用到。 db = event.target.result; }
若是數據庫已經存在,indexedDB.open 會打開數據庫,若是數據庫不存在,indexedDB.open 會新建並打開。IndexedDB 也有相似於表的概念,在 IndexedDB 中叫 object store。而且新建 object store 還只能在特殊的場景下進行,先看下代碼再解釋:瀏覽器
const DATABASE_NAME = 'alita'; const OBJECT_STORE_NAME = 'battleangel'; let db = null; let request = window.indexedDB.open( DATABASE_NAME ); // 省略代碼。 // request.onerror = ... // request.onsuccess = ... request.onupgradeneeded = function(event) { let db = event.target.result; // 新建 object store let os = db.createObjectStore( OBJECT_STORE_NAME, {autoIncrement: true} ); // 若是想在新建完 object store 後初始化數據能夠寫在下面。 let initDataArray = [...]; initDataArray.forEach( function(data){ os.add( data ); } ); };
db.createObjectStore 只能在 onupgradeneeded 回調函數中被調用。onupgradeneeded 何時觸發呢?只有在你 indexedDB.open() 的數據庫是新的,沒有創建過的時候纔會被觸發。因此新建數據庫和新建 object store 並非隨時隨地均可以的(還有一種場景會觸發,等會下面會說到)。createObjectStore 的第二個參數 {autoIncrement: true} 表示你之後添加進數據庫的數據存儲策略採用自增 key 的形式。緩存
第2、添加日誌數據服務器
打開數據庫後咱們就能夠添加數據了,咱們來看下:cookie
let transaction = db.transaction( OBJECT_STORE_NAME, 'readwrite' ); // db 就是上面第一步保存下來的數據庫對象。 transaction.oncomplete = function(event) { alert( '事物關閉' ); }; transaction.onerror = function(event) { // Don't forget to handle errors! }; let os = transaction.objectStore( OBJECT_STORE_NAME ); let request = os.add( { // 日誌對象。 } ); request.onsuccess = function(event) { alert( '添加成功' ) }; request.onerror = function(event) { alert( '添加失敗' + event.target.error ); };
第3、讀取全部日誌數據網絡
在咱們的場景中,添加完日誌後,並不須要單獨查詢,只須要保存到必定數量後一次獲取所有日誌上傳就能夠了。獲取表中全部數據也有新老 API 之分,先看新的 objectStore.getAll,chrome48及以上支持。
let os = db.transaction( OBJECT_STORE_NAME, 'read' ).objectStore( OBJECT_STORE_NAME ); let request = os.getAll(); request.onsuccess = function(event) { let logObjectArray = event.target.result; };
若是你用戶的瀏覽器是不支持 getAll 方法,你還能夠經過遊標輪詢的方式來迭代出全部的數據:
let os = db.transaction( OBJECT_STORE_NAME, 'read' ).objectStore( OBJECT_STORE_NAME ); let logObjectArray = []; let request = os.openCursor(); request.onsuccess = function(event){ let cursor = event.target.result; if ( cursor ) { logObjectArray.push( cursor.value ); cursor.continue(); } };
當 cursor.continue() 被調用後,onsuccess 會被反覆觸發,當 event.target.result 返回的 cursor 爲空時,表示沒有更多的數據了。咱們的場景有點特殊,當日志存儲到必定數量時,咱們除了要讀出全部的數據上傳外,還要把已經上傳的數據刪除掉,這樣就不至於越存越多,把 IndexedDB 存爆掉的狀況,因此咱們修改代碼以下(請注意 db.transaction 的第二個參數此次不一樣了,由於咱們要刪數據,因此不能是隻讀):
let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let logObjectArray = []; if ( os.getAll ) { let request = os.getAll(); request.onsuccess = function(event) { logObjectArray = event.target.result; // 刪除全部數據 let clearRequest = os.clear(); // clearRequest.onsuccess = ... // clearRequest.onerror = ... // 上傳日誌 upload( logObjectArray ); }; } else { let request = os.openCursor(); request.onsuccess = function(event){ let cursor = event.target.result; if ( cursor ) { logObjectArray.push( cursor.value ); cursor.continue(); } else { // 刪除全部數據 let clearRequest = os.clear(); // clearRequest.onsuccess = ... // clearRequest.onerror = ... // 上傳日誌 upload( logObjectArray ); } }; }
以上的操做能完成咱們的日誌持久化的主流程了:存日誌 - 獲取已存日誌 - 上傳。
若是隻有上述代碼天然是沒有辦法完成一個健壯的持久化方案,還須要考慮以下幾個點:
咱們看到代碼了 IndexedDB 的操做都是異步,當咱們正在獲取全部日誌時,又有寫日誌的調用怎麼辦?會不會在獲取到全部日誌和刪除全部日誌中間,新日誌被添加進去了呢?這樣新日誌就會在沒有被上傳前就丟失了。這其實就是併發致使的問題,IndexedDB 有沒有鎖機制?
規範中規定 'readwrite' 模式的 transaction 同時只能有一個在處理 request,其餘 'readwrite' 模式的 transaction 即便生成了 request 也會被鎖住不會觸發 onsuccess。
let request1 = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ).add({}) let request2 = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ).add({}) let request3 = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ).add({}) // request1 沒有處理完,request2 和 request3 就處於 pending 狀態
當前一個 transaction 完成後,後一個 transaction 才能響應,因此咱們無需寫額外的代碼,IndexedDB 內部幫咱們實現了鎖機制。那麼你要問了,何時 transaction 完成呢?沒有看到你上面顯式調用代碼結束 transaction 呀?transaction 自動完成的條件有兩個:
let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.getAll(); request.onsuccess = function(event) { logObjectArray = event.target.result; // 刪除全部數據 let clearRequest = os.clear(); };
上述代碼中 os.clear() 之因此能被成功調用,是由於 os.getAll() 生成的 request 的 onsuccess 尚未執行完,os.clear() 就又生成了一個 request。因此當前 transaction 在 os.getAll().onsuccess 時並無結束。可是以下代碼中的 os.clear() 調用就會拋異常:
let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.getAll(); request.onsuccess = function(event) { logObjectArray = event.target.result; // 刪除全部數據 setTimeout( function(){ let clearRequest = os.clear(); // 這裏會拋異常說 os 對應的 transaction 已經被關閉了。 }, 10 ); };
咱們解決了併發問題,那麼咱們如何來判斷何時該上傳日誌了呢?有兩個方案:1 基於數據庫所存數據條數;2 基於數據庫所存數據的大小。由於每條日誌的數據或多或少都不同,用條數來判斷會出現一樣30條數據,此次數據只佔10k,下次可能有30k。因此相對理想的,咱們應該以所存數據大小並設定一個閾值。這樣每次上傳量比較穩定。不過告訴你們一個悲傷的消息,IndexedDB 提供了查詢條數的 API:objectStore.count,可是並無提供查詢容量的 API。因此咱們採起了預估的方式先把查出來的全部數據轉成 string,而後按 utf-8 的編碼規則,逐個 char 累加,大體的代碼以下:
/** * UTF-8 是一種可變長度的 Unicode 編碼格式,使用一至四個字節爲每一個字符編碼 * * 000000 - 00007F(128個代碼) 0zzzzzzz(00-7F) 一個字節 * 000080 - 0007FF(1920個代碼) 110yyyyy(C0-DF) 10zzzzzz(80-BF) 兩個字節 * 000800 - 00D7FF 00E000 - 00FFFF(61440個代碼) 1110xxxx(E0-EF) 10yyyyyy 10zzzzzz 三個字節 * 010000 - 10FFFF(1048576個代碼) 11110www(F0-F7) 10xxxxxx 10yyyyyy 10zzzzzz 四個字節 */ function sizeOf( str ) { let size = 0; if ( typeof str==='string' ) { let len = str.length; for( let i = 0; i < len; i++ ) { let charCode = str.charCodeAt( i ); if ( charCode<=0x007f ) { size += 1; } else if ( charCode<= 0x07ff ) { size += 2; } else if ( charCode<=0xffff ) { size += 3; } else { size += 4; } } } return size; }
因此咱們添加日誌的代碼能夠進一步完善成以下:
function writeLog( logObj ) { let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.getAll(); request.onsuccess = function(event) { let logObjectArray = event.target.result; logObjectArray.push( logObj ); let allDataStr = logObjectArray.map( l=>JSON.string(l) ).join( `分隔符` ); let allDataSize = sizeOf( allDataStr ); // 若是已存日誌加上這次要添加的日誌數據總和超過閾值,則上傳並清空數據庫 if ( allDataSize > `預設閾值` ) { os.clear(); upload( allDataStr ); } else { // 若是尚未達到閾值,則把日誌添加進數據庫 os.add( logObj ); } } }
到上面爲止正常的日誌持久化方案已經較爲完整了,上線也可以跑了(固然我示例代碼裏面省略了異常處理的代碼)。可是這其中有一個隱形的問題存在,咱們新建 object store 的時候存儲結構使用的是自增 key。每一個 object store 的自增 key 會隨着新加入的數據不斷的增長,刪除和 clear 數據也不會重置這個 key。key 的最大值是2的53次方(9007199254740992)。當達到這個數值時,再 add 就會 add 不進數據了。此時 request.onerror 會獲得一個 ConstraintError。咱們能夠經過顯式得把 key 設置成最大的來模擬下:
let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.add( {}, 9007199254740992 ); setTimeout( function(){ let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.add( {} ); request.onerror = function(event) { console.log( event.target.error.name ); // ConstraintError } }, 2000 );
這裏有個一個問題,ConstraintError 並非一個特定的 error 表示數據庫「寫滿」了,其餘場景也會觸發拋出 ConstraintError,譬如添加 index 時候重複了。規範中也沒有特定的 error 給到這種場景,因此這裏要特別注意下。固然這個最大值是很大的,咱們5秒鐘寫一第二天志也須要14億年寫滿。不過我比較任性,爲了代碼完備性,我給理論上兜個底。那麼怎麼才能重置 key 呢?很直接,就是刪了當前的 object store,再建一個。這個時候坑爹的事又出現了。就像上面提到的 db.createObjectStore 只能在 onupgradeneeded 回調函數中被調用同樣。db.deleteObjectStore 也只能在 onupgradeneeded 回調函數中被調用。那麼咱們上面提到了只有在新建的 db 的時候才能觸發這個回調,怎麼辦?這個時候輪到 window.indexedDB.open 的第二個參數出場了。咱們若是須要更新當前 db,那麼就能夠在第二個參數上傳入一個比當前版本高的版本,就會觸發 upgradeneeded 事件(第一次不傳默認新建數據庫的 version 就是1),代碼以下:
let nextVersion = 1; if ( db ) { nextVersion = db.version + 1; db.close(); // 這裏必定要注意,必定要關閉當前 db 再作 open,要否則代碼往下執行在 chrome 上根本不 work(其餘瀏覽器沒有測)。 db = null; } let request = window.indexedDB.open( DATABASE_NAME, nextVersion ); request.onerror = function() { // 處理異常 }; request.onsuccess = ( event )=>{ db = event.target.result; }; // 利用open version+1 的 db 重建 object store,由於 deleteObjectStore 只能在 onupgradeneeded 中調用。 request.onupgradeneeded = function(event) { let currentDB = event.target.result; currentDB.deleteObjectStore( OBJECT_STORE_NAME ); currentDB.createObjectStore( OBJECT_STORE_NAME, { autoIncrement: true } ); }
因此添加日誌的代碼最終形態是:
function recreateObjectStore( success ) { let nextVersion = 1; if ( db ) { nextVersion = db.version + 1; db.close(); // 這裏必定要注意,必定要關閉當前 db 再作 open,要否則代碼往下執行在 chrome 上根本不 work(其餘瀏覽器沒有測)。 db = null; } let request = self.indexedDB.open( DATABASE_NAME, nextVersion ); request.onerror = function() { // 處理異常 }; request.onsuccess = ( event )=>{ db = event.target.result; success && success(); }; // 利用open version+1 的 db 重建 object store,由於 deleteObjectStore 只能在 onupgradeneeded 中調用。 request.onupgradeneeded = function(event) { let currentDB = event.target.result; currentDB.deleteObjectStore( OBJECT_STORE_NAME ); currentDB.createObjectStore( OBJECT_STORE_NAME, { autoIncrement: true } ); } } let recreating = false; // 標誌位,爲了在沒有從新創建 object store 前不要重複觸發 recreate function writeLog( logObj ) { let os = db.transaction( OBJECT_STORE_NAME, 'readwrite' ).objectStore( OBJECT_STORE_NAME ); let request = os.getAll(); request.onsuccess = function(event) { let logObjectArray = event.target.result; logObjectArray.push( logObj ); let allDataStr = logObjectArray.map( l=>JSON.string(l) ).join( `分隔符` ); let allDataSize = sizeOf( allDataStr ); // 若是已存日誌加上這次要添加的日誌數據總和超過閾值,則上傳並清空數據庫 if ( allDataSize > `預設閾值` ) { os.clear(); upload( allDataStr ); } else { // 若是尚未達到閾值,則把日誌添加進數據庫 let addRequest = os.add( logObj ); addRequest.onerror = function(e) { // 若是添加新數據失敗了 if ( error.name==='ConstraintError' ) { // 1.先把已有數據上傳 uploadAllDbDate(); // 2. 看看是否已經在重置了 if ( !recreating ) { recreating = true; // 3. 若是沒有重置,就重置 object store recreateObjectStore( function(){ // 4. 重置完成,再添加一遍數據 recreating = false; writeLog( logObj ); } ) } } } } } }
好了到如今爲止,整個日誌持久化方案的流程就閉環了,固然實際代碼確定要更精細,結構更好。由於併發鎖問題,數據大小問題,重置 object store 問題都不是很容易查到解決方案,網上大多數只有一些基本操做,因此這裏記錄下,方便有須要的人。