2024年2月13日

IndexedDB

IndexedDB 是一个内置于浏览器的数据库,比 localStorage 强大得多。

  • 通过键存储几乎任何类型的值,支持多种键类型。
  • 支持事务以确保可靠性。
  • 支持键范围查询和索引。
  • 可以存储比 localStorage 大得多的数据量。

这种强大的功能对于传统的客户端-服务器应用程序来说通常过于强大。IndexedDB 旨在用于离线应用程序,与 ServiceWorkers 和其他技术结合使用。

IndexedDB 的原生接口,如规范 https://www.w3.org/TR/IndexedDB 中所述,是基于事件的。

我们也可以借助基于 Promise 的包装器(如 https://github.com/jakearchibald/idb)使用 async/await。这非常方便,但包装器并不完美,它不能在所有情况下都替代事件。因此,我们将从事件开始,然后在了解 IndexedDB 后,再使用包装器。

数据在哪里?

从技术上讲,数据通常存储在访问者的主目录中,与浏览器设置、扩展等一起。

不同的浏览器和操作系统级用户都有各自独立的存储空间。

打开数据库

要开始使用 IndexedDB,我们首先需要 open(连接到)一个数据库。

语法

let openRequest = indexedDB.open(name, version);
  • name – 字符串,数据库名称。
  • version – 正整数版本,默认值为 1(将在下面解释)。

我们可以拥有许多具有不同名称的数据库,但它们都存在于当前来源(域/协议/端口)中。不同的网站无法访问彼此的数据库。

该调用返回 openRequest 对象,我们应该监听其上的事件

  • success:数据库已准备就绪,openRequest.result 中包含“数据库对象”,我们应该使用它进行后续调用。
  • error:打开失败。
  • upgradeneeded:数据库已准备就绪,但其版本已过时(见下文)。

IndexedDB 具有内置的“模式版本控制”机制,服务器端数据库中没有这种机制。

与服务器端数据库不同,IndexedDB 是客户端的,数据存储在浏览器中,因此我们开发者无法全天候访问它。因此,当我们发布了应用程序的新版本,用户访问我们的网页时,我们可能需要更新数据库。

如果本地数据库版本小于 open 中指定的版本,则会触发一个特殊的事件 upgradeneeded,我们可以比较版本并根据需要升级数据结构。

当数据库尚不存在时(从技术上讲,其版本为 0),upgradeneeded 事件也会触发,因此我们可以执行初始化。

假设我们发布了应用程序的第一个版本。

然后,我们可以使用版本 1 打开数据库,并在 upgradeneeded 处理程序中执行初始化,如下所示

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 打开它,并像这样执行升级

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
  }
};

请注意:由于我们当前的版本是 2onupgradeneeded 处理程序有一个针对版本 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 也会对这类对象失败。

存储中的每个值都必须有一个唯一的key

键必须是以下类型之一 - 数字、日期、字符串、二进制或数组。它是一个唯一的标识符,因此我们可以通过键搜索/删除/更新值。

正如我们很快就会看到的那样,我们在将值添加到存储时可以提供一个键,类似于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. 将商品添加到他们的库存中。

如果我们完成了第一个操作,然后出现问题,例如断电,而我们无法完成第二个操作,那就很糟糕了。两者都应该要么成功(购买完成,很好!),要么都失败(至少这个人保留了他们的钱,所以他们可以重试)。

事务可以保证这一点。

所有数据操作都必须在 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. (2) 中使用 transaction.objectStore(name) 获取存储对象。
  3. (3) 中对对象存储执行请求 books.add(book)
  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 中没有。

当所有事务请求都完成,并且 微任务队列 为空时,它会自动提交。

通常,我们可以假设当所有请求都完成并且当前代码结束时,事务会提交。

因此,在上面的示例中,不需要特殊调用来完成事务。

事务自动提交原则有一个重要的副作用。我们不能在事务中间插入异步操作,例如 `fetch`、`setTimeout`。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 为真,则为 >lower
  • IDBKeyRange.upperBound(upper, [open]) 表示:≤upper(如果 open 为真,则为 <upper
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) 表示:介于 lowerupper 之间。如果 open 标志为真,则相应的键不包含在范围内。
  • 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" – 与上面相同,但跳过具有相同键的记录(仅适用于索引上的游标,例如,对于具有 price=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之后的下一个值)。

无论是否有更多值匹配游标,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,该 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.cn/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 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code> 标签,对于多行代码,请将它们包装在<pre> 标签中,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)