Javascript IndexedDB

2023-02-17 10:57 更新

IndexedDB 是一個瀏覽器內(nèi)建的數(shù)據(jù)庫,它比 ?localStorage? 強(qiáng)大得多。

  • 通過支持多種類型的鍵,來存儲幾乎可以是任何類型的值。
  • 支撐事務(wù)的可靠性。
  • 支持鍵值范圍查詢、索引。
  • 和 ?localStorage? 相比,它可以存儲更大的數(shù)據(jù)量。

對于傳統(tǒng)的 客戶端-服務(wù)器 應(yīng)用,這些功能通常是沒有必要的。IndexedDB 適用于離線應(yīng)用,可與 ServiceWorkers 和其他技術(shù)相結(jié)合使用。

根據(jù)規(guī)范 https://www.w3.org/TR/IndexedDB 中的描述,IndexedDB 的本機(jī)接口是基于事件的。

我們還可以在基于 promise 的包裝器(wrapper),如 https://github.com/jakearchibald/idb 的幫助下使用 async/await。這要方便的多,但是包裝器并不完美,它并不能替代所有情況下的事件。因此,我們先練習(xí)事件(events),在理解了 IndexedDB 之后,我們將使用包裝器。

數(shù)據(jù)在哪兒?

從技術(shù)上講,數(shù)據(jù)通常與瀏覽器設(shè)置、擴(kuò)展程序等一起存儲在訪問者的主目錄中。

不同的瀏覽器和操作系統(tǒng)級別的用戶都有各自獨立的存儲。

打開數(shù)據(jù)庫

要想使用 IndexedDB,首先需要 open(連接)一個數(shù)據(jù)庫。

語法:

let openRequest = indexedDB.open(name, version);
  • ?name? —— 字符串,即數(shù)據(jù)庫名稱。
  • ?version? —— 一個正整數(shù)版本,默認(rèn)為 ?1?(下面解釋)。

數(shù)據(jù)庫可以有許多不同的名稱,但是必須存在于當(dāng)前的源(域/協(xié)議/端口)中。不同的網(wǎng)站不能相互訪問對方的數(shù)據(jù)庫。

調(diào)用之后會返回 openRequest 對象,我們需要監(jiān)聽該對象上的事件:

  • ?success?:數(shù)據(jù)庫準(zhǔn)備就緒,?openRequest.result? 中有了一個數(shù)據(jù)庫對象“Database Object”,我們應(yīng)該將其用于進(jìn)一步的調(diào)用。
  • ?error?:打開失敗。
  • ?upgradeneeded?:數(shù)據(jù)庫已準(zhǔn)備就緒,但其版本已過時(見下文)。

IndexedDB 具有內(nèi)建的“模式(scheme)版本控制”機(jī)制,這在服務(wù)器端數(shù)據(jù)庫中是不存在的。

與服務(wù)器端數(shù)據(jù)庫不同,IndexedDB 存在于客戶端,數(shù)據(jù)存儲在瀏覽器中。因此,開發(fā)人員無法隨時都能訪問它。因此,當(dāng)我們發(fā)布了新版本的應(yīng)用程序,用戶訪問我們的網(wǎng)頁,我們可能需要更新該數(shù)據(jù)庫。

如果本地數(shù)據(jù)庫版本低于 open 中指定的版本,會觸發(fā)一個特殊事件 upgradeneeded。我們可以根據(jù)需要比較版本并升級數(shù)據(jù)結(jié)構(gòu)。

當(dāng)數(shù)據(jù)庫還不存在時(從技術(shù)上講,其版本為 0),也會觸發(fā) upgradeneeded 事件。因此,我們可以執(zhí)行初始化。

假設(shè)我們發(fā)布了應(yīng)用程序的第一個版本。

接下來我們就可以打開版本 1 中的 IndexedDB 數(shù)據(jù)庫,并在一個 upgradeneeded 的處理程序中執(zhí)行初始化,如下所示:

let openRequest = indexedDB.open("store", 1);

openRequest.onupgradeneeded = function() {
  // 如果客戶端沒有數(shù)據(jù)庫則觸發(fā)
  // ...執(zhí)行初始化...
};

openRequest.onerror = function() {
  console.error("Error", openRequest.error);
};

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // 繼續(xù)使用 db 對象處理數(shù)據(jù)庫
};

之后不久,我們發(fā)布了第二個版本。

我們可以打開版本 2 中的 IndexedDB 數(shù)據(jù)庫,并像這樣進(jìn)行升級:

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = function(event) {
  // 現(xiàn)有的數(shù)據(jù)庫版本小于 2(或不存在)
  let db = openRequest.result;
  switch(event.oldVersion) { // 現(xiàn)有的 db 版本
    case 0:
      // 版本 0 表示客戶端沒有數(shù)據(jù)庫
      // 執(zhí)行初始化
    case 1:
      // 客戶端版本為 1
      // 更新
  }
};

請注意:雖然我們目前的版本是 2,onupgradeneeded 處理程序有針對版本 0 的代碼分支(適用于初次訪問,瀏覽器中沒有數(shù)據(jù)庫的用戶)和針對版本 1 的代碼分支(用于升級)。

接下來,當(dāng)且僅當(dāng) onupgradeneeded 處理程序沒有錯誤地執(zhí)行完成,openRequest.onsuccess 被觸發(fā),數(shù)據(jù)庫才算是成功打開了。

刪除數(shù)據(jù)庫:

let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess/onerror 追蹤(tracks)結(jié)果

我們無法使用較舊的 open 調(diào)用版本打開數(shù)據(jù)庫

如果當(dāng)前用戶的數(shù)據(jù)庫版本比 open 調(diào)用的版本更高(比如當(dāng)前的數(shù)據(jù)庫版本為 3,我們卻嘗試運行 open(...2),就會產(chǎn)生錯誤并觸發(fā) openRequest.onerror)。

這很罕見,但這樣的事情可能會在用戶加載了一個過時的 JavaScript 代碼時發(fā)生(例如用戶從一個代理緩存中加載 JS)。在這種情況下,代碼是過時的,但數(shù)據(jù)庫卻是最新的。

為了避免這樣的錯誤產(chǎn)生,我們應(yīng)當(dāng)檢查 db.version 并建議用戶重新加載頁面。使用正確的 HTTP 緩存頭(header)來避免之前緩存的舊代碼被加載,這樣你就永遠(yuǎn)不會遇到此類問題。

并行更新問題

提到版本控制,有一個相關(guān)的小問題。

舉個例子:

  1. 一個用戶在一個瀏覽器標(biāo)簽頁中打開了數(shù)據(jù)庫版本為 ?1? 的我們的網(wǎng)站。
  2. 接下來我們發(fā)布了一個更新,使得代碼更新了。
  3. 接下來同一個用戶在另一個瀏覽器標(biāo)簽中打開了這個網(wǎng)站。

這時,有一個標(biāo)簽頁和版本為 1 的數(shù)據(jù)庫建立了一個連接,而另一個標(biāo)簽頁試圖在其 upgradeneeded 處理程序中將數(shù)據(jù)庫版本升級到 2

問題是,這兩個網(wǎng)頁是同一個站點,同一個源,共享同一個數(shù)據(jù)庫。而數(shù)據(jù)庫不能同時為版本 1 和版本 2。要執(zhí)行版本 2 的更新,必須關(guān)閉對版本 1 的所有連接,包括第一個標(biāo)簽頁中的那個。

為了解決這一問題,versionchange 事件會在“過時的”數(shù)據(jù)庫對象上觸發(fā)。我們需要監(jiān)聽這個事件,關(guān)閉對舊版本數(shù)據(jù)庫的連接(還應(yīng)該建議訪問者重新加載頁面,以加載最新的代碼)。

如果我們不監(jiān)聽 versionchange 事件,也不去關(guān)閉舊連接,那么新的連接就不會建立。openRequest 對象會產(chǎn)生 blocked 事件,而不是 success 事件。因此第二個標(biāo)簽頁無法正常工作。

下面是能夠正確處理并行升級情況的代碼。它安裝了 onversionchange 處理程序,如果當(dāng)前數(shù)據(jù)庫連接過時(數(shù)據(jù)庫版本在其他位置被更新)并關(guān)閉連接,則會觸發(fā)該處理程序。

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;

openRequest.onsuccess = function() {
  let db = openRequest.result;

  db.onversionchange = function() {
    db.close();
    alert("Database is outdated, please reload the page.")
  };

  // ……數(shù)據(jù)庫已經(jīng)準(zhǔn)備好,請使用它……
};

openRequest.onblocked = function() {
  // 如果我們正確處理了 onversionchange 事件,這個事件就不應(yīng)該觸發(fā)

  // 這意味著還有另一個指向同一數(shù)據(jù)庫的連接
  // 并且在 db.onversionchange 被觸發(fā)后,該連接沒有被關(guān)閉
};

……換句話說,在這我們做兩件事:

  1. 如果當(dāng)前數(shù)據(jù)庫版本過時,?db.onversionchange? 監(jiān)聽器會通知我們并行嘗試更新。
  2. ?openRequest.onblocked? 監(jiān)聽器通知我們相反的情況:在其他地方有一個與過時的版本的連接未關(guān)閉,因此無法建立新的連接。

我們可以在 db.onversionchange 中更優(yōu)雅地進(jìn)行處理,提示訪問者在連接關(guān)閉之前保存數(shù)據(jù)等。

