indexedDB是一个内置的数据库,它比localStorage 强大的多。

  • 键/值储存:值(几乎)可以是任何类型,键有多种类型。
  • 支撑事务的可靠性。
  • 支持键范围查询、索引。
  • 相比localStorage ,它可以存储更多数据 。

对于传统的客户端-服务器应用,这些功能通常是没有必要的。IndexedDB适用于离线应用,适合与ServiceWorkers、其他技术相结合。

根据规范 https://www.w3.org/tr/indexeddb 中的描述,IndexedDB的本机接口是基于事件的。

我们还可以在基于promise的包装器wrapper (如 https://github.com/jakearchibald/idb)的帮助下使用`async/await`。这要方便的多,但是包装器并不完美,它并不能替代所有情况下的事件。因此,我们先从事件开始练习,在理解IndexedDB之后,最后使用包装器。

打开数据库

要开始使用IndexedDB,首先要打开一个数据库。

语法:

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

数据库可以有许多不同的名称,但是必须存在于当前的源(域/协议/端口)中。不同的网站不能相互访问对方的数据库。

调用之后,监听OpenRequest对象上的事件:

  • success :数据库准备就绪,OpenRequest.Result 中有了一个数据库对象”Database Object”,我们应该使用它进行进一步的调用。
  • error:打开失败。
  • upgradeneeded: 数据库已准备就绪,但其版本已过时(见下文)。

IndexedDB具有内置的”模式版本控制”机制,这在服务器端数据库中是不存在的。

与服务器端数据库不同,IndexedDB是客户端的,数据存储在浏览器中。因此开发人员不能直接访问它。但当新版本的应用程序发布时,我们可能需要更新数据库。

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

数据库还不存在时,也会触发这个事件。因此,应该先执行初始化。

首次发布应用程序时,使用版本1打开它,并在upgradeneedHandler中执行初始化:

  1. let openRequest = indexedDB.open("store", 1);
  2. openRequest.onupgradeneeded = function() {
  3. // 如果客户端没有数据库则触发
  4. // ...执行初始化...
  5. };
  6. openRequest.onerror = function() {
  7. console.error("Error", openRequest.error);
  8. };
  9. openRequest.onsuccess = function() {
  10. let db = openRequest.result;
  11. // 继续使用db对象处理数据库
  12. };

当我们发布第二个版本时:

  1. let openRequest = indexedDB.open("store", 2);
  2. openRequest.onupgradeneeded = function() {
  3. // 现有的数据库版本小于2(或不存在)
  4. let db = openRequest.result;
  5. switch(db.version) { // 现有的db版本
  6. case 0:
  7. // 版本0表示客户端没有数据库
  8. // 执行初始化
  9. case 1:
  10. // 客户端版本为1
  11. // 更新
  12. }
  13. };

因此,需要在openRequest.onUpgradeneeded 中更新数据库 ,很快就能知道运行结果。当程序处理完且不报错时,会触发openRequest.onSuccess

openRequest.onSuccess 的函数回调中,将使用openRequest.result中的数据库对象,进行进一步的操作。

删除数据库:

  1. let deleteRequest = indexedDB.deleteDatabase(name)
  2. // deleteRequest.onsuccess/onerror 返回结果

能打开一个旧版本吗? 如果打开了一个比当前版本更低的数据库,该怎么办?例如,现有的DB版本是3,但打开了版本2open(...2)。 报错,触发openRequest.onError。 当访问者加载了旧代码(例如,代理缓存),可能会发生这种情况。这时我们应该检查db.version ,并建议他重新加载页面。重新检查缓存标头,确保用户永远不会加载旧代码。

并行更新问题

提到版本控制,有一个小问题。

一个用户在网页中使用数据库版本1打开了网站。
这时网站更新到版本2,这个用户在另一网页下打开了网站。这时两个网页都是我们的网站,但一个与数据库版本1有开放连接,而另一个试图在 upgradeneed 函数处理器中更新。

问题是,两个网页是同一个站点,同一个来源,共享同一个数据库。而数据库不能同时是版本1和版本2。要执行到版本2的更新,必须关闭版本1的所有连接。

为了完成这些,当尝试并行升级时, versionChange 事件会触发一个打开的数据库对象。监听这个对象,关闭数据库(并且建议访问者重新加载页面,获取最新的代码)。

如果旧连接不关闭,新连接会被 blocked 事件阻塞,而不是 success 事件。

下面是执行此操作的代码:

  1. let openRequest = indexedDB.open("store", 2);
  2. openRequest.onupgradeneeded = ...;
  3. openRequest.onerror = ...;
  4. openRequest.onsuccess = function() {
  5. let db = openRequest.result;
  6. db.onversionchange = function() {
  7. db.close();
  8. // 数据库已过时,请重新加载页面
  9. alert("Database is outdated, please reload the page.")
  10. };
  11. // ...db 数据库已经准备好,使用它...
  12. };
  13. openRequest.onblocked = function() {
  14. // 到同一数据库的另一个开放连接
  15. // 触发db.onversionchange后没有关闭
  16. };

在这里做两件事:
1、在成功打开后添加 db.onVersionChange 监听器,以得到并行更新尝试的信息。
2、添加 openRequest.onBlockedListener 来处理旧连接未关闭的情况。如果在 db.onVersionChange 中关闭,就不会发生这种情况。
还有其他变体。例如,可以在 db.onversionchange 中优雅地关闭一些东西,关闭连接之前提示用户保存数据。如果db.onVersionChange 完成但没有关闭,新的连接将立即阻塞。可以要求用户只保留新的网页,关闭旧网页,以此更新数据。

这种更新冲突很少发生,但我们至少应该处理一下。例如使用 onblocked 处理程序,以防程序卡死影响用户体验。

Object store 对象存储

要在IndexedDB中存储某些内容,我们需要一个对象库 object store

对象库是IndexedDB的核心概念,在其他数据库中对应的对象称为”表”或”集合”。这是储存数据的地方。一个数据库可能有多个存储区:一个用于用户,另一个用于商品,等等。

尽管被命名为”对象库”,但也可以存储原始类型。

几乎可以存储任何值,包括复杂的对象。

IndexedDB使用标准序列化算法 standard serialization algorithm来克隆和存储对象。它类似于 JSON.Stringify ,但功能更强大,能够存储更多的数据类型。

无法存储的对象示例:循环引用的对象。此类对象不可序列化,也不能进行 JSON.stringify

存储区中的每个值都必须有唯一的键 key 。**

键的类型必须为数字、日期、字符串、二进制或数组。它是唯一的标识符:通过键来 搜索/删除/更新 值。
indexedDB 索引数据库 - 图1
类似于 localStorage ,我们向存储区添加值时,可以提供一个键。但当我们存储对象时,IndexedDB允许设置一个对象属性作为键,这更加方便了。也可以自动生成密钥。

但我们需要先创建一个对象库。

创建对象库的语法:

  1. db.createObjectStore(name[, keyOptions]);

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

  • name 是存储区名称,例如 "books" 表示书籍。
  • keyOptions 是具有以下两个属性之一的可选对象:
    • keyPath ——对象属性的路径,IndexedDB 将以此路径作为键,例如 id
    • autoincrement —— 如果为true,则自动生成新存储的对象的键,键是一个不断递增的数字。

如果不提供 keyOptions ,那么我们将需要在以后存储对象时显式地提供一个键。

例如,此对象库使用 id 属性作为键:

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

upgradeneedHandler 程序中,只有在创建DB版本时,对象库被 才能 创建/修改。

这是技术上的限制。在
upgradeneedHandler **之外,可以 添加/删除/更新数据,但是只能在版本更新期间 创建/删除/更改 对象库。

要执行数据库版本升级,主要有两种方法:

1、实现每个版本的升级功能:从1到2,从2到3,从3到4,等等。在 upgradeneeded 中,可以进行版本比较(例如,老版本是2,需要升级到4),并针对每个中间版本(2到3,然后3到4)逐步运行每个版本的升级。
2、检查数据库:以 db.objectStoreNames 的形式获取现有对象存储的列表。该对象是一个 DOMStringList ,提供 contains(name) 方法来检查name是否存在,再根据存在和不存在的内容进行更新。

对于小型数据库,第二种变体可能更简单。

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

  1. let openRequest = indexedDB.open("db", 2);
  2. // 创建/升级数据库而无需版本检查
  3. openRequest.onupgradeneeded = function() {
  4. let db = openRequest.result;
  5. if (!db.objectStoreNames.contains('books')) { // 如果没有 "books" 数据
  6. db.createObjectStore('books', {keyPath: 'id'}); // 创造它
  7. }
  8. };

要删除对象库:

  1. db.deleteObjectStore('books')

事务

术语”事务”是通用的,许多数据库都有用到。

事务是一组操作,要么全部成功,要么全部失败。

例如,当一个人买东西时,需要:
1、从他们的账户中扣除这笔钱。
2、将该项目添加到他们的清单中。

如果完成了第一个操作,但是出了问题,比如停电。这时无法完成第二个操作,这非常糟糕。两件时应该要么都成功(购买完成,好!)或同时失败(这个人保留了钱,可以重新尝试)。

事务可以保证同时完成。

所有数据操作都必须在IndexedDB中的事务内进行。
**
启动事务:

  1. db.transaction(store[, type]);
  • store 是事务要访问的存储名称,例如”书”。如果我们要访问多个商店,则是商店名称的数组。
  • type –交易类型,以下类型之一:
    • readonly –只读,默认值。
    • readwrite – 只能读取和写入数据,而不能 创建/删除/更改 对象存储库。

还有 versionChange 事务类型:这种事务可以做任何事情,但不能被手动创建。IndexedDB在打开数据库时,会自动为 updateNeeded 处理程序创建 versionChange 事务。这就是它为什么可以更新数据库结构、 创建/删除 对象存储的原因。
**

为什么存在不同类型的事务? 性能是事务需要标记为 readonlyreadwrite 的原因。 许多只读事务能够同时访问同一存储区,但读写事务不能。因为读写事务会”锁定”存储区进行写操作。下一个事务必须等待前一个事务完成,才能访问相同的存储区。

创建事务后,我们可以将项目添加到存储,如下所示:

  1. let transaction = db.transaction("books", "readwrite"); // (1)
  2. // 让对象存储对其进行操作
  3. let books = transaction.objectStore("books"); // (2)
  4. let book = {
  5. id: 'js',
  6. price: 10,
  7. created: new Date()
  8. };
  9. let request = books.add(book); // (3)
  10. request.onsuccess = function() { // (4)
  11. console.log("Book added to the store", request.result);
  12. // 书已添加到存储区
  13. };
  14. request.onerror = function() {
  15. console.log("Error", request.error);
  16. };

基本有四个步骤:

  1. 创建一个事务,在(1)表明要访问的所有存储。
  2. 使用 transaction.objectStore(name) ,在(2)中获取存储对象。
  3. 在(3)执行对对象存储 books.add(book) 的请求。
  4. …处理请求 成功/错误(4),还可以发出其他请求。

对象存储支持两种存储值的方法:

  • put(value, [key])value 添加到存储区。仅当对象存储没有 keyPathautoIncrement 时,才提供 key 。如果已经存在具有相同键的值,则将替换该值。

  • add(value, [key]) put 相同,但是如果已经有一个值具有相同的键,则请求失败,并生成一个名为 "ConstraInterror" 的错误。

与打开数据库类似,我们可以发送一个请求: books.add(book) ,然后等待 成功/错误 `事件。

  • addrequest.result 是新对象的键。
  • 错误在 request.error (如果有的话)中。

事务的自动提交

在上面的示例中,我们启动了事务并发出了 add 请求。但正如前面提到的,一个事务可能有多个相关的请求,这些请求必须全部成功或全部失败。那么如何标记事务为已完成,并不再请求呢?
简短的回答是:没有。
在该规范的下一个版本3.0中,可能会有一种手动方式来完成事务,但目前在2.0中还没有。
当所有事务的请求完成,并且微任务队列 microtasks queue 为空时,它将自动提交。
通常,我们可以假设事务在其所有请求完成时提交,并且当前代码完成。
因此,在上面的示例中,不需要任何特殊调用即可完成事务。
事务自动提交原则有一个重要的副作用。不能在事务中间插入fetch, setTimeout等异步操作。IndexedDB不会让事务等待这些操作完成。

在下面的代码中,请求2中的行(*)失败,因为事务已经提交,不能在其中发出任何请求:

  1. let request1 = books.add(book);
  2. request1.onsuccess = function() {
  3. fetch('/').then(response => {
  4. *!*
  5. let request2 = books.add(anotherBook); // (*)
  6. */!*
  7. request2.onerror = function() {
  8. console.log(request2.error.name); // 事务激活错误
  9. };
  10. });
  11. };

这是因为 fetch 是一个异步操作,一个宏任务。事务在浏览器开始执行宏任务之前关闭。

IndexedDB规范的作者认为事务应该是短期的。主要是性能原因。

值得注意的是, readwrite 事务将存储”锁定”以进行写入。因此,如果应用程序的一部分启动了Books对象存储上的读写操作 readwrite,那么希望执行相同操作的另一部分必须等待 新事务”挂起”,直到第一个事务完成。如果事务处理需要很长时间,将会导致奇怪的延迟。

那么,该怎么办?

在上面的示例中,我们可以在新请求 (*) 之前创建一个新的 db.transaction。

如果需要在一个事务中把所有操作保持一致,更好的做法是将IndexedDB事务和”其他”异步内容分开。

首先,执行 fetch ,并根据需要准备数据。然后创建事务并执行所有数据库请求,就可以工作了。

为了检测成功完成的时刻,我们可以监听 transaction.oncomplete 事件:

  1. let transaction = db.transaction("books", "readwrite");
  2. // ...执行操作...
  3. transaction.oncomplete = function() {
  4. console.log("Transaction is complete"); // 事件执行完成
  5. };

只有complete才能保证将事务作为一个整体保存。个别请求可能会成功,但最终的写入操作可能会出错(例如I/O错误或其他错误)。

要手动中止事务,请调用:

  1. transaction.abort();

取消其中的请求所做的所有修改,并触发 transaction.onabort 事件。

错误处理

写入请求可能会失败。

这是意料之中的事,不仅是事物可能出错,还有其他原因。例如超过了存储配额。因此,必须做好请求失败的处理。

失败的请求将自动中止事务,并取消所有的更改。
**
遇到需要不取消现有更改的情况下(例如尝试另一个请求)处理失败情况,并让事务继续的情况,可以调用request.onerror 处理程序,在其中调用 event.preventDefault() 防止事务中止。

在下面的示例中,添加了一本新书,其key (id) 与现有的书相同。 store.add 方法生成一个”ConstraInterror”。可以在不取消事务的情况下进行处理:

  1. let transaction = db.transaction("books", "readwrite");
  2. let book = { id: 'js', price: 10 };
  3. let request = transaction.objectStore("books").add(book);
  4. request.onerror = function(event) {
  5. // 已经有相同id的对象已经存在时,发生ConstraintError
  6. if (request.error.name == "ConstraintError") {
  7. console.log("具有此id的book已存在"); // 处理错误
  8. event.preventDefault(); // 不要中止事务
  9. // use another key for the book? 这本书使用另一好
  10. } else {
  11. // 意外错误,无法处理
  12. // 事务将中止
  13. }
  14. };
  15. transaction.onabort = function() {
  16. console.log("Error", transaction.error);
  17. };


事件委托

每个请求都需要调用 onerror/onsuccess ?并不,可以使用事件委托来代替。

IndexedDB事件冒泡:请求 -> 事务 -> 数据库。

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

因此,出于报告或其他原因,我们可以使用 db.onerror 处理程序捕获所有错误:

  1. db.onerror = function(event) {
  2. let request = event.target; // 导致错误的请求
  3. console.log("Error", request.error);
  4. };

…但是如果错误被完全处理了呢?报告这种情况不应该被报告。

我们可以通过在 request.onerror 中使用 event.stopPropagation() 来停止冒泡,从而停止 db.onerror

  1. request.onerror = function(event) {
  2. if (request.error.name == "ConstraintError") {
  3. console.log("具有此id的书已存在"); // 处理错误
  4. event.preventDefault(); // 不要中止事务
  5. event.stopPropagation(); // 不要让错误冒泡, 停止它的传播
  6. } else {
  7. // 什么都不做
  8. // 事务将中止
  9. // 我们可以解决transaction.onabort中的错误
  10. }
  11. };

通过键搜索

对象存储有两种主要的搜索类型:
1、通过一个键或一个键范围。即:通过在”books”中存储的 book.id
2、另一个对象字段,例如 book.price 。

首先,让我们来处理键和键区 (1) 。

涉及到的搜索方法,包括支持精确键,也包括所谓的“范围查询”—— IDBKeyRange 对象指定一个“键范围”。

使用以下调用函数创建范围:

  • IDBKeyRange.lowerBound(lower, [open]) 表示: ≥lower (如果 open 是 true,表示 >lower )
  • IDBKeyRange.upperBound(upper, [open]) 表示: ≤upper (如果 open 是 true,表示 <upper)
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) 表示: 在lowerupper 之间。如果open为true,则相应的键不包括在范围中。
  • IDBKeyRange.only(key) – 仅包含一个键的范围 key ,很少使用。

所有搜索方法都接受一个查询参数,该参数可以是精确键或者键范围:

  • store.get(query) – 按键或范围搜索第一个值。
  • store.getAll([query], [count]) – 搜索所有值。如果count 给定,则按 count 进行限制。
  • store.getKey(query) – 搜索满足查询的第一个键,通常是一个范围。
  • store.getAllKeys([query], [count]) – 搜索满足查询的所有键,通常是一个范围。如果 count 给定,则最多为 count
  • store.count([query]) – 获取满足查询的键的总数,通常是一个范围。

例如,我们存储区里有很多书。因为id字段是键,因此所有方法都可以按 id 进行搜索。

请求示例:

  1. // 获取一本书
  2. books.get('js')
  3. // 获取 'css' <= id <= 'html' 的书
  4. books.getAll(IDBKeyRange.bound('css', 'html'))
  5. // 获取 id < 'html' 的书
  6. books.getAll(IDBKeyRange.upperBound('html', true))
  7. // 获取所有书
  8. books.getAll()
  9. // 获取所有 id > 'js' 的键
  10. books.getAllKeys(IDBKeyRange.lowerBound('js', true))


对象存储始终是有序的 对象存储按键对值进行内部排序。 因此,请求的返回值,是按照键的顺序排列的。

通过带索引的字段搜索

要根据其他对象字段进行搜索,我们需要创建一个名为“索引(index)”的附加数据结构。

索引是存储的”附加项”,用于跟踪给定的对象字段。对于该字段的每个值,它存储有该值的对象的键列表。下面会有更详细的图片。

语法:

  1. objectStore.createIndex(name, keyPath, [options]);
  • name — 索引名称。
  • keyPath — 索引应该跟踪的对象字段的路径(我们将根据该字段进行搜索)。
  • option— 具有以下属性的可选对象:
    • unique — 如果为true,则存储中只有一个对象在 keyPath 上具有给定值。如果我们尝试添加重复项,索引将生成错误。
    • multiEntry — 只有keypath上的值是数组才时使用。这时,索引将默认把整个数组视为键。但是如果multiEntry 为true,那么索引将为该数组中的每个值保留一个存储对象的列表。所以数组成员成为了索引键。

在我们的示例中,是按照 id 键存储图书的。
假设我们想通过price进行搜索。
首先,我们需要创建一个索引。它像对象存储一样,必须在upgradeneeded中创建完成:

  1. openRequest.onupgradeneeded = function() {
  2. // 在versionchange事务中,我们必须在这里创建索引,
  3. let books = db.createObjectStore('books', {keyPath: 'id'});
  4. let index = inventory.createIndex('price_idx', 'price');
  5. };
  • 该索引将跟踪 price 字段。
  • 价格 price 不是唯一的,可能有多本书价格相同,所以我们不设置唯一 unique 选项。
  • 价格不是一个数组,因此不适用多入口 multiEntry 标志。

假设我们的库存里有4本书。下面的图片显示了该索引 index 的确切内容:

如上所述,每个price值的索引(第二个参数)保存具有该价格的键的列表。
索引自动保持最新,所以我们不必关心它。
现在,当我们想要搜索给定的价格时,只需将相同的搜索方法应用于索引。

  1. let transaction = db.transaction("books"); // 只读
  2. let books = transaction.objectStore("books");
  3. let priceIndex = books.index("price_idx");
  4. let request = priceIndex.getAll(10);
  5. request.onsuccess = function() {
  6. if (request.result !== undefined) {
  7. console.log("Books", request.result); // 价格为 10 的书的数组
  8. } else {
  9. console.log("No such books");
  10. }
  11. };

我们还可以使用 IDBKeyRange 创建范围,并查找 便宜/昂贵 的书:

  1. // 查找价格<=5的书籍
  2. let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

在我们的例子中,索引是按照被跟踪对象字段 价格 price 进行内部排序的。所以当我们进行搜索时,搜索结果也会按照价格排序。

从存储中删除

The delete 方法查找要由查询删除的值,调用格式类似于 getAll

  • delete(query) — 通过查询删除匹配的值。

例如:

  1. // 删除id=js的书
  2. books.delete('js');

如果要基于价格或其他对象字段删除书。首先需要在索引中找到键,然后调用 delete:

  1. // 找到价格= 5的钥匙
  2. let request = priceIndex.getKey(5);
  3. request.onsuccess = function() {
  4. let id = request.result;
  5. let deleteRequest = books.delete(id);
  6. };

删除所有内容:

  1. books.clear(); // 清除存储。

光标

getAll/getAllKeys 这样的方法,会返回一个 键/值 数组。

但是一个对象存储可能很大,比可用的内存还大。这时, getAll 就无法将所有记录作为一个数组获取。
该怎么办呢?

光标提供了解决这一问题的方法。

光标是一种特殊的对象,它在给定查询的情况下遍历对象存储,一次返回一个键/值,从而节省内存。**

由于对象存储是按键在内部排序的,因此游标按键顺序(默认为升序)遍历存储。

语法:

  1. // 类似于getAll,但带有游标:
  2. let request = store.openCursor(query, [direction]);
  3. // 获取键,而不是值(例如getAllKeys):store.openKeyCursor


  • query 是一个键或键范围,与 getAll相同。
  • direction 是一个可选参数,使用顺序是:
    • "next" — 默认值,光标从有最小索引的记录向上移动。
    • "prev" — 相反的顺序:从有最大的索引的记录开始下降。
    • "nextunique""prevunique" — 同上,但是跳过键相同的记录 (仅适用于索引上的光标,例如,对于价格为5的书,仅返回第一本)。

光标对象的主要区别在于request.onSuccess多次触发:每个结果触发一次。

  1. let transaction = db.transaction("books");
  2. let books = transaction.objectStore("books");
  3. let request = books.openCursor();
  4. // 为光标找到的每本书调用
  5. request.onsuccess = function() {
  6. let cursor = request.result;
  7. if (cursor) {
  8. let key = cursor.key; // 书的键(id字段)
  9. let value = cursor.value; // 书本对象
  10. console.log(key, value);
  11. cursor.continue();
  12. } else {
  13. console.log("没有书了");
  14. }
  15. };

主要的光标方法有:

  • advance(count) — 将光标向前移动 count次,跳过值。
  • continue([key]) — 将光标移至匹配范围中的下一个值(如果给定键,紧接键之后)。

无论是否有更多的值匹配光标 —— 调用 onsuccess。结果中,我们可以获得指向下一条记录的光标,或者未定义的undefined

在上面的示例中,光标是为对象存储创建的。

也可以在索引上创建一个光标。索引是允许按对象字段进行搜索的。在索引上的光标与在对象存储上的光标完全相同 — 它们通过一次返回一个值来节省内存。

对于索引上的游标,cursor.key 是索引键(例如:价格),我们应该使用cursor.primaryKey 属性作为对象的键。

  1. let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));
  2. // 为每条记录调用
  3. request.onsuccess = function() {
  4. let cursor = request.result;
  5. if (cursor) {
  6. let key = cursor.primaryKey; // 下一个对象存储键(id字段)
  7. let value = cursor.value; // 下一个对象存储对象(book对象)
  8. let key = cursor.key; // 下一个索引键(price)
  9. console.log(key, value);
  10. cursor.continue();
  11. } else {
  12. console.log("No more books"); // 没有书了
  13. }
  14. };

Promise 包装器

onsuccess/onerror 添加到每个请求是一项相当麻烦的任务。我们可以通过使用事件委托(例如,在整个事务上设置处理程序)来简化我们的工作,但是 async/await 要方便的多。

在本章,我们会进一步使用一个轻便的承诺包装器 https://github.com/jakearchibald/idb。它使用 promisified IndexedDB方法创建全局IDB对象。

然后,我们可以不使用 onsuccess/onerror ,而是这样写:

  1. let db = await idb.openDb('store', 1, db => {
  2. if (db.oldVersion == 0) {
  3. // 执行初始化
  4. db.createObjectStore('books', {keyPath: 'id'});
  5. }
  6. });
  7. let transaction = db.transaction('books', 'readwrite');
  8. let books = transaction.objectStore('books');
  9. try {
  10. await books.add(...);
  11. await books.add(...);
  12. await transaction.complete;
  13. console.log('jsbook saved'); // js书已保存
  14. } catch(err) {
  15. console.log('error', err.message);
  16. }

现在我们有了可爱的“简单异步代码”和“try..catch”捕获的东西。

错误处理

An uncaught error becomes an “unhandled promise rejection” event on window object.

如果我们没有捕获到错误,那么程序将一直失败,直到外部最近的 try..catch 捕获到为止。

未捕获的错误将成为window 对象上的“unhandled promise rejection”事件。

我们可以这样处理这种错误:

  1. window.addEventListener('unhandledrejection', event => {
  2. let request = event.target; // IndexedDB本机请求对象
  3. let error = event.reason; // 未处理的错误对象,与request.error相同
  4. ...报告错误...
  5. });

“非活跃交易”陷阱 “Inactive transaction” pitfall

我们都知道,浏览器一旦执行完成当前的代码和微任务之后,事务就会自动提交。

因此,如果我们在事务中间放置一个类似fetch 的宏任务,事务只是会自动提交,而不会等待它执行完成。因此,下一个请求会失败。

对于promise 包装器和 async/await ,情况是相同的。

这是在事务中间进行fetch 的示例:

  1. let transaction = db.transaction("inventory", "readwrite");
  2. let inventory = transaction.objectStore("inventory"); // 库存
  3. await inventory.add({ id: 'js', price: 10, created: new Date() });
  4. await fetch(...); // (*)
  5. await inventory.add({ id: 'js', price: 10, created: new Date() }); // 错误

fetch (*) 后的下一个inventory.add 失败,出现“非活动事务 inactive transaction”错误,因为这时事务已经被提交并且关闭了。

解决方法与使用本机IndexedDB时相同:进行新事务,或者将事物分开。

  1. 准备数据,先获取所有需要的信息。
  2. 然后保存在数据库中。

    获取本机对象

    在内部,包装器执行本机IndexedDB请求,并添加onerror/onsuccess方法,并返回 拒绝/解决 结果的promise。

在大多数情况下都可以运行, 示例在这 https://github.com/jakearchibald/idb

极少数情况下,我们需要原始的 request 对象。可以将promise的promise.request 属性,当作原始对象进行访问:

  1. let promise = books.add(book); // 获取promise对象(不要await结果)
  2. let request = promise.request; // 本地请求对象
  3. let transaction = request.transaction; // 本地事务对象
  4. // ... 做些本地的IndexedDB的处理...
  5. let result = await promise; // 如果仍然需要

总结

IndexedDB可以被认为是“localStorage on steroids”。这是一个简单的键值数据库,功能强大到足以支持离线应用,而且用起来比较简单。

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

基本用法可以用几个短语来描述:

  1. 获取一个promise包装器像 idb.
  2. 打开一个数据库: idb.openDb(name, version, onupgradeneeded)
    • Create object storages and indexes in onupgradeneeded handler or perform version update if needed.
    • 在onupgradeneeded处理程序中创建对象存储和索引,或者根据需要执行版本更新。
  3. 对于请求:
    • 创建事务 db.transaction('books') (如果需要的话,设置readwrite).
    • 获取对象存储 transaction.objectStore('books').
  4. 按键搜索,可以直接调用对象存储区上的方法。
    • 要按对象字段搜索,需要创建索引。
  5. 如果内存中容纳不下数据,请使用光标。

这里有一个小应用程序示例: