indexDB出坑指南(一)


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

本文針對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])   到底是什么樣的范圍?如下:

(field1 > min1 || field1=== min1 && field2 > min2 || field1 === min1 && field2 === min2 && field3 >= min3)&&
(field1 < max1 || field1=== max1 && field2 < max2 || field1 === max1 && field2 === max2 && field3 <= max3) 
// 好好理解一下這個 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("請先關閉其他頁面,再加載本頁面!");
};

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

繼續閱讀:indexDB出坑指南(二)

(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM