2024 年 2 月 13 日

IndexedDB

IndexedDB 是內建於瀏覽器的資料庫,比 localStorage 強大許多。

  • 使用金鑰儲存幾乎任何類型的值,多種金鑰類型。
  • 支援交易以確保可靠性。
  • 支援金鑰範圍查詢、索引。
  • 可以儲存比 localStorage 大得多的資料量。

對於傳統的用戶端伺服器應用程式來說,這種功能通常過於強大。IndexedDB 是針對離線應用程式設計的,可以與 ServiceWorkers 和其他技術結合使用。

規格 https://www.w3.org/TR/IndexedDB 中描述的 IndexedDB 原生介面是基於事件的。

我們也可以在 promise-based wrapper 的協助下使用 async/await,例如 https://github.com/jakearchibald/idb。這相當方便,但 wrapper 並不完美,無法在所有情況下取代事件。因此,我們將從事件開始,然後在了解 IndexedDB 之後,我們將使用 wrapper。

資料在哪裡?

技術上來說,資料通常儲存在訪客的家目錄中,與瀏覽器設定、擴充功能等放在一起。

不同的瀏覽器和作業系統層級的使用者各自有獨立的儲存空間。

開啟資料庫

要開始使用 IndexedDB,我們首先需要開啟(連線到)一個資料庫。

語法

let openRequest = indexedDB.open(name, version);
  • 名稱 – 字串,資料庫名稱。
  • 版本 – 正整數版本,預設為1(說明如下)。

我們可以擁有許多不同名稱的資料庫,但它們都存在於目前的來源(網域/協定/埠)中。不同的網站無法存取彼此的資料庫。

呼叫會傳回openRequest物件,我們應該監聽其事件

  • 成功:資料庫已準備好,在openRequest.result中有「資料庫物件」,我們應該將其用於後續呼叫。
  • 錯誤:開啟失敗。
  • 需要升級:資料庫已準備好,但其版本已過時(請參閱下文)。

IndexedDB 有內建的「架構版本控制」機制,這在伺服器端資料庫中不存在。

與伺服器端資料庫不同,IndexedDB 是客戶端資料庫,資料儲存在瀏覽器中,因此我們開發人員無法全天候存取它。因此,當我們發布新版本的應用程式,而使用者造訪我們的網頁時,我們可能需要更新資料庫。

如果本機資料庫版本低於開啟中指定的版本,則會觸發特殊事件需要升級,我們可以比較版本並視需要升級資料結構。

當資料庫尚未存在(技術上來說,其版本為0)時,也會觸發需要升級事件,因此我們可以執行初始化。

假設我們發布了我們應用程式的第一個版本。

然後,我們可以使用版本1開啟資料庫,並在需要升級處理常式中執行初始化,如下所示

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

openRequest.onupgradeneeded = function() {
  // triggers if the client had no database
  // ...perform initialization...
};

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

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // continue working with database using db object
};

然後,稍後,我們發布第 2 版。

我們可以使用版本2開啟它,並執行升級,如下所示

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

openRequest.onupgradeneeded = function(event) {
  // the existing database version is less than 2 (or it doesn't exist)
  let db = openRequest.result;
  switch(event.oldVersion) { // existing db version
    case 0:
      // version 0 means that the client had no database
      // perform initialization
    case 1:
      // client had version 1
      // update
  }
};

請注意:由於我們目前的版本是2,因此onupgradeneeded處理常式有一個適用於版本0的程式碼分支,適用於首次存取且沒有資料庫的使用者,也適用於版本1,用於升級。

然後,只有當onupgradeneeded處理常式在沒有錯誤的情況下完成時,openRequest.onsuccess才會觸發,並且資料庫才算成功開啟。

要刪除資料庫

let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess/onerror tracks the result
我們無法使用較舊的開啟呼叫版本開啟資料庫

如果目前的使用者資料庫版本高於在 open 呼叫中的版本,例如現有的資料庫版本為 3,而我們嘗試 open(...2),那麼這會是一個錯誤,openRequest.onerror 會觸發。

這很罕見,但當訪客載入過時的 JavaScript 程式碼時,例如從代理快取中載入,可能會發生這種情況。因此,程式碼很舊,但他的資料庫很新。

為了避免錯誤,我們應該檢查 db.version 並建議重新載入網頁。使用適當的 HTTP 快取標頭來避免載入舊程式碼,這樣您將永遠不會遇到此類問題。

平行更新問題

由於我們正在討論版本控制,讓我們來解決一個相關的小問題。

假設

  1. 訪客在瀏覽器標籤中開啟我們的網站,資料庫版本為 1
  2. 然後我們推出更新,因此我們的程式碼較新。
  3. 然後同一位訪客在另一個標籤中開啟我們的網站。

因此,有一個標籤開啟與資料庫版本 1 的連線,而第二個標籤嘗試在它的 upgradeneeded 處理常式中將其更新為版本 2

問題在於資料庫在兩個標籤之間共用,因為它是同一個網站,同一個來源。它不能同時是版本 12。若要執行更新至版本 2,必須關閉所有與版本 1 的連線,包括第一個標籤中的連線。

為了組織它,versionchange 事件會在「過時的」資料庫物件上觸發。我們應該監聽它並關閉舊的資料庫連線(並可能建議重新載入網頁,以載入更新的程式碼)。

如果我們沒有監聽 versionchange 事件,也沒有關閉舊連線,那麼第二個新連線將無法建立。openRequest 物件將發出 blocked 事件,而不是 success。因此,第二個標籤將無法運作。

以下是正確處理平行升級的程式碼。它安裝 onversionchange 處理常式,如果目前的資料庫連線過時(資料庫版本在其他地方更新),它會觸發並關閉連線。

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.")
  };

  // ...the db is ready, use it...
};

openRequest.onblocked = function() {
  // this event shouldn't trigger if we handle onversionchange correctly

  // it means that there's another open connection to the same database
  // and it wasn't closed after db.onversionchange triggered for it
};

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

  1. 如果目前的資料庫版本過時,db.onversionchange 監聽器會通知我們平行更新嘗試。
  2. openRequest.onblocked 監聽器會通知我們相反的情況:其他地方有一個與過時版本連線,而且它沒有關閉,因此無法建立較新的連線。

我們可以在 db.onversionchange 中更優雅地處理事情,提示訪客在連線關閉之前儲存資料,等等。

或者,另一種方法是在 db.onversionchange 中不關閉資料庫,而是使用 onblocked 處理常式(在新標籤中)來警告訪客,告訴他較新的版本無法載入,直到他們關閉其他標籤。

這些更新衝突很少發生,但我們至少應該為它們做一些處理,至少是一個 onblocked 處理常式,以防止我們的指令碼靜默失效。

物件儲存

要將某個東西儲存在 IndexedDB 中,我們需要一個物件儲存區

物件儲存區是 IndexedDB 的核心概念。其他資料庫中的對應項目稱為「表格」或「集合」。資料就是儲存在這裡。資料庫可以有多個儲存區:一個用於使用者,另一個用於商品,等等。

儘管名稱為「物件儲存區」,但也可以儲存基本型別。

我們可以儲存幾乎任何值,包括複雜物件。

IndexedDB 使用標準序列化演算法來複製並儲存物件。它就像 JSON.stringify,但功能更強大,能夠儲存更多資料型別。

無法儲存的物件範例:具有循環參照的物件。此類物件無法序列化。JSON.stringify 也會對此類物件失敗。

儲存區中的每個值都必須有唯一的金鑰

金鑰必須是下列型別之一:數字、日期、字串、二進位或陣列。它是唯一的識別碼,因此我們可以使用金鑰來搜尋/移除/更新值。

我們很快就會看到,當我們將值新增到儲存區時,可以提供金鑰,類似於 localStorage。但是當我們儲存物件時,IndexedDB 允許將物件屬性設定為金鑰,這方便多了。或者我們可以自動產生金鑰。

但是我們需要先建立一個物件儲存區。

建立物件儲存區的語法

db.createObjectStore(name[, keyOptions]);

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

  • name 是儲存區名稱,例如書籍的 "books"
  • keyOptions 是包含兩個屬性之一的選用物件
    • keyPath – IndexedDB 將用作金鑰的物件屬性路徑,例如 id
    • autoIncrement – 如果為 true,則會自動產生新儲存物件的金鑰,作為持續遞增的數字。

如果我們不提供 keyOptions,則稍後在儲存物件時,我們需要明確提供金鑰。

例如,此物件儲存區使用 id 屬性作為金鑰

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

只能在更新資料庫版本時,在 upgradeneeded 處理常式中建立/修改物件儲存區。

這是一個技術限制。在處理常式之外,我們可以新增/移除/更新資料,但只能在版本更新期間建立/移除/變更物件儲存區。

要執行資料庫版本升級,有兩種主要方法

  1. 我們可以實作每個版本的升級函式:從 1 到 2,從 2 到 3,從 3 到 4 等。然後,在 upgradeneeded 中,我們可以比較版本(例如舊版 2,現在是 4),並逐一執行每個版本的升級步驟,針對每個中間版本(2 到 3,然後 3 到 4)。
  2. 或者我們可以只檢查資料庫:取得現有物件儲存區的清單,例如 db.objectStoreNames。該物件是一個DOMStringList,它提供 contains(name) 方法來檢查是否存在。然後,我們可以根據存在的和不存在的內容進行更新。

對於小型資料庫,第二種變體可能比較簡單。

以下是第二種方法的示範

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

// create/upgrade the database without version checks
openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('books')) { // if there's no "books" store
    db.createObjectStore('books', {keyPath: 'id'}); // create it
  }
};

刪除物件儲存區

db.deleteObjectStore('books')

交易

「交易」一詞是通用的,用於許多種類的資料庫。

交易是一組作業,這些作業應全部成功或全部失敗。

例如,當一個人購買某樣東西時,我們需要

  1. 從他們的帳戶扣除款項。
  2. 將該物品新增到他們的庫存。

如果我們完成了第 1 個作業,然後發生了一些問題,例如停電,而我們無法執行第 2 個作業,那將非常糟糕。兩者都應成功(購買完成,很好!)或都失敗(至少該人保留了他們的錢,因此他們可以重試)。

交易可以保證這一點。

所有資料作業都必須在 IndexedDB 中的交易內進行。

開始交易

db.transaction(store[, type]);
  • store 是交易將存取的儲存區名稱,例如 "books"。如果我們要存取多個儲存區,則可以是儲存區名稱的陣列。
  • type – 交易類型,其中之一
    • readonly – 只能讀取,為預設值。
    • readwrite – 只能讀取和寫入資料,但不能建立/移除/變更物件儲存區。

還有 versionchange 交易類型:此類交易可以執行所有操作,但我們無法手動建立它們。IndexedDB 會在開啟資料庫時自動建立 versionchange 交易,以供 upgradeneeded 處理常式使用。這就是為什麼它是我們可以更新資料庫結構、建立/移除物件儲存區的唯一地方。

為什麼會有不同類型的交易?

效能是交易需要標記為 readonlyreadwrite 的原因。

許多 readonly 交易可以同時存取同一個儲存區,但 readwrite 交易不行。readwrite 交易會「鎖定」儲存區以進行寫入。下一個交易必須等到前一個交易完成後才能存取同一個儲存區。

建立交易後,我們可以像這樣將項目新增到儲存區

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

// get an object store to operate on it
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. 建立交易,提到它將存取的所有儲存區,在 (1)
  2. 使用 transaction.objectStore(name) 取得儲存區物件,在 (2)
  3. 對物件儲存區 books.add(book) 執行要求,在 (3)
  4. …處理要求成功/錯誤 (4),然後我們可以視需要提出其他要求等。

物件儲存區支援兩種方法來儲存值

  • put(value, [key])value 新增至儲存空間。僅當物件儲存空間沒有 keyPathautoIncrement 選項時,才會提供 key。如果已經有具有相同金鑰的值,則會將其取代。

  • add(value, [key])put 相同,但如果已經有具有相同金鑰的值,則要求會失敗,並會產生名稱為 "ConstraintError" 的錯誤。

類似於開啟資料庫,我們可以傳送要求:books.add(book),然後等待 success/error 事件。

  • addrequest.result 是新物件的金鑰。
  • 錯誤會在 request.error 中(如果有的話)。

交易自動提交

在上面的範例中,我們啟動交易並提出 add 要求。但如同我們先前所述,交易可能有多個關聯要求,這些要求必須全部成功或全部失敗。我們如何將交易標示為已完成,且沒有更多要求?

簡短的回答是:我們不標示。

在規範的下一版本 3.0 中,可能會有一種手動完成交易的方法,但目前在 2.0 中還沒有。

當所有交易要求都完成,且 微任務佇列 為空時,交易會自動提交。

通常,我們可以假設交易在所有要求都完成且目前的程式碼完成時提交。

因此,在上面的範例中,不需要任何特殊呼叫來完成交易。

交易自動提交原則有一個重要的副作用。我們無法在交易中插入非同步操作,例如 fetchsetTimeout。IndexedDB 不會讓交易一直等到這些操作完成。

在下面的程式碼中,第 (*) 行的 request2 會失敗,因為交易已經提交,且無法在其中提出任何要求。

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 是非同步操作,也就是巨任務。交易會在瀏覽器開始執行巨任務之前關閉。

IndexedDB 規範的作者認為交易應該是短暫的。主要是出於效能考量。

值得注意的是,readwrite 交易會「鎖定」儲存空間以進行寫入。因此,如果應用程式的某一部分在 books 物件儲存空間上啟動 readwrite,則想要執行相同動作的另一部分必須等待:新的交易會「暫停」,直到第一個交易完成。如果交易花費很長時間,可能會導致奇怪的延遲。

因此,該怎麼做?

在上述範例中,我們可以在新的請求 (*) 之前建立一個新的 db.transaction

但是,如果我們想要將作業保留在一起,在一個交易中,將 IndexedDB 交易和「其他」非同步內容分開,這樣會更好。

首先,建立 fetch,視需要準備資料,之後建立一個交易並執行所有資料庫請求,這樣就會運作。

為了偵測成功完成的時刻,我們可以聆聽 transaction.oncomplete 事件

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

// ...perform operations...

transaction.oncomplete = function() {
  console.log("Transaction is complete");
};

只有 complete 可以保證交易會整體儲存。個別請求可能會成功,但最終寫入作業可能會出錯(例如 I/O 錯誤或其他問題)。

若要手動中止交易,請呼叫

transaction.abort();

這樣會取消請求所做的所有修改,並觸發 transaction.onabort 事件。

錯誤處理

寫入請求可能會失敗。

這是可以預期的,不只因為我們這邊可能發生錯誤,也可能是因為與交易本身無關的原因。例如,儲存空間配額可能已超過。因此,我們必須準備好處理這種情況。

失敗的請求會自動中止交易,取消其所有變更。

在某些情況下,我們可能想要處理失敗(例如嘗試另一個請求),而不取消現有變更,並繼續交易。這是可能的。request.onerror 處理常式可以透過呼叫 event.preventDefault() 來防止交易中止。

在以下範例中,會新增一本新書,其金鑰 (id) 與現有書相同。store.add 方法會在這種情況下產生一個 "ConstraintError"。我們會在不取消交易的情況下處理它

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

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

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

request.onerror = function(event) {
  // ConstraintError occurs when an object with the same id already exists
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // handle the error
    event.preventDefault(); // don't abort the transaction
    // use another key for the book?
  } else {
    // unexpected error, can't handle it
    // the transaction will abort
  }
};

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

事件委派

我們需要每個請求的 onerror/onsuccess 嗎?並非每次都需要。我們可以使用事件委派來代替。

IndexedDB 事件會冒泡:requesttransactiondatabase

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

因此,我們可以使用 db.onerror 處理常式擷取所有錯誤,以進行報告或其他目的

db.onerror = function(event) {
  let request = event.target; // the request that caused the error

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

…但是,如果錯誤已完全處理,該怎麼辦?我們不希望在這種情況下報告錯誤。

我們可以使用 request.onerror 中的 event.stopPropagation() 來停止冒泡,進而停止 db.onerror

request.onerror = function(event) {
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // handle the error
    event.preventDefault(); // don't abort the transaction
    event.stopPropagation(); // don't bubble error up, "chew" it
  } else {
    // do nothing
    // transaction will be aborted
    // we can take care of error in transaction.onabort
  }
};

搜尋

在物件儲存區中有兩種主要的搜尋類型

  1. 透過金鑰值或金鑰範圍。在我們的「書籍」儲存區中,這將是 book.id 的值或值範圍。
  2. 透過另一個物件欄位,例如 book.price。這需要一個額外的資料結構,稱為「索引」。

依據鍵值

首先,我們來處理第一種搜尋類型:依據鍵值。

搜尋方法同時支援精確鍵值和所謂的「值範圍」-IDBKeyRange 物件,用來指定可接受的「鍵值範圍」。

IDBKeyRange 物件使用下列呼叫建立

  • IDBKeyRange.lowerBound(lower, [open]) 表示:≥lower(如果 open 為 true,則為 >lower
  • IDBKeyRange.upperBound(upper, [open]) 表示:≤upper(如果 open 為 true,則為 <upper
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) 表示:介於 lowerupper 之間。如果開啟標記為 true,則對應鍵值不會包含在範圍內。
  • IDBKeyRange.only(key) -一個僅包含一個 key 的範圍,很少使用。

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

若要執行實際搜尋,可以使用下列方法。它們接受一個 query 參數,該參數可以是精確鍵值或鍵值範圍

  • store.get(query) -依據鍵值或範圍搜尋第一個值。
  • store.getAll([query], [count]) -搜尋所有值,如果已提供,則以 count 限制。
  • store.getKey(query) -搜尋滿足查詢(通常是範圍)的第一個鍵值。
  • store.getAllKeys([query], [count]) -搜尋滿足查詢(通常是範圍)的所有鍵值,如果已提供,則最多 count 個。
  • store.count([query]) -取得滿足查詢(通常是範圍)的鍵值總數。

例如,我們的商店有很多書。請記住,id 欄位是鍵值,因此所有這些方法都可以依據 id 搜尋。

要求範例

// get one book
books.get('js')

// get books with 'css' <= id <= 'html'
books.getAll(IDBKeyRange.bound('css', 'html'))

// get books with id < 'html'
books.getAll(IDBKeyRange.upperBound('html', true))

// get all books
books.getAll()

// get all keys, where id > 'js'
books.getAllKeys(IDBKeyRange.lowerBound('js', true))
物件儲存區總是經過排序

物件儲存區會在內部依據鍵值對值進行排序。

因此,傳回多個值的請求總是會依據鍵值順序傳回這些值。

依據使用索引的欄位

若要依據其他物件欄位搜尋,我們需要建立一個名為「索引」的額外資料結構。

索引是儲存區的「附加元件」,用來追蹤特定物件欄位。對於該欄位的每個值,它會儲存擁有該值的物件的鍵值清單。下方會有更詳細的說明。

語法

objectStore.createIndex(name, keyPath, [options]);
  • name -索引名稱,
  • keyPath -索引應追蹤的物件欄位路徑(我們將依據該欄位搜尋),
  • option -一個具有屬性的選用物件
    • unique – 如果為 true,則儲存區中只有一個物件的 keyPath 值與給定值相同。如果我們嘗試新增重複值,索引會產生錯誤來強制執行此規則。
    • multiEntry – 僅在 keyPath 上的值為陣列時使用。在這種情況下,索引會預設將整個陣列視為鍵。但如果 multiEntry 為 true,則索引會為陣列中的每個值保留一個儲存區物件清單。因此,陣列成員會變成索引鍵。

在我們的範例中,我們儲存以 id 為鍵的書籍。

假設我們要依據 price 搜尋。

首先,我們需要建立索引。它必須在 upgradeneeded 中完成,就像物件儲存區一樣

openRequest.onupgradeneeded = function() {
  // we must create the index here, in versionchange transaction
  let books = db.createObjectStore('books', {keyPath: 'id'});
  let index = books.createIndex('price_idx', 'price');
};
  • 索引會追蹤 price 欄位。
  • 價格並非唯一,可能有多本書價格相同,所以我們不設定 unique 選項。
  • 價格並非陣列,所以 multiEntry 旗標不適用。

假設我們的 inventory 有 4 本書。以下圖片明確顯示 index 的內容

如前所述,每個 price 值(第二個引數)的索引會保留具有該價格的鍵清單。

索引會自動保持最新狀態,我們不必擔心它。

現在,當我們要搜尋特定價格時,我們只需對索引套用相同的搜尋方法

let transaction = db.transaction("books"); // readonly
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); // array of books with price=10
  } else {
    console.log("No such books");
  }
};

我們也可以使用 IDBKeyRange 建立範圍,並尋找便宜/昂貴的書籍

// find books where price <= 5
let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

索引會根據追蹤的物件欄位(在本例中為 price)進行內部排序。因此,當我們進行搜尋時,結果也會依據 price 排序。

從儲存區中刪除

delete 方法會透過查詢來尋找要刪除的值,呼叫格式類似於 getAll

  • delete(query) – 透過查詢刪除相符的值。

例如

// delete the book with id='js'
books.delete('js');

如果我們要根據價格或其他物件欄位刪除書籍,則我們應先在索引中找到鍵,然後呼叫 delete

// find the key where price = 5
let request = priceIndex.getKey(5);

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

要刪除所有內容

books.clear(); // clear the storage.

游標

getAll/getAllKeys 等方法會傳回鍵/值陣列。

但物件儲存空間可能很大,大於可用記憶體。然後 getAll 將無法取得所有記錄作為陣列。

該怎麼辦?

游標提供解決此問題的方法。

游標 是特殊物件,會根據查詢條件遍歷物件儲存空間,並一次傳回一個金鑰/值,進而節省記憶體。

由於物件儲存空間會依金鑰內部排序,因此游標會以金鑰順序瀏覽儲存空間(預設為遞增)。

語法

// like getAll, but with a cursor:
let request = store.openCursor(query, [direction]);

// to get keys, not values (like getAllKeys): store.openKeyCursor
  • query 是金鑰或金鑰範圍,與 getAll 相同。
  • direction 是選用引數,用於指定要使用的順序
    • "next" – 預設值,游標會從金鑰最小的記錄開始往上瀏覽。
    • "prev" – 反向順序:從金鑰最大的記錄開始往下瀏覽。
    • "nextunique""prevunique" – 與上述相同,但會略過金鑰相同的記錄(僅適用於索引上的游標,例如對於價格=5 的多本書,只會傳回第一本書)。

游標的主要差異在於 request.onsuccess 會觸發多次:每次針對一個結果觸發一次。

以下是使用游標的範例

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

let request = books.openCursor();

// called for each book found by the cursor
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key; // book key (id field)
    let value = cursor.value; // book object
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books");
  }
};

主要的游標方法如下

  • advance(count) – 將游標往前推進 count 次,略過值。
  • continue([key]) – 將游標推進到範圍內符合條件的下一筆值(或在給定 key 的情況下,推進到緊接在 key 之後)。

無論游標是否符合更多值,onsuccess 都會被呼叫,然後我們可以在 result 中取得指向下一筆記錄的游標,或 undefined

在上述範例中,游標是針對物件儲存空間建立的。

但我們也可以針對索引建立游標。正如我們所知,索引允許根據物件欄位進行搜尋。針對索引建立的游標與針對物件儲存空間建立的游標執行完全相同的動作,它們透過一次傳回一個值來節省記憶體。

對於針對索引建立的游標,cursor.key 是索引金鑰(例如價格),我們應該使用 cursor.primaryKey 屬性來取得物件金鑰

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

// called for each record
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let primaryKey = cursor.primaryKey; // next object store key (id field)
    let value = cursor.value; // next object store object (book object)
    let key = cursor.key; // next index key (price)
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books");
  }
};

Promise 包裝函式

在每個請求中加入 onsuccess/onerror 是一項相當繁瑣的任務。有時,我們可以使用事件委派讓生活更輕鬆,例如對整個交易設定處理常式,但 async/await 更為方便。

讓我們在本章節中進一步使用精簡的 Promise 封裝 https://github.com/jakearchibald/idb。它會建立一個全域的 idb 物件,其中包含 已轉換為 Promise 的 IndexedDB 方法。

然後,我們可以像這樣撰寫,取代 onsuccess/onerror

let db = await idb.openDB('store', 1, db => {
  if (db.oldVersion == 0) {
    // perform the initialization
    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);
}

因此,我們擁有所有絕佳的「純粹非同步程式碼」和「try…catch」內容。

錯誤處理

如果我們沒有捕捉到錯誤,它就會一直往下傳遞,直到最接近的外層 try..catch

未捕捉到的錯誤會變成 window 物件上的「未處理的 Promise 拒絕」事件。

我們可以像這樣處理此類錯誤

window.addEventListener('unhandledrejection', event => {
  let request = event.target; // IndexedDB native request object
  let error = event.reason; //  Unhandled error object, same as request.error
  ...report about the error...
});

「非活動交易」陷阱

正如我們所知,交易會在瀏覽器完成目前的程式碼和微任務後自動提交。因此,如果我們在交易中放置巨觀任務(例如 fetch),交易就不會等到它完成。它只會自動提交。因此,其中的下一個請求就會失敗。

對於 Promise 封裝和 async/await,情況也是一樣。

以下是交易中放置 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() }); // Error

fetch (*) 之後的下一個 inventory.add 會因「非活動交易」錯誤而失敗,因為交易在那個時候已經提交並關閉。

解決方法與使用原生 IndexedDB 時相同:建立新的交易或將事物分開。

  1. 準備資料並先擷取所有需要的事物。
  2. 然後儲存至資料庫。

取得原生物件

在內部,封裝會執行原生 IndexedDB 請求,為其加入 onerror/onsuccess,並傳回一個 Promise,以結果拒絕或解析。

這在大部分時間都能正常運作。範例在程式庫頁面 https://github.com/jakearchibald/idb

在少數情況下,當我們需要原始 request 物件時,可以存取 Promise 的 promise.request 屬性。

let promise = books.add(book); // get a promise (don't await for its result)

let request = promise.request; // native request object
let transaction = request.transaction; // native transaction object

// ...do some native IndexedDB voodoo...

let result = await promise; // if still needed

摘要

IndexedDB 可以視為「類固醇的 localStorage」。它是一個簡單的鍵值資料庫,功能強大到足以支援離線應用程式,但使用起來卻很簡單。

最好的手冊是規格書,目前的版本 是 2.0,但 3.0 的少數方法(差異不大)已獲得部分支援。

基本用法可以用幾句話說明

  1. 取得一個 Promise 封裝器,例如 idb
  2. 開啟一個資料庫:idb.openDb(name, version, onupgradeneeded)
    • onupgradeneeded 處理常式中建立物件儲存和索引,或在需要時執行版本更新。
  3. 對於要求
    • 建立交易 db.transaction('books')(需要的話,設定為讀寫)。
    • 取得物件儲存 transaction.objectStore('books')
  4. 然後,若要依據金鑰搜尋,請直接呼叫物件儲存上的方法。
    • 若要依據物件欄位搜尋,請建立索引。
  5. 如果資料不符合記憶體大小,請使用游標。

以下是小型示範應用程式

結果
index.html
<!doctype html>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/idb.min.js"></script>

<button onclick="addBook()">Add a book</button>
<button onclick="clearBooks()">Clear books</button>

<p>Books list:</p>

<ul id="listElem"></ul>

<script>
let db;

init();

async function init() {
  db = await idb.openDb('booksDb', 1, db => {
    db.createObjectStore('books', {keyPath: 'name'});
  });

  list();
}

async function list() {
  let tx = db.transaction('books');
  let bookStore = tx.objectStore('books');

  let books = await bookStore.getAll();

  if (books.length) {
    listElem.innerHTML = books.map(book => `<li>
        name: ${book.name}, price: ${book.price}
      </li>`).join('');
  } else {
    listElem.innerHTML = '<li>No books yet. Please add books.</li>'
  }


}

async function clearBooks() {
  let tx = db.transaction('books', 'readwrite');
  await tx.objectStore('books').clear();
  await list();
}

async function addBook() {
  let name = prompt("Book name?");
  let price = +prompt("Book price?");

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

  try {
    await tx.objectStore('books').add({name, price});
    await list();
  } catch(err) {
    if (err.name == 'ConstraintError') {
      alert("Such book exists already");
      await addBook();
    } else {
      throw err;
    }
  }
}

window.addEventListener('unhandledrejection', event => {
  alert("Error: " + event.reason.message);
});

</script>
教學地圖

留言

留言前請先閱讀…
  • 如果您有改善建議,請 提交 GitHub 議題或提交 Pull Request,而不是留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行,請用 <pre> 標籤將其包起來,若要插入 10 行以上的程式碼,請使用沙盒(plnkrjsbincodepen…)。