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
}
};
请注意:由于我们当前的版本是 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
的开放连接,而第二个选项卡尝试在其 upgradeneeded
处理程序中将其更新到版本 2
。
问题是,数据库在两个选项卡之间共享,因为它是同一个网站,同一个来源。它不能同时是版本 1
和 2
。要执行更新到版本 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
};
…换句话说,我们在这里做了两件事
db.onversionchange
监听器通知我们关于并行更新尝试的信息,如果当前数据库版本过期。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 到 2,从 2 到 3,从 3 到 4 等。然后,在
upgradeneeded
中,我们可以比较版本(例如,旧版本 2,现在版本 4),并逐步运行每个版本的升级,针对每个中间版本(从 2 到 3,然后从 3 到 4)。 - 或者,我们可以直接检查数据库:获取现有对象存储的列表,作为
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')
事务
术语“事务”是通用的,在许多类型的数据库中使用。
事务是一组操作,这些操作应该全部成功或全部失败。
例如,当一个人购买东西时,我们需要:
- 从他们的账户中扣除金额。
- 将商品添加到他们的库存中。
如果我们完成了第一个操作,然后出现问题,例如断电,而我们无法完成第二个操作,那就很糟糕了。两者都应该要么成功(购买完成,很好!),要么都失败(至少这个人保留了他们的钱,所以他们可以重试)。
事务可以保证这一点。
所有数据操作都必须在 IndexedDB 中的事务内进行。
要启动事务,请执行以下操作:
db.transaction(store[, type]);
store
是一个存储名称,事务将要访问它,例如"books"
。如果要访问多个存储,则可以是存储名称数组。type
- 事务类型,其中之一readonly
- 只能读取,默认值。readwrite
- 只能读取和写入数据,但不能创建/删除/修改对象存储。
还有 versionchange
事务类型:此类事务可以执行所有操作,但我们无法手动创建它们。IndexedDB 在打开数据库时会自动创建 versionchange
事务,用于 upgradeneeded
处理程序。这就是为什么它是一个可以更新数据库结构、创建/删除对象存储的唯一地方。
性能是事务需要被标记为 readonly
和 readwrite
的原因。
许多 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)
中创建事务,并提及它将要访问的所有存储。 - 在
(2)
中使用transaction.objectStore(name)
获取存储对象。 - 在
(3)
中对对象存储执行请求books.add(book)
。 - …处理请求成功/错误
(4)
,然后我们可以根据需要发出其他请求,等等。
对象存储支持两种方法来存储值
-
put(value, [key]) 将
value
添加到存储中。仅当对象存储没有keyPath
或autoIncrement
选项时才提供key
。如果已经存在具有相同键的值,它将被替换。 -
add(value, [key]) 与
put
相同,但如果已经存在具有相同键的值,则请求失败,并生成一个名为"ConstraintError"
的错误。
类似于打开数据库,我们可以发送一个请求:books.add(book)
,然后等待 success/error
事件。
add
的request.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 事件冒泡:request
→ transaction
→ database
。
所有事件都是 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
}
};
搜索
对象存储中有两种主要的搜索类型。
- 通过键值或键范围。在我们的“书籍”存储中,这将是
book.id
的值或值的范围。 - 通过另一个对象字段,例如
book.price
。这需要一个额外的名为“索引”的数据结构。
通过键
首先,让我们处理第一种搜索类型:通过键。
搜索方法支持精确的键值和所谓的“值范围” - IDBKeyRange 对象,指定可接受的“键范围”。
IDBKeyRange
对象使用以下调用创建
IDBKeyRange.lowerBound(lower, [open])
表示:≥lower
(如果open
为真,则为>lower
)IDBKeyRange.upperBound(upper, [open])
表示:≤upper
(如果open
为真,则为<upper
)IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen])
表示:介于lower
和upper
之间。如果 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 时相同:要么创建一个新事务,要么将操作分开。
- 准备数据并首先获取所有需要的数据。
- 然后保存到数据库中。
获取原生对象
在内部,包装器执行一个原生 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 的一些方法(差别不大)部分支持。
基本用法可以用几个短语描述
- 获取一个 Promise 包装器,例如 idb。
- 打开数据库:
idb.openDb(name, version, onupgradeneeded)
- 在
onupgradeneeded
处理程序中创建对象存储和索引,或根据需要执行版本更新。
- 在
- 对于请求
- 创建事务
db.transaction('books')
(如果需要,则为读写)。 - 获取对象存储
transaction.objectStore('books')
。
- 创建事务
- 然后,要按键搜索,直接在对象存储上调用方法。
- 要按对象字段搜索,请创建索引。
- 如果数据不适合内存,请使用游标。
这是一个小型演示应用程序
<!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>
评论
<code>
标签,对于多行代码,请将它们包装在<pre>
标签中,对于超过 10 行的代码,请使用沙箱(plnkr,jsbin,codepen…)