或者,另一種方式是不在 db.onversionchange 中關(guān)閉數(shù)據(jù)庫,而是使用 onblocked 處理程序(在瀏覽器新 tab 頁中)來提醒用戶,告訴他新版本無法加載,直到他們關(guān)閉瀏覽器其他 tab 頁。

這種更新沖突很少發(fā)生,但我們至少應(yīng)該有一些對其進(jìn)行處理的程序,至少在 onblocked 處理程序中進(jìn)行處理,以防程序默默卡死而影響用戶體驗。

對象庫(object store)

要在 IndexedDB 中存儲某些內(nèi)容,我們需要一個 對象庫。

對象庫是 IndexedDB 的核心概念,在其他數(shù)據(jù)庫中對應(yīng)的對象稱為“表”或“集合”。它是儲存數(shù)據(jù)的地方。一個數(shù)據(jù)庫可能有多個存儲區(qū):一個用于存儲用戶數(shù)據(jù),另一個用于商品,等等。

盡管被命名為“對象庫”,但也可以存儲原始類型。

幾乎可以存儲任何值,包括復(fù)雜的對象。

IndexedDB 使用 標(biāo)準(zhǔn)序列化算法 來克隆和存儲對象。類似于 JSON.stringify,不過功能更加強(qiáng)大,能夠存儲更多的數(shù)據(jù)類型。

有一種對象不能被存儲:循環(huán)引用的對象。此類對象不可序列化,也不能進(jìn)行 JSON.stringify。

庫中的每個值都必須有唯一的鍵 key。

鍵的類型必須為數(shù)字、日期、字符串、二進(jìn)制或數(shù)組。它是唯一的標(biāo)識符,所以我們可以通過鍵來搜索/刪除/更新值。


正如我們很快就會看到的,類似于 localStorage,我們向存儲區(qū)添加值時,可以提供一個鍵。但當(dāng)我們存儲對象時,IndexedDB 允許將一個對象屬性設(shè)置為鍵,這就更加方便了?;蛘?,我們可以自動生成鍵。

但我們需要先創(chuàng)建一個對象庫。

創(chuàng)建對象庫的語法:

db.createObjectStore(name[, keyOptions]);

請注意,操作是同步的,不需要 await

  • ?name? 是存儲區(qū)名稱,例如 ?"books"? 表示書。
  • ?keyOptions? 是具有以下兩個屬性之一的可選對象:
    • ?keyPath? —— 對象屬性的路徑,IndexedDB 將以此路徑作為鍵,例如 ?id?。
    • ?autoIncrement? —— 如果為 ?true?,則自動生成新存儲的對象的鍵,鍵是一個不斷遞增的數(shù)字。

如果我們不提供 keyOptions,那么以后需要在存儲對象時,顯式地提供一個鍵。

例如,此對象庫使用 id 屬性作為鍵:

db.createObjectStore('books', {keyPath: 'id'});

在 upgradeneeded 處理程序中,只有在創(chuàng)建數(shù)據(jù)庫版本時,對象庫被才能被 創(chuàng)建/修改。

這是技術(shù)上的限制。在 upgradeneedHandler 之外,可以添加/刪除/更新數(shù)據(jù),但是只能在版本更新期間創(chuàng)建/刪除/更改對象庫。

要進(jìn)行數(shù)據(jù)庫版本升級,主要有兩種方法:

  1. 我們實現(xiàn)每個版本的升級功能:從 1 到 2,從 2 到 3,從 3 到 4,等等。在 ?upgradeneeded? 中,可以進(jìn)行版本比較(例如,老版本是 2,需要升級到 4),并針對每個中間版本(2 到 3,然后 3 到 4)逐步運行每個版本的升級。
  2. 或者我們可以檢查數(shù)據(jù)庫:以 ?db.objectStoreNames? 的形式獲取現(xiàn)有對象庫的列表。該對象是一個 DOMStringList 提供 ?contains(name)? 方法來檢查 name 是否存在,再根據(jù)存在和不存在的內(nèi)容進(jìn)行更新。

對于小型數(shù)據(jù)庫,第二種方法可能更簡單。

下面是第二種方法的演示:

let openRequest = indexedDB.open("db", 2);

// 創(chuàng)建/升級 數(shù)據(jù)庫而無需版本檢查
openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('books')) { // 如果沒有 “books” 數(shù)據(jù)
    db.createObjectStore('books', {keyPath: 'id'}); // 創(chuàng)造它
  }
};

刪除對象庫:

db.deleteObjectStore('books')

事務(wù)(transaction)

術(shù)語“事務(wù)(transaction)”是通用的,許多數(shù)據(jù)庫中都有用到。

事務(wù)是一組操作,要么全部成功,要么全部失敗。

例如,當(dāng)一個人買東西時,我們需要:

  1. 從他們的賬戶中扣除這筆錢。
  2. 將該項目添加到他們的清單中。

