使用 IndexedDB 的最佳实践

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

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

将应用状态存储在 IndexedDB 中可以有效缩短重复访问的加载时间。然后,应用就可以在后台与任何 API 服务同步,并采用 stale-while- revalidate 策略,延迟用新数据更新界面。

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

然而,在使用 IndexedDB 时,需要考虑的许多重要事项对刚开始接触 API 的开发者而言可能并不立即显而易见。本文解答了常见问题,并讨论了在 IndexedDB 中保留数据时需要注意的一些重要事项。

确保应用可预测

IndexedDB 的许多复杂性都源于一个事实,那就是您(开发者)无法控制的因素太多了。本节探讨在使用 IndexedDB 时必须注意的许多问题。

并非所有内容都可以存储在所有平台上的 IndexedDB 中

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

幸运的是,将 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 写入数据。此外,用户所使用的设备上的磁盘可用空间也即将用尽,而浏览器将限制您存储任何内容。

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

您可以在每次创建 IDBDatabaseIDBTransactionIDBRequest 对象时为 error 事件添加事件处理脚本,以捕获 IndexedDB 操作中的错误。

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

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

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

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

存储的数据可能已过时

与上一部分类似,即使用户本身没有修改过数据,他们存储空间中的数据也有可能是由旧版代码(可能是存在 bug 的版本)写入的。

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

单元测试在这里会非常有用,因为手动测试所有可能的升级路径和用例通常不可行。

让应用保持高性能

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

一般来说,对 IndexedDB 的读取和写入操作不应大于所访问数据所需的大小。

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

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

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

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

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

与大多数最佳做法一样,这不是一刀切的规则。如果不可分解状态对象而仅写入最小的更改集,则将数据分解为子树,仅写入子树仍比始终写入整个状态树更可取。稍有改进,总比不做改进要好。

最后,您应始终衡量所编写的代码对性能的影响。虽然对 IndexedDB 的小规模写入确实优于大写入,但只有在您的应用执行的 IndexedDB 写入实际上会导致长任务阻塞主线程并使用户体验下降时,这一点才很重要。请务必衡量效果,以便您了解优化目标。

总结

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

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

由于客户端存储涉及许多超出您控制的因素,因此您的代码必须经过充分测试并正确处理错误,即使是最初可能看起来不可能发生的错误,这一点至关重要。