indexDB出坑指南

enne5w4發表於2020-05-13

對於入了前端坑的同學,indexDB絕對是需要深入學習的。

本文針對indexDB的難點問題(事務資料庫升級)做了詳細的講解,而對於indexDB的特點和使用方法只簡要的介紹了一下。如果你有一些使用indexDB的經驗的話,本文一定能讓你有更深的收穫!但如果你尚需要一個詳盡教程的話,為你推薦教程使用indexDB

indexDB的特點

優點:

  • indexDB 大小取決於你的硬碟,可以說是不受限的
  • 可以直接儲存任何 js 資料,包括blob(其實是支援結構化克隆的資料),不像 storage 只能存放字串!
  • 可以建立索引,提供高效能的搜尋功能!
  • 採用事務,保證資料的準確性和一致性。(絕對的黑科技,某些棘手的場景只能用它了!

唯一的缺點就是太複雜了,比storage和cookie都要複雜的多!

使用indexDB

使用分為3步:
 1、開啟資料庫DB
 2、在versionChange事件中 建立表(ObjectStore),包括定義表的鍵,索引規則等。
 3、運算元據(增刪改查)

運算元據又分為4步:
 1、開啟事務
 2、獲取事務中的objectStore
 3、通過objectStore發起操作請求
 4、定義請求的回撥函式

開啟資料庫很簡單:

const opendbRequest = indexedDB.open("MyDatabase", version);  // 注意:並不是直接開啟資料庫,而是發起了一個開啟資料庫的請求!

let db;
opendbRequest.onsuccess = function(event) {
  // 請求的 success 回撥裡面就可以獲取開啟的資料庫了:
  db = event.target.result; // 或 opendbRequest.result
};

當 indexDB.open 第二個引數version 的值 比 已經存在的DB的版本號大時,或者 當前不存在對應的DB 這是第一次開啟資料庫時,就會觸發changeVersion事件,通過onupgradeneeded設定回撥。一定要記住這點!

opendbRequest.onupgradeneeded = e=>{
    const db = e.target.result
    // 只有在這個回撥裡面,才能定義(增刪改)物件倉庫及物件倉庫的規則!
    // 術語:物件倉庫(objectStore) 相當於 MySQL中的表(table),mogodb中的repository(倉庫)

    // 建立objectStore
    // 建立時 一定要注意定義好key的規範,key就相當於 MySQL裡的主鍵,關於key的規範請參考推薦教程
    const objectStore = DB.createObjectStore('myObjectStore', { keyPath: 'id' });

    // 建立索引:
    // 有聯合索引,唯一索引,對陣列欄位建索引 這些強大的功能,推薦教程裡都有講解!
    objectStore.createIndex('index_name', ['field1', 'field2', 'field3'], { unique: true })
}

現在我們瞭解瞭如何開啟一個DB,以及如何在DB中定義 objectStore 及其規則(schedule),接下來就是往資料庫的objectStore中增刪改查資料了。4步如下:

// 建立事務:
// 第一個引數指明事務所涉及的objectStores,如果只有一個objectStore,[]可以省略,本例可以直接寫 'myObjectStore'
// 第二引數指明事務運算元據的方式,如不寫 預設是 readonly,表示只能讀資料 不能寫。如果不僅僅是讀,還有增刪改資料,必須用 readwrite。
//  請注意 readwrite 事務效能較低,並且只能有一個處於活動狀態。所以除非必要,不要隨意使用readwrite!
let transaction = db.transaction(['myObjectStore'],'readwrite')  

// 獲取事務中的objectStore (注意:objectStore只有事務才能獲取,而不能通過db直接獲取)
let objectStore = transaction.objectStore('myObjectStore')

// 在事務objectStore上發起運算元據的請求:(注意只有objectStore才能發起運算元據的請求! 
//
add 新增, put 不存在相同key值的,是新增;存在,是修改,
//
delete 刪除,get 查詢 這兩個引數只能傳入特定物件的 key!如:let request = objectStore.delete(myKey)
let request = objectStore.add(modifyData)
// 請求成功的回撥
request.onsuccess = e => {
console.log(
'新增資料成功。新資料的key是:',e.target.result)
}

indexDB的查詢介紹

以上操作基本都是要先知道資料的key值,如delete和get都要傳入一個key。但更多時候,我們並不知道key(特別是當你採用Key Generator生成key值時),比如我們也許只知道要delete或get的資料的name是“Jeck”,這時我們如何得到想要操作的資料的key呢。
我們可以通過全表查詢objectStore.getAll() ,然後逐個遍歷表中的資料,但這是效能最低的查詢,也是所有資料庫設計中要竭力避免的查詢,這裡就不詳述了!
indexDB還提供了索引查詢,對於上面的情況只需對name建立一個索引,然後就可以直接查詢 name為“Jeck”的資料了。
索引查詢和key查詢,是本節要介紹的重點。不過還是要強調一點,雖然查詢的方式相當多,但都大同小異!記住下面三點將有助於你快速掌握:

  • 索引和key的操作形式(傳遞引數的形式,查詢條件的形式)是一模一樣的
  • IDBIndex和ObjectStore的各種api:get, getKey, getAll, getAllKeys, openCursor, openKeyCursor  裡面都可以傳入條件,也可以不傳,條件可以是key的或索引的特定值或範圍。
  • 需要一次操作多個資料的情況很常見,但是並不提倡直接 getAll( condition )或 getAllKeys( condition ) 這樣的操作,思考一下它的效能,以及佔用的記憶體資源你就明白了——我們更多采用的是遊標(cursor)。

鑑於所有操作都基本相同,所以接下來舉一個常見的使用遊標且稍微有點難的查詢場景!開始之前:

// 回顧 前面定義的索引:(索引必須先建立再使用)
objectStore.createIndex('index_name', ['field1', 'field2', 'field3'], { unique: true })

查詢單個資料:

// 單個查詢:
const dbIndex = objectStore.index('index_name')
// 注意: 下面傳入索引值的語法規則,v1 對應欄位 field1,v2 對應欄位 field2, v3 對應欄位 field3
// 注意:如果索引不是unique的(unique索引get最多當然只會得到一條資料),有可能有多條對應的資料,這時get只會得到最小key的資料。獲取所有資料要使用 getAll
dbIndex.get([v1, v2, v3]).onsuccess = e => {
    let data = e.target.result; // 得到符合條件的資料    
}

使用IDBKeyRange查詢範圍內的多個資料:

// 遊標查詢範圍內的多個:
const range = IDBKeyRange.bound([min1, min2, min3], [max1, max2, max3]) // 除了bound 還有 only,lowerBound, upperBound 方法,還可以指明是否排除邊界值
dbIndex.openCursor(range, "prev").onsuccess = e => {   // 傳入的 prev 表示是降序遍歷遊標,預設是next表示升序;如果索引不是unique的,而你又不想訪問重複的索引,可以使用nextunique或prevunique,這時每次會得到key最小的那個資料
    let cursor = e.target.result;
    if (cursor) {
        let data = cursor.value  // 資料的處理就在這裡。。。 [ 理解 cursor.key,cursor.primaryKey,cursor.value ]
        cursor.continue()
    } else {
        // 遊標遍歷結束!
    }
}

需要說明的是 IDBKeyRange.bound([min1, min2, min3], [max1, max2, max3])   到底是什麼樣的範圍?如下:

max1 > min1 || max1 === min1 && max2 > min2 || max1 === min1 && max2 === min2 && max3 > min3  // 好好理解一下這個 bound 的含義吧 ! 

事務

理解事務是用好indexDB的關鍵!事務是在一個特定的資料庫上,一組具備原子性和永續性的資料訪問和資料修改的操作。

考慮大檔案斷點續傳-任務佇列的場景:
實現斷點續傳,你需要快取上傳的檔案 和 這個檔案的上傳任務資訊(任務名稱,上傳進度等),這樣就可以下次開啟browser時續傳了,還可以在任務失敗後重啟任務了;
上傳大檔案需要很長時間,設計一個可以隨時檢視的任務佇列,這樣使用者就不用一直等待了 —— 為了能讓使用者能隨時檢視,所有的任務資訊需要常駐記憶體。
考慮到大檔案的記憶體佔用過大,你應該只將當前正在上傳的檔案放到記憶體中,而非所有任務的所有檔案 —— 大部分的檔案,應當待在indexDB快取中,而非記憶體中。
綜上所述:indexDB資料庫將會有兩個ObjectStore:tasks用於存放任務除了檔案之外的資訊,files用於存放任務要上傳的檔案。

現在我們考慮刪除任務的場景,刪掉一個任務,需要同時刪除tasks中的資訊和files中的資訊;
如果只成功刪除了tasks,files中將額外多出永遠訪問不到的大檔案;
如果只刪除了files,tasks中將存在一個無法重啟的異常任務!這都是不可取的
這就是一個典型的事務場景,具有原子性,不可拆分性,必須都成功!

錯誤程式碼:

// 注意這裡是錯誤示範,實際上開啟了兩個事務:刪除tasks 和 files 不能保證都同時成功
const tasksStore = db.transaction('tasks', 'readwrite').objectStore('tasks')
const filesStore = db.transaction('files', 'readwrite').objectStore('files')
tasksStore.delete(processId).onsuccess = () => {
    console.log('刪除了任務')
}
filesStore.delete(processId).onsuccess = () => {
    console.log('刪除了檔案')
}

其實我們只需要做一個很簡單的改變,就是宣告一個事務來傳送兩個請求:

const trans = db.transaction(['tasks', 'files'], 'readwrite')
const tasksStore = trans.objectStore('tasks')
const filesStore = trans.objectStore('files')
// 下方兩個操作請求共用了一個事務trans,必須同時成功,否則就失敗(即使成功了的請求,資料也將會回滾)
tasksStore.delete(processId).onsuccess = () => {
    console.log('刪除了任務')
}
filesStore.delete(processId).onsuccess = () => {
    console.log('刪除了檔案')
}

或者這樣寫(雖然效率低了寫,但看起來更具原子性):

const trans = db.transaction(['tasks', 'files'], 'readwrite')
const tasksStore = trans.objectStore('tasks')
const filesStore = trans.objectStore('files')
// 還可以這樣:
tasksStore.delete(processId).onsuccess = () => {
    filesStore.delete(processId).onsuccess = () => {
        console.log('刪除成功')
    }
}

深入事務的生命週期

也許你覺得上面的寫法都不夠優雅,或者僅僅是想抽出更通用的邏輯,而想做一些封裝和抽取時,你會發現事情並不是那麼簡單。深刻理解indexDB事務的生命週期很關鍵,雖然這並不容易。
在這裡先假設你已經很熟悉js的 Event Loop 和 DOM Event (如果不熟悉,就先去了解一下再回來吧!),接下來一起探討indexDB的事務生命週期。

正常情況下的生命週期

也許你已經注意到了,indexDB核心就是一個一個的請求,這種請求的處理很像ajax,與其使用回撥函式來程式設計,為何不將其封裝成更優雅的promise呢,就像下面這樣?:

 1 function request(objectStore, method, params) {
 2     return new Promise(resolve => {
 3         objectStore[method](params).onsuccess = e => {
 4             resolve(e.target.result)
 5         }
 6     })
 7 }
 8 const trans = db.transaction(['tasks', 'files'], 'readwrite')
 9 const tasksStore = trans.objectStore('tasks')
10 const filesStore = trans.objectStore('files')
11 await request(tasksStore, 'delete', processId)
12 // 此時事務已經結束,所以下面的請求會報錯:
13 await request(filesStore, 'delete', processId)

回顧一下 js的event loop!下面直接給出事務生命週期的要點:
【要點】:當event loop 任務佇列中沒有等待處理的該事務發起的回撥函式,並且正在處理的任務也不是該事務發起的回撥函式,這個事務就會停止。
參考官方:Transactions are tied very closely to the event loop. If you make a transaction and return to the event loop without using it then the transaction will become inactive. The only way to keep the transaction active is to make a request on it. When the request is finished you'll get a DOM event and, assuming that the request succeeded, you'll have another opportunity to extend the transaction during that callback. If you return to the event loop without extending the transaction then it will become inactive, and so on. As long as there are pending requests the transaction remains active.

上面程式碼第11行結束後,event loop 任務佇列為空,事務就會結束,第13行就會報 事務已失活的錯誤。我們可以把await去掉,像這樣:

// request 是個非同步函式,而呼叫是同步的,這裡恰好用了非同步延遲的特點,讓兩個請求都能在事務失活前發出。(這裡不過是鑽了個空子!)
request(tasksStore, 'delete', processId)
request(filesStore, 'delete', processId)

結合上面標註的【要點】,好好理解一下去掉await前後程式碼的本質差異,為什麼前面的會失敗,而後面的會成功。

再看下面的例子

// 錯誤程式碼,這和上面await的例子本質是一樣的,第一個請求結束後 事務就失活
request(tasksStore, 'delete', processId).then(() => {
    request(filesStore, 'delete', processId).then(() => {
        console.log('結束')
    })
})

 再回顧一下前面的程式碼:

// 正確的程式碼,本質和上面不帶await的是一樣的,不過這裡與其說是鑽空子,不如說是使用了非同步回撥的延遲技巧,
// 因為上面不帶await的程式碼並不能直觀的看出request是非同步的(而這裡卻可以很明顯的看出),極有可能會出錯。
tasksStore.delete(processId).onsuccess = () => {
    console.log('刪除了任務')
}
filesStore.delete(processId).onsuccess = () => {
    console.log('刪除了檔案')
}

 進一步回顧程式碼,以便理解 【要點】 的本質

tasksStore.delete(processId).onsuccess = () => {
    // 第一個請求的回撥處理,在處理結束(return)前,又發起了一個請求,從而保證了事務的活性!
    filesStore.delete(processId).onsuccess = () => {
        console.log('刪除成功')
    }
}

異常情況下的生命週期

以上是所有請求都成功(success)的情況。事務還有一個特性:任何一個請求失敗了,其他請求都會回滾,整個事務就失敗!
indexDB請求中,我們最常用的回撥就是onsuccess,onerror,onupgradeneeded,這些都是對應的DOM event,所以你也可以使用 addEventListener和 removeEventListener,…… 但這裡真正的重點是,DOM Event 具有傳遞的特性。
想象event 在html DOM樹中的傳遞,event在 indexDB事務中的傳遞基本一樣,不過只有error事件會傳遞!!任何一個error event 一旦被傳遞給事務,這個事務就會失敗。

按照官方的文件,你應該可以像下面這樣阻止事務被回滾,但是經過測試沒有任何效果:
參考官方:a transaction receives error events from any requests that are generated from it.……A more subtle point here is that the default behavior of an error is to abort the transaction in which it occurred. ……Unless you handle the error by first calling stopPropagation() on the error event then doing something else, the entire transaction is rolled back. 

let req = filesStore.delete(processId)
req.onsuccess = () => {
    console.log('刪除成功')
}
req.onerror = e => {
    // 你可以處理錯誤,但請記住:只有顯式的阻止error event 向上傳遞,它才不會向上傳遞!
    // 這和 promise的catch 不一樣,你雖然處理了錯誤,但是沒有阻止其傳遞,整個事務還是會失敗!
   // 不過,請注意:這只是標準,實際上(經過測試,至少在chrome上)這樣是沒法阻止事務失敗的!
e.stopPropagation() }

indexDB的資料庫升級問題

當你開啟資料庫時,版本號引數比當前已存在的資料庫版本高時,或者當前本地不存在這個資料庫,就會觸發versionChange升級事件,對應於onupgradeneeded 回撥(前面講過)。
定義db的schema(首次建立db或升級db,比如建立和刪除objectStore,建立和刪除索引) ,這樣的事情都只能在onupgradeneeded 回撥中進行!

由於indexDB是執行在客戶端(瀏覽器)的資料庫,它的升級比服務端的資料庫升級要複雜(的多),畢竟你可以完全掌控服務端,但使用者的行為卻無法預測,你需要考慮各種情況。

不能只基於上一個版本做升級

舉個例子:假如你的資料庫經歷過兩次升級,版本號由1,到2,又到現在的3了。在做2到3的升級時,你不能只寫2到3這一個升級邏輯,你的邏輯必須能夠適配1到3的升級,以及直接到3的建立。
因為使用者可能是第一次開啟你的網站,本地壓根就不存在資料庫,這時要進行直接到3的建立;
使用者也可能在你的indexDB版本還是1的時候開啟過你的網站,但直到現在版本升到3了才再次開啟,這時要進行1到3的升級;
……
以此類推,你的資料庫升級程式碼必須足夠靈活,已便適配所有場景,可以由 無、第1版、第2版   。。。直接到當前的最新版!

索引升級與資料升級的問題

在增刪索引時需要先得到對應的objectStore,而要得到objectStore必須先有事務,但是onupgradeneeded 時 你不能建立事務,這似乎是一個矛盾!
其實onupgradeneeded 時已經自帶了一個 versionchange的事務,這是一個作用域覆蓋了所有objectStores的事務,像這樣就可以運算元據了:

openDBRequest.onupgradeneeded = (e) => {
    objectStore = openDBRequest.transaction.objectStore('myObjectStore')   
    objectStore.createIndex('index_name', ['field1', 'field2', 'field3'], { unique: true })
}

 

有些時候我們必須要在onupgradeneeded 中運算元據,已便在升級資料庫的同時,升級轉換已經存在了的資料!上面解決拿到objectStore的問題(運算元據必須拿到objectStore),但確實不應該在onupgradeneeded中運算元據,當你成功完成了onupgradeneeded 資料庫升級後,會觸發 onsuccess回撥,你應該在這裡面運算元據!

資料庫升級面臨的多視窗問題

使用者可能開啟了多個瀏覽器標籤或視窗,這時所有頁面連結的都是舊版的indexDB。如果使用者重新整理了某一個頁面,從而下載了最新的程式碼,就會在這個頁面觸發資料庫的升級,這時升級就會出現問題 —— 好在我們在其他頁面,可以監聽到資料庫在請求升級,也可以主動斷開連結,你可以這樣:

openReq.onsuccess = e => {
    console.log('db open success!')
    db = openReq.result
    db.onversionchange=e=>{
        db.close()  // 關閉連線
        console.log("頁面內容已過期,請重新整理");
    }
}    

 當資料庫已經升級,但頁面沒有重新整理而使用老程式碼在開啟低版本的資料庫時,這時會觸發VersionError錯誤,你可以監聽這個錯誤,並提示使用者重新整理頁面!

未經使用者同意就直接關閉資料庫的連結,可能會給使用者帶來不好的體驗,如果不這麼做,就要像下面這樣給出提示:

openReq.onblocked = function(event) {  
  console.log("請先關閉其他頁面,再載入本頁面!");
};

以上兩種方式,你需要二選一!

(完)

相關文章