如果完成了第一個操作,但是出了問題,比如停電。這時無法完成第二個操作,這非常糟糕。兩件時應(yīng)該要么都成功(購買完成,好?。┗蛲瑫r失?。ㄟ@個人保留了錢,可以重新嘗試)。

事務(wù)可以保證同時完成。

所有數(shù)據(jù)操作都必須在 IndexedDB 中的事務(wù)內(nèi)進(jìn)行。

啟動事務(wù):

db.transaction(store[, type]);
  • ?store? 是事務(wù)要訪問的庫名稱,例如 ?"books"?。如果我們要訪問多個庫,則是庫名稱的數(shù)組。
  • ?type? – 事務(wù)類型,以下類型之一:
    • ?readonly? —— 只讀,默認(rèn)值。
    • ?readwrite? —— 只能讀取和寫入數(shù)據(jù),而不能 創(chuàng)建/刪除/更改 對象庫。

還有 versionchange 事務(wù)類型:這種事務(wù)可以做任何事情,但不能被手動創(chuàng)建。IndexedDB 在打開數(shù)據(jù)庫時,會自動為 upgradeneeded 處理程序創(chuàng)建 versionchange 事務(wù)。這就是它為什么可以更新數(shù)據(jù)庫結(jié)構(gòu)、創(chuàng)建/刪除 對象庫的原因。

為什么會有不同類型的事務(wù)?

性能是事務(wù)需要標(biāo)記為 readonly 和 readwrite 的原因。

許多 readonly 事務(wù)能夠同時訪問同一存儲區(qū),但 readwrite 事務(wù)不能。因為 readwrite 事務(wù)會“鎖定”存儲區(qū)進(jìn)行寫操作。下一個事務(wù)必須等待前一個事務(wù)完成,才能訪問相同的存儲區(qū)。

創(chuàng)建事務(wù)后,我們可以將項目添加到庫,就像這樣:

let transaction = db.transaction("books", "readwrite"); // (1)

// 獲取對象庫進(jìn)行操作
let books = transaction.objectStore("books"); // (2)

let book = {
  id: 'js',
  price: 10,
  created: new Date()
};

let request = books.add(book); // (3)

request.onsuccess = function() { // (4)
  console.log("Book added to the store", request.result);
};

request.onerror = function() {
  console.log("Error", request.error);
};

基本有四個步驟:

  1. 創(chuàng)建一個事務(wù),在(1)表明要訪問的所有存儲。
  2. 使用 ?transaction.objectStore(name)?,在(2)中獲取存儲對象。
  3. 在(3)執(zhí)行對對象庫 ?books.add(book)? 的請求。
  4. ……處理請求 成功/錯誤(4),還可以根據(jù)需要發(fā)出其他請求。

對象庫支持兩種存儲值的方法:

  • put(value, [key]) 將 ?value? 添加到存儲區(qū)。僅當(dāng)對象庫沒有 ?keyPath? 或 ?autoIncrement? 時,才提供 ?key?。如果已經(jīng)存在具有相同鍵的值,則將替換該值。
  • add(value, [key]) 與 ?put? 相同,但是如果已經(jīng)有一個值具有相同的鍵,則請求失敗,并生成一個名為 ?"ConstraInterror"? 的錯誤。

與打開數(shù)據(jù)庫類似,我們可以發(fā)送一個請求:books.add(book),然后等待 success/error 事件。

  • ?add? 的 ?request.result? 是新對象的鍵。
  • 錯誤在 ?request.error?(如果有的話)中。

事務(wù)的自動提交

在上面的示例中,我們啟動了事務(wù)并發(fā)出了 add 請求。但正如前面提到的,一個事務(wù)可能有多個相關(guān)的請求,這些請求必須全部成功或全部失敗。那么我們?nèi)绾螌⑹聞?wù)標(biāo)記為已完成,并不再請求呢?

簡短的回答是:沒有。

在下一個版本 3.0 規(guī)范中,可能會有一種手動方式來完成事務(wù),但目前在 2.0 中還沒有。

當(dāng)所有事務(wù)的請求完成,并且 微任務(wù)隊列 為空時,它將自動提交。

通常,我們可以假設(shè)事務(wù)在其所有請求完成時提交,并且當(dāng)前代碼完成。

因此,在上面的示例中,不需要任何特殊調(diào)用即可完成事務(wù)。

事務(wù)自動提交原則有一個重要的副作用。不能在事務(wù)中間插入 fetchsetTimeout 等異步操作。IndexedDB 不會讓事務(wù)等待這些操作完成。

在下面的代碼中,request2 中的行 (*) 失敗,因為事務(wù)已經(jīng)提交,不能在其中發(fā)出任何請求:

let request1 = books.add(book);

request1.onsuccess = function() {
  fetch('/').then(response => {
    let request2 = books.add(anotherBook); // (*)
    request2.onerror = function() {
      console.log(request2.error.name); // TransactionInactiveError
    };
  });
};

這是因為 fetch 是一個異步操作,一個宏任務(wù)。事務(wù)在瀏覽器開始執(zhí)行宏任務(wù)之前關(guān)閉。

IndexedDB 規(guī)范的作者認(rèn)為事務(wù)應(yīng)該是短期的。主要是性能原因。

值得注意的是,readwrite 事務(wù)將存儲“鎖定”以進(jìn)行寫入。因此,如果應(yīng)用程序的一部分啟動了 books 對象庫上的 readwrite 操作,那么希望執(zhí)行相同操作的另一部分必須等待新事務(wù)“掛起”,直到第一個事務(wù)完成。如果事務(wù)處理需要很長時間,將會導(dǎo)致奇怪的延遲。

那么,該怎么辦?

在上面的示例中,我們可以在新請求 (*) 之前創(chuàng)建一個新的 db.transaction

如果需要在一個事務(wù)中把所有操作保持一致,更好的做法是將 IndexedDB 事務(wù)和“其他”異步內(nèi)容分開。

首先,執(zhí)行 fetch,并根據(jù)需要準(zhǔn)備數(shù)據(jù)。然后創(chuàng)建事務(wù)并執(zhí)行所有數(shù)據(jù)庫請求,然后就正常了。

為了檢測到成功完成的時刻,我們可以監(jiān)聽 transaction.oncomplete 事件:

let transaction = db.transaction("books", "readwrite");

// ……執(zhí)行操作……

transaction.oncomplete = function() {
  console.log("Transaction is complete"); // 事務(wù)執(zhí)行完成
};

只有 complete 才能保證將事務(wù)作為一個整體保存。個別請求可能會成功,但最終的寫入操作可能會出錯(例如 I/O 錯誤或其他錯誤)。

要手動中止事務(wù),請調(diào)用:

transaction.abort();

取消請求里所做的所有修改,并觸發(fā) transaction.onabort 事件。

錯誤處理

寫入請求可能會失敗。

這是意料之中的事,不僅是我們可能會犯的粗心失誤,還有與事務(wù)本身相關(guān)的其他原因。例如超過了存儲配額。因此,必須做好請求失敗的處理。

失敗的請求將自動中止事務(wù),并取消所有的更改。

在一些情況下,我們會想自己去處理失敗事務(wù)(例如嘗試另一個請求)并讓它繼續(xù)執(zhí)行,而不是取消現(xiàn)有的更改??梢哉{(diào)用 request.onerror 處理程序,在其中調(diào)用 event.preventDefault() 防止事務(wù)中止。

在下面的示例中,添加了一本新書,鍵 (id) 與現(xiàn)有的書相同。store.add 方法生成一個 "ConstraInterror"??梢栽诓蝗∠聞?wù)的情況下進(jìn)行處理:

let transaction = db.transaction("books", "readwrite");

let book = { id: 'js', price: 10 };

let request = transaction.objectStore("books").add(book);

request.onerror = function(event) {
  // 有相同 id 的對象存在時,發(fā)生 ConstraintError
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // 處理錯誤
    event.preventDefault(); // 不要中止事務(wù)
    // 這個 book 用另一個鍵?
  } else {
    // 意外錯誤,無法處理
    // 事務(wù)將中止
  }
};

transaction.onabort = function() {
  console.log("Error", transaction.error);
};

事件委托

每個請求都需要調(diào)用 onerror/onsuccess ?并不,可以使用事件委托來代替。

IndexedDB 事件冒泡:請求 → 事務(wù) → 數(shù)據(jù)庫。

所有事件都是 DOM 事件,有捕獲和冒泡,但通常只使用冒泡階段。

因此,出于報告或其他原因,我們可以使用 db.onerror 處理程序捕獲所有錯誤:

db.onerror = function(event) {
  let request = event.target; // 導(dǎo)致錯誤的請求

  console.log("Error", request.error);
};

……但是錯誤被完全處理了呢?這種情況不應(yīng)該被報告。

我們可以通過在 request.onerror 中使用 event.stopPropagation() 來停止冒泡,從而停止 db.onerror 事件。

request.onerror = function(event) {
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // 處理錯誤
    event.preventDefault(); // 不要中止事務(wù)
    event.stopPropagation(); // 不要讓錯誤冒泡, 停止它的傳播
  } else {
    // 什么都不做
    // 事務(wù)將中止
    // 我們可以解決 transaction.onabort 中的錯誤
  }
};

搜索

對象庫有兩種主要的搜索類型:

  1. 通過鍵值或鍵值范圍。在我們的 “books” 存儲中,將是 ?book.id? 的值或值的范圍。
  2. 通過另一個對象字段,例如 ?book.price?。這需要一個額外的數(shù)據(jù)結(jié)構(gòu),名為“索引(index)”。

通過 key 搜索

首先,讓我們來處理第一種類型的搜索:按鍵。

支持精確的鍵值和被稱為“值范圍”的搜索方法 —— IDBKeyRange 對象,指定一個可接受的“鍵值范圍”。

IDBKeyRange 對象是通過下列調(diào)用創(chuàng)建的:

  • ?IDBKeyRange.lowerBound(lower, [open])? 表示:?≥lower?(如果 ?open? 是 true,表示 ?>lower?)
  • ?IDBKeyRange.upperBound(upper, [open])? 表示:≤upper(如果 ?open? 是 true,表示 ?<upper?)
  • ?IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen])? 表示: 在 ?lower? 和 ?upper? 之間。如果 open 為 true,則相應(yīng)的鍵不包括在范圍中。
  • ?IDBKeyRange.only(key)? —— 僅包含一個鍵的范圍 ?key?,很少使用。

我們很快就會看到使用它們的實際示例。

要進(jìn)行實際的搜索,有以下方法。它們接受一個可以是精確鍵值或鍵值范圍的 query 參數(shù):

  • ?store.get(query)? —— 按鍵或范圍搜索第一個值。
  • ?store.getAll([query], [count])? —— 搜索所有值。如果 ?count? 給定,則按 ?count? 進(jìn)行限制。
  • ?store.getKey(query)? —— 搜索滿足查詢的第一個鍵,通常是一個范圍。
  • ?store.getAllKeys([query], [count])? —— 搜索滿足查詢的所有鍵,通常是一個范圍。如果 ?count? 給定,則最多為 count。
  • ?store.count([query])? —— 獲取滿足查詢的鍵的總數(shù),通常是一個范圍。

例如,我們存儲區(qū)里有很多書。因為 id 字段是鍵,因此所有方法都可以按 id 進(jìn)行搜索。

請求示例:

// 獲取一本書
books.get('js')

// 獲取 'css' <= id <= 'html' 的書
books.getAll(IDBKeyRange.bound('css', 'html'))

// 獲取 id < 'html' 的書
books.getAll(IDBKeyRange.upperBound('html', true))

// 獲取所有書
books.getAll()

// 獲取所有 id > 'js' 的鍵
books.getAllKeys(IDBKeyRange.lowerBound('js', true))

對象中對值的存儲始終是有序的

對象內(nèi)部存儲的值是按鍵對值進(jìn)行排序的。

因此,請求的返回值,是按照鍵的順序排列的。

通過使用索引的字段搜索

要根據(jù)其他對象字段進(jìn)行搜索,我們需要創(chuàng)建一個名為“索引(index)”的附加數(shù)據(jù)結(jié)構(gòu)。

索引是存儲的"附加項",用于跟蹤給定的對象字段。對于該字段的每個值,它存儲有該值的對象的鍵列表。下面會有更詳細(xì)的圖片。

語法:

objectStore.createIndex(name, keyPath, [options]);
  • ?name? —— 索引名稱。
  • ?keyPath? —— 索引應(yīng)該跟蹤的對象字段的路徑(我們將根據(jù)該字段進(jìn)行搜索)。
  • ?option? —— 具有以下屬性的可選對象:
    • ?unique? —— 如果為true,則存儲中只有一個對象在 ?keyPath? 上具有給定值。如果我們嘗試添加重復(fù)項,索引將生成錯誤。
    • ?multiEntry? —— 只有 ?keypath? 上的值是數(shù)組時才使用。這時,默認(rèn)情況下,索引將默認(rèn)把整個數(shù)組視為鍵。但是如果 ?multiEntry? 為 true,那么索引將為該數(shù)組中的每個值保留一個存儲對象的列表。所以數(shù)組成員成為了索引鍵。

在我們的示例中,是按照 id 鍵存儲圖書的。

假設(shè)我們想通過 price 進(jìn)行搜索。

首先,我們需要創(chuàng)建一個索引。它像對象庫一樣,必須在 upgradeneeded 中創(chuàng)建完成:

openRequest.onupgradeneeded = function() {
  // 在 versionchange 事務(wù)中,我們必須在這里創(chuàng)建索引
  let books = db.createObjectStore('books', {keyPath: 'id'});
  let index = books.createIndex('price_idx', 'price');
};
  • 該索引將跟蹤 ?price? 字段。
  • 價格不是唯一的,可能有多本書價格相同,所以我們不設(shè)置唯一 ?unique? 選項。
  • 價格不是一個數(shù)組,因此不適用多入口 ?multiEntry? 標(biāo)志。

假設(shè)我們的庫存里有4本書。下面的圖片顯示了該索引 index 的確切內(nèi)容:


如上所述,每個 price 值的索引(第二個參數(shù))保存具有該價格的鍵的列表。

索引自動保持最新,所以我們不必關(guān)心它。

現(xiàn)在,當(dāng)我們想要搜索給定的價格時,只需將相同的搜索方法應(yīng)用于索引:

let transaction = db.transaction("books"); // 只讀
let books = transaction.objectStore("books");
let priceIndex = books.index("price_idx");

let request = priceIndex.getAll(10);

request.onsuccess = function() {
  if (request.result !== undefined) {
    console.log("Books", request.result); // 價格為 10 的書的數(shù)組
  } else {
    console.log("No such books");
  }
};

我們還可以使用 IDBKeyRange 創(chuàng)建范圍,并查找 便宜/貴 的書:

// 查找價格 <=5 的書籍
let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

在我們的例子中,索引是按照被跟蹤對象字段價格 price 進(jìn)行內(nèi)部排序的。所以當(dāng)我們進(jìn)行搜索時,搜索結(jié)果也會按照價格排序。

從存儲中刪除

delete 方法查找要由查詢刪除的值,調(diào)用格式類似于 getAll

  • ?delete(query)? —— 通過查詢刪除匹配的值。

例如:

// 刪除 id='js' 的書
books.delete('js');

如果要基于價格或其他對象字段刪除書。首先需要在索引中找到鍵,然后調(diào)用 delete

// 找到價格 = 5 的鑰匙
let request = priceIndex.getKey(5);

request.onsuccess = function() {
  let id = request.result;
  let deleteRequest = books.delete(id);
};

刪除所有內(nèi)容:

books.clear(); // 清除存儲。

光標(biāo)(Cursors)

像 getAll/getAllKeys 這樣的方法,會返回一個 鍵/值 數(shù)組。

但是一個對象庫可能很大,比可用的內(nèi)存還大。這時,getAll 就無法將所有記錄作為一個數(shù)組獲取。

該怎么辦呢?

光標(biāo)提供了解決這一問題的方法。

光標(biāo)是一種特殊的對象,它在給定查詢的情況下遍歷對象庫,一次返回一個鍵/值,從而節(jié)省內(nèi)存。

由于對象庫是按鍵在內(nèi)部排序的,因此光標(biāo)按鍵順序(默認(rèn)為升序)遍歷存儲。

語法:

// 類似于 getAll,但帶有光標(biāo):
let request = store.openCursor(query, [direction]);

// 獲取鍵,而不是值(例如 getAllKeys):store.openKeyCursor
  • ?query? 是一個鍵值或鍵值范圍,與 ?getAll? 相同。
  • ?direction? 是一個可選參數(shù),使用順序是:
    • ?"next"? —— 默認(rèn)值,光標(biāo)從有最小索引的記錄向上移動。
    • ?"prev"? —— 相反的順序:從有最大的索引的記錄開始下降。
    • ?"nextunique"?,?"prevunique"? —— 同上,但是跳過鍵相同的記錄 (僅適用于索引上的光標(biāo),例如,對于價格為 5 的書,僅返回第一本)。

光標(biāo)對象的主要區(qū)別在于 request.onSuccess 多次觸發(fā):每個結(jié)果觸發(fā)一次。

這有一個如何使用光標(biāo)的例子:

let transaction = db.transaction("books");
let books = transaction.objectStore("books");

let request = books.openCursor();

// 為光標(biāo)找到的每本書調(diào)用
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key; // 書的鍵(id字段)
    let value = cursor.value; // 書本對象
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books");
  }
};

主要的光標(biāo)方法有:

  • ?advance(count)? —— 將光標(biāo)向前移動 ?count? 次,跳過值。
  • ?continue([key])? —— 將光標(biāo)移至匹配范圍中的下一個值(如果給定鍵,緊接鍵之后)。

無論是否有更多的值匹配光標(biāo) —— 調(diào)用 onsuccess。結(jié)果中,我們可以獲得指向下一條記錄的光標(biāo),或者 undefined。

在上面的示例中,光標(biāo)是為對象庫創(chuàng)建的。

也可以在索引上創(chuàng)建一個光標(biāo)。索引是允許按對象字段進(jìn)行搜索的。在索引上的光標(biāo)與在對象存儲上的光標(biāo)完全相同 —— 它們通過一次返回一個值來節(jié)省內(nèi)存。

對于索引上的游標(biāo),cursor.key 是索引鍵(例如:價格),我們應(yīng)該使用 cursor.primaryKey 屬性作為對象的鍵:

let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));

// 為每條記錄調(diào)用
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let primaryKey = cursor.primaryKey; // 下一個對象存儲鍵(id 字段)
    let value = cursor.value; // 下一個對象存儲對象(book 對象)
    let key = cursor.key; // 下一個索引鍵(price)
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books"); // 沒有書了
  }
};

Promise 包裝器

將 onsuccess/onerror 添加到每個請求是一項相當(dāng)麻煩的任務(wù)。我們可以通過使用事件委托(例如,在整個事務(wù)上設(shè)置處理程序)來簡化我們的工作,但是 async/await 要方便的多。

在本章,我們會進(jìn)一步使用一個輕便的承諾包裝器 https://github.com/jakearchibald/idb 。它使用 promisified IndexedDB 方法創(chuàng)建全局 idb 對象。

然后,我們可以不使用 onsuccess/onerror,而是這樣寫:

let db = await idb.openDB('store', 1, db => {
  if (db.oldVersion == 0) {
    // 執(zhí)行初始化
    db.createObjectStore('books', {keyPath: 'id'});
  }
});

let transaction = db.transaction('books', 'readwrite');
let books = transaction.objectStore('books');

try {
  await books.add(...);
  await books.add(...);

  await transaction.complete;

  console.log('jsbook saved');
} catch(err) {
  console.log('error', err.message);
}

現(xiàn)在我們有了可愛的“簡單異步代碼”和「try…catch」捕獲的東西。

錯誤處理

如果我們沒有捕獲到錯誤,那么程序?qū)⒁恢笔?,直到外部最近?nbsp;try..catch 捕獲到為止。

未捕獲的錯誤將成為 window 對象上的“unhandled promise rejection”事件。

我們可以這樣處理這種錯誤:

window.addEventListener('unhandledrejection', event => {
  let request = event.target; // IndexedDB 本機(jī)請求對象
  let error = event.reason; //  未處理的錯誤對象,與 request.error 相同
  // ……報告錯誤……
});

“非活躍事務(wù)”陷阱

我們都知道,瀏覽器一旦執(zhí)行完成當(dāng)前的代碼和 微任務(wù) 之后,事務(wù)就會自動提交。因此,如果我們在事務(wù)中間放置一個類似 fetch 的宏任務(wù),事務(wù)只是會自動提交,而不會等待它執(zhí)行完成。因此,下一個請求會失敗。

對于 promise 包裝器和 async/await,情況是相同的。

這是在事務(wù)中間進(jìn)行 fetch 的示例:

let transaction = db.transaction("inventory", "readwrite");
let inventory = transaction.objectStore("inventory");

await inventory.add({ id: 'js', price: 10, created: new Date() });

await fetch(...); // (*)

await inventory.add({ id: 'js', price: 10, created: new Date() }); // 錯誤

fetch (*) 后的下一個 inventory.add 失敗,出現(xiàn)“非活動事務(wù)”錯誤,因為這時事務(wù)已經(jīng)被提交并且關(guān)閉了。

解決方法與使用本機(jī) IndexedDB 時相同:進(jìn)行新事務(wù),或者將事情分開。

  1. 準(zhǔn)備數(shù)據(jù),先獲取所有需要的信息。
  2. 然后保存在數(shù)據(jù)庫中。

獲取本機(jī)對象

在內(nèi)部,包裝器執(zhí)行本機(jī) IndexedDB 請求,并添加 onerror/onsuccess 方法,并返回 rejects/resolves 結(jié)果的 promise。

在大多數(shù)情況下都可以運行, 示例在這 https://github.com/jakearchibald/idb

極少數(shù)情況下,我們需要原始的 request 對象。可以將 promise 的 promise.request 屬性,當(dāng)作原始對象進(jìn)行訪問:

let promise = books.add(book); // 獲取 promise 對象(不要 await 結(jié)果)

let request = promise.request; // 本地請求對象
let transaction = request.transaction; // 本地事務(wù)對象

// ……做些本地的 IndexedDB 的處理……

let result = await promise; // 如果仍然需要

總結(jié)

IndexedDB 可以被認(rèn)為是“l(fā)ocalStorage on steroids”。這是一個簡單的鍵值對數(shù)據(jù)庫,功能強(qiáng)大到足以支持離線應(yīng)用,而且用起來比較簡單。

最好的指南是官方文檔。目前的版本 是2.0,但是 3.0 版本中的一些方法(差別不大)也得到部分支持。

基本用法可以用幾個短語來描述:

  1. 獲取一個 promise 包裝器,比如 idb。
  2. 打開一個數(shù)據(jù)庫:?idb.openDb(name, version, onupgradeneeded)?
    • 在 ?onupgradeneeded? 處理程序中創(chuàng)建對象存儲和索引,或者根據(jù)需要執(zhí)行版本更新。
  3. 對于請求:
    • 創(chuàng)建事務(wù) ?db.transaction('books')?(如果需要的話,設(shè)置 readwrite)。
    • 獲取對象存儲 ?transaction.objectStore('books')?。
  4. 按鍵搜索,可以直接調(diào)用對象庫上的方法。
    • 要按對象字段搜索,需要創(chuàng)建索引。
  5. 如果內(nèi)存中容納不下數(shù)據(jù),請使用光標(biāo)。

這里有一個小應(yīng)用程序示例:

完整示例


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號