使用 IndexedDB 的最佳实践

了解在常用的状态管理库 IndexedDB 之间同步应用状态的最佳做法。

当用户首次加载网站或应用时,构建用于呈现界面的初始应用状态通常需要完成大量工作。例如,有时应用需要先对用户客户端进行身份验证,然后发出几个 API 请求,然后才能获得要在页面上显示的所有数据。

将应用状态存储在 IndexedDB 中是缩短重复访问加载时间的好方法。然后,应用可以在后台与任何 API 服务同步,并采用“过时重新验证”策略,使用新数据延迟更新界面。

IndexedDB 的另一个良好用途是存储用户生成的内容,既可以在上传到服务器之前作为临时存储区,也可以作为远程数据的客户端缓存存储,当然,两者兼有。

但是,在使用 IndexedDB 时,有许多重要事项需要注意,而对于刚开始接触 API 的开发者而言,这些事项可能并不明显。本文将解答常见问题,并讨论在 IndexedDB 中保留数据时一些最重要的注意事项。

确保应用可预测

IndexedDB 的许多复杂性源自您(开发者)无法控制的许多因素。本部分探讨了在使用 IndexedDB 时必须注意的许多问题。

并非所有平台都可以存储在 IndexedDB 中

如果您要存储用户生成的大型文件(例如图片或视频),则可以尝试将它们存储为 FileBlob 对象。这适用于某些平台,但在某些平台上会失败。特别是 iOS 上的 Safari,无法将 Blob 存储在 IndexedDB 中。

幸运的是,将 Blob 转换为 ArrayBuffer 并不难,反之亦然。我们非常支持在 IndexedDB 中存储 ArrayBuffer

但请注意,Blob 具有 MIME 类型,而 ArrayBuffer 则没有。您需要将类型与缓冲区一起存储才能正确进行转换。

如需将 ArrayBuffer 转换为 Blob,您只需使用 Blob 构造函数即可。

function arrayBufferToBlob(buffer, type) {
  return new Blob([buffer], { type: type });
}

另一个方向稍微涉及一些,是一个异步过程。您可以使用 FileReader 对象将 blob 作为 ArrayBuffer 读取。读取完成后,读取器上会触发一个 loadend 事件。您可以将此过程封装在 Promise 中,如下所示:

function blobToArrayBuffer(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.addEventListener('loadend', () => {
      resolve(reader.result);
    });
    reader.addEventListener('error', reject);
    reader.readAsArrayBuffer(blob);
  });
}

写入存储空间可能会失败

写入 IndexedDB 时出错的原因有很多,在某些情况下,这些原因超出了开发者的控制范围。例如,某些浏览器目前不允许在无痕浏览模式下向 IndexedDB 写入数据。另外,用户使用的设备可能即将用尽磁盘空间,浏览器会限制您存储任何内容。

因此,始终在 IndexedDB 代码中实现适当的错误处理至关重要。这也意味着,通常最好将应用状态保留在内存中(除了将其存储下来),这样在无痕浏览模式下运行或没有存储空间时界面不会中断(即使某些需要存储空间的应用功能无法正常运行)。

每次创建 IDBDatabaseIDBTransactionIDBRequest 对象时,您都可以通过为 error 事件添加事件处理程序来捕获 IndexedDB 操作中的错误。

const request = db.open('example-db', 1);
request.addEventListener('error', (event) => {
  console.log('Request error:', request.error);
};

用户可能修改或删除了存储的数据

与您可以限制未经授权的访问的服务器端数据库不同,客户端数据库可通过浏览器扩展程序和开发者工具访问,并且可由用户清除。

虽然用户可能不常见的修改其本地存储的数据,但用户清除这些数据却很常见。您的应用必须能够处理这两种情况而不会出错,这一点非常重要。

存储的数据可能已过期

与上一部分类似,即使用户自己没有修改数据,其存储空间中的数据也可能是由旧版本的代码(可能是版本存在错误的版本)编写的。

IndexedDB 内置了对架构版本和通过其 IDBOpenDBRequest.onupgradeneeded() 方法升级的支持;但是,您仍然需要在编写升级代码时,使其能够处理来自先前版本的用户(包括存在 bug 的版本)。

此时,单元测试会非常有用,因为手动测试所有可能的升级路径和情形通常是不可行的。

保持应用性能

IndexedDB 的主要功能之一是其异步 API,但不要因此而误认为在使用 IndexedDB 时无需担心性能。在很多情况下,使用不当仍可能阻塞主线程,从而导致卡顿和无响应。

一般来说,对 IndexedDB 的读写不应超过访问数据所需的大小。

尽管 IndexedDB 可以将大型嵌套对象存储为单个记录(从开发者的角度来看,这样做显然非常方便),但应避免这种做法。原因在于,当 IndexedDB 存储对象时,必须先创建该对象的结构化克隆,并且结构化克隆过程在主线程上进行。对象越大,阻塞时间就越长。

在规划如何将应用状态保留到 IndexedDB 时,这会带来一些挑战,因为大多数常用的状态管理库(如 Redux)都是通过将整个状态树作为单个 JavaScript 对象进行管理的。

虽然以这种方式管理状态有很多好处(例如,可以使代码易于推理和调试),虽然简单地将整个状态树作为单个记录存储在 IndexedDB 中可能既有吸引力又很方便,但在每次更改之后执行此操作(即使已进行限制/去抖动)会导致主线程发生不必要的阻塞,但会增加发生写入错误或导致浏览器崩溃的可能性,甚至在某些情况下,甚至会导致标签页崩溃。

您应该将整个状态树分解成单独的记录并仅更新实际更改的记录,而不是将整个状态树存储在单个记录中。

如果将图片、音乐或视频等大型内容存储在 IndexedDB 中,也是如此。请使用自己的键存储每个项,而不是存储在更大的对象中,这样您可以检索结构化数据,而无需支付检索二进制文件的开销。

与大多数最佳做法一样,这也是“一刀切”。在无法拆分状态对象并仅写入最小更改集的情况下,将数据分解为子树并仅写入子树比始终写入整个状态树更可取。细微的改进总比没有改进要好。

最后,您应始终衡量所编写的代码对性能的影响。虽然对 IndexedDB 进行少量写入操作的执行效果确实优于大规模写入,但只有当对 IndexedDB 的写入操作实际上会导致耗时较长的任务(阻塞主线程并降低用户体验)时,才有意义。衡量效果非常重要,因为这样您才能了解优化目标

总结

开发者不仅可以在会话之间保持状态,还可以减少重复访问时加载初始状态所需的时间,从而利用 IndexedDB 等客户端存储机制来改善应用的用户体验。

虽然正确使用 IndexedDB 可以显著改善用户体验,但不正确使用 IndexedDB 或无法处理错误情况可能会导致应用崩溃和用户不满。

由于客户端存储涉及许多不受您控制的因素,因此请务必对您的代码进行充分测试并正确处理错误,即使是最初看起来不太可能发生的错误也是如此。