為什么要續寫一篇這樣的指南?在indexDB中,指南(一)深入介紹了事務,但對於如何用好事務還不夠詳細。此外,各種事件在事務、請求、DB三者之間傳播;objectStore創建時 key的規則定義;indexDB的意外情況等,這些在實際開發中非常重要的知識點,指南(一)也沒有介紹。所以續寫很有必要。
開始前,還是強烈建議你好好讀一下 indexDB出坑指南(一),並且至少通過demo體驗過了indexDB,至少嘗試過通過事務保持數據的一致性。
再談indexDB中的事務
在你使用indexDB時,事務絕對是第一等公民,即便是只有一條簡單請求的功能實現,你也需要先開啟事務。回顧一下,指南一中 刪除上傳任務:刪除任務信息和文件信息,所有請求同時必須成功,或者同時失敗,否則就會引起數據不一致、永久性的臟數據等問題。能夠保證數據的一致性,這就是事務的特性。
實際開發中,每個場景實際上都對應着一個事務,所以為你的項目寫一個事務服務是明智的選擇:事務服務的每個方法,都對應一個完整的操作場景。寫一個事務服務又有什么要點呢?
事務中的封裝
封裝抽取可復用代碼,避免重復code,在事務這里可能要好好思考一下。
舉個例子:數據清理場景下,我們會遍歷所有數據,找出過期的並清除掉;數據刪除場景下,我們只需要根據傳入的關鍵字刪除一條數據。
clearData(){ const trans = DB.transaction(STORE_NAME, 'readwrite') const objectStore = trans.objectStore(STORE_NAME) const rangeReq = objectStore.openCursor() rangeReq.onsuccess = () => { let cursor = rangeReq.result; if (!cursor) { return } let task = cursor.value if (task.lastModify < minTime) { objectStore.delete(task.id) // 重復點:把這想象成有很多行,你非常想復用的那種。 //你也許想直接調用delTask(task),但這會報錯,因為兩個readwrite事務不能同時開啟 } cursor.continue() } } delTask(task){ const trans = DB.transaction(STORE_NAME, 'readwrite') const objectStore = trans.objectStore(STORE_NAME) objectStore.delete(task.id) // 重復點 }
你可以再抽一個下面這樣的方法,然后將上面的重復點,換成調用 delOperate(objectStore, task)。
delOperate(objectStore, task){ // 相同部分抽取為函數,函數不會開啟事務,但是會使用事務下的objectStore,index等 objectStore.delete(task.id) }
你還可以改造clearData方法,只是收集需要清理的task,而不做真正的清理。結束后,對收集到的每個task,在調用 delTask 方法。
實現promise的正確方式
indexDB操作都是基於事件的,而前端開發喜歡異步(promise化)。如何將操作封裝成異步的呢?指南一 只舉了一個失敗的例子,其實正確封裝很簡單。
const trans = db.transaction(['tasks', 'files'], 'readwrite') const tasksStore = trans.objectStore('tasks') const filesStore = trans.objectStore('files') return new Promise((resolve,reject)=>{ tasksStore.delete(processId).onsuccess = () => { filesStore.delete(processId).onsuccess = () => { resolve() } } })
上面的封裝很簡單,一看就懂。但它有2個問題:
兩個請求是串發的了(他們本可以並發的),這無疑影響了性能;
追蹤所有請求,是一個巨大的工作量。考慮一下:你需要追蹤每個請求的error事件,來實現出錯時reject;非常復雜的事務,串發請求肯定就是地獄嵌套了;你也可以讓請求並發享受高性能,但要借助類似於 totalRequestCount,successRequestCount 這些輔助變量來控制了。
所幸的是,除了請求,事務本身也有3個事件:complete(所有請求都成功后觸發),error(任何一個請求失敗后觸發),abort(意外或主動終止)
所以最佳封裝應該是這樣的:
const trans = db.transaction(['tasks', 'files'], 'readwrite') const tasksStore = trans.objectStore('tasks') const filesStore = trans.objectStore('files') // 盡情讓你的請求並發起來吧(請求無需追蹤,簡潔,清晰,高效): tasksStore.delete(processId) filesStore.delete(processId) // 直接在事務上封裝:注意事務失敗有error和abort兩種事件 return new Promise((resolve,reject)=>{ trans.oncomplete = () => { resolve() } trans.onerror = trans.onabort = e => { reject(e) } })
注意避免副作用
數據通過引用傳遞(什么是引用傳遞?請自行百度)時,一處改變 所有地方都同步變化,這個特點可能是讓你編碼更高效的技巧,但也可能會給你帶來痛苦的噩夢。在你使用這一技巧時,如果事務失敗一定要記得消除副作用。
下方例子注意注釋處:
clearArticle(task, article) { const trans = DB.transaction([STORE_NAME], 'readwrite') const objectStore = trans.objectStore(STORE_NAME) const cloneOpLen = article.opLen // 臨時緩存 const cloneTime = task.lastModify // 臨時緩存 // … … 省略其他代碼 article.opLen = 0 // 修改 task.lastModify = Date.now(); // 修改 objectStore.put(article) objectStore.put(task) return new Promise((resolve, reject) => { trans.onerror = trans.onabort = e => { article.opLen = cloneOpLen // 事務失敗,回滾對變量的修改,消除副作用 task.lastModify = cloneTime // 事務失敗,回滾對變量的修改,消除副作用 // 上面兩行尤為重要:事務失敗,indexDB中的數據自動回滾,但變量的修改需要你自己控制其回滾了! reject(e) } }) }
事務的其他事項
1、事務只能自動提交,不能手動提交。
2、設計成事務是因為考慮到用戶可能打開兩個選項卡,用事務保證數據的一致性。采用異步事務,通過事件交互,是為了防止大量數據的讀寫拖慢網頁。
3、一個鏈接可以同時有多個活動的事務,前提是不與寫操作事務的作用域有重疊。(一個store一旦有相關的“可寫事務”在活動,那么這個store相關的事務只能有這一個在活動)
作用域:事務涉及到的store,開啟事務時第一個參數指明。規范中當傳入一個空數組,表示事務涉及到所有的store,但這個特性最好不要用,有歧義且性能不好。
4、事務oncomplete之后,數據就持久化到磁盤中了嗎?99.99999...%的情況是。當操作系統被告知去寫入數據后 complete
事件便被觸發,但此時數據可能還沒有真正的寫入磁盤。會有極小的機會發生以下情況:如果操作系統崩潰或在數據被寫入磁盤前斷電,那么整個事務都將丟失。由於這種災難事件是罕見的,通常並不需要過分擔心。
如果極端情況下非要保證100%,可以去了解實驗中的模式:readwriteflush,
真正寫入磁盤后才觸發complete
indexDB中的事件系統
在indexDB中三種對象會產生事件,用一張圖表示:
請求都有有兩個事件:
error(失敗),success(成功)。
IDBOpenRequest開啟數據庫鏈接請求:額外多了block事件(其他頁面已有其他版本,通常是較低版本的連接時觸發)和 upgradeneeded事件。如果成果觸發了upgradeneeded事件,Handler處理完成后才會觸發success事件。
事務:VersionChange事務和普通事務都三個事件:
error事件:事務下任何一個請求出錯會觸發
complete事件:事務下所有請求都成功后會觸發
abort事件:外界的異常(如關閉應用,磁盤異常等)以及主動調用 transaction.abort() 會觸發
DB:
error和abort事件:事務的error和abort事件會冒泡給DB。如果你想全局處理操作異常的話,可以直接監聽DB的這兩個事件。
versionchange事件:正處於鏈接狀態的DB,如果其他頁面要升級數據庫會觸發當前頁面的改事件
close事件:關閉連接后觸發。外界異常會導致連接關閉,當然這會先abort所有事務。主動調用 db.close() 也會關閉連接。
上面有個黃色的圈:開啟數據庫請求的block事件,upgradeneeded事件和數據庫的versionchange事件,從上面的描述就可以看出三者相關性很強!
相信你已經豁然開朗了。
創建ObjectStore時的Key
objectStore中存放的每一條數據,總會關聯一個key,類似於SQL數據庫中的主鍵。
數據的增刪改查中,add(增)、put(改或增)返回的結果就是key,delete(刪)傳入的參數都是一個key。不用索引,直接objectStore.get(查)傳入的參數也是key。
key是在創建objectStore時定義的:
// 'keyPath' 或 'autoIncrement'(即所謂的keyGenerator) 兩兩組合有以下四種定義key的方法: const objStore = db.createObjectStore("storeName"); const objStore = db.createObjectStore("storeName", { autoIncrement : true }); const objStore = db.createObjectStore("storeName", { keyPath : 'id' }); const objStore = db.createObjectStore("storeName", { keyPath : 'id', autoIncrement : true });
- 沒有配置autoIncrement時,新增和修改數據時必須指明key(如果配置了keyPath,對應的字段必須存在);
- 配置autoIncrement時,新增和修改時可以不指明key(如果配置了keyPath,對應的字段可以沒有);
這時會自動生成一個數值類型的key:自動生成從1開始,不斷遞增;即使刪除數據,甚至清空store,也不會影響key的遞增,除非事務回滾;如果新增數據時顯式指明了一個很大的數值類型key,超過了最后一次的遞增結果,那么接下來會以這個數值為基礎來遞增。當然你還是可以顯式指明一個(任意類型的)key。 - 配置keyPath時,顧名思義就是以存放到store中的數據中的屬性值來作為key,比如上面第三種情況,每次新增或修改的數據都必須有id字段,字段值就是對應的key值(第四種情況可以沒有id字段,這時會自動生成一個key,並為數據追加一個id字段,值就是這個key)。
- 配置keyPath時,你存放的數據就只能是js對象了。不配置keyPath時,可以存放任何數據。
- 不配做keyPath時,store中存放的數據本身是沒有key的信息的,這種store一般是作為其他store的附屬的。
- 顯示指明key時,key可以是任何類型,甚至二進制類型。
沒有keyPath時,新整修改數據指明key的示例:
objectStore.add(data, 'fkey1') objectStore.put(data, 'fkey1') objectStore.delete('fkey1')
索引
說到key就不得不說索引。
key是主鍵,每個store都有key,每條數據都有唯一的key與之關聯,很多操作(如刪除一條數據)就是通過key來進行的。key就是一個身份標識,是數據的“代表”。
索引是對數據的屬性而言的,在indexDB中如果數據沒有包含索引的字段,那么這個數據仍然能添加成功,只是不會出現在這個索引中,這是indexDB相對於MySQL這樣的數據庫的又一個區別,也是索引與key的一個區別!
索引可以是唯一的,也可以不是。key必須是唯一。
可以有復合索引,集合索引(multiEntry配置)等高級用法。【參考】
很多文章都將二者混為一談,簡單的認為主鍵就是一個唯一索引,但這時錯誤的。記住key是數據的代表,索引只是數據的屬性相關,明白這一點你才能更好的設計!
indexDB的異常
異常一:indexDB權限被異常阻止。
用戶可以選擇禁用indexDB,這么有用的科技為什么要禁用?
One of the main design goals of IndexedDB is to allow large amounts of data to be stored for offline use. Obviously, browsers do not want to allow some advertising network or malicious( [məˈlɪʃəs] 惡意的) website to pollute(污染) your computer, so browsers used to prompt the user the first time any given web app attempts to open an IndexedDB for storage.
異常二:瀏覽器被關閉(異常的或認為的):
瀏覽器隨時可能被關閉,所以理論上任何事務都是沒法保證其完成,任何事務都有可能被abort!
有些很坑的產品經理,經常提一些在瀏覽器關閉前做一些事的需求。在瀏覽器關閉前做一些事是不可取的,官方也表明onbeforeunload、onunload這類事件僅僅建議給出提示而已。
對於這樣的需求,也許需要你更好的去理解需求本質。比如“頁面關閉前,保存表單”,你可以將其做成“一旦用戶修改表單,就緩存起來”!總之,在瀏覽器關閉前去操作數據庫是不可取的!
異常三:存放數據的磁盤文件不可用
磁盤文件被異常刪除,數據過多導致超出上限無法繼續添加數據。
參考:indexDB存儲規范
當以上三種異常發生時,就會進行以下處理:
1,所有受影響的事務都會產生AbortError,就和調用IDBTransaction.abort()
相同。
2,所有事務完成或abort后,連接關閉(相當於db.close())
3,DB就會收到一個onclose事件,你可以通過監聽它,來提示用戶。
為了識別出這些異常,監聽db的close事件,是非常重要的