logo

LocalStorage vs. IndexedDB vs. Cookies vs. OPFS vs. WASM-SQLite

Published on

所以你正在构建一个 Web 应用程序,并希望在用户的浏览器中存储数据。也许你只需要存储一些小的标志,或者你甚至需要一个完整的数据库。

我们构建的 Web 应用程序类型已经发生了显著变化。在 Web 的早期,我们提供静态 HTML 文件。然后我们提供动态渲染的 HTML,后来我们构建了单页应用程序,这些应用程序在客户端运行大部分逻辑。在未来几年,你可能想要构建所谓的“本地优先”应用程序,这些应用程序仅在客户端处理大型和复杂的数据操作,甚至在离线时也能工作,这为你提供了构建零延迟用户交互的机会。

在 Web 的早期,Cookies是存储小型键值对的唯一选项。但 JavaScript 和浏览器已经显著发展,并添加了更好的存储 API,为更大和更复杂的数据操作铺平了道路。

在本文中,我们将深入探讨在浏览器中存储和查询数据的各种技术。我们将探索传统方法,如 Cookies、localStorage、WebSQL、IndexedDB,以及较新的解决方案,如 OPFS 和通过 WebAssembly 的 SQLite。我们比较这些方法的功能和限制,并通过性能测试揭示在 Web 应用程序中使用各种方法写入和读取数据的速度。

现代浏览器中的可用存储 API

首先,让我们简要概述不同的 API、它们的预期用途和历史:

什么是 Cookies

Cookies 最早由 Netscape 在 1994 年引入。Cookies 存储小块键值数据,主要用于会话管理、个性化和跟踪。Cookies 可以有多种安全设置,如生存时间或域属性,以便在多个子域之间共享 Cookies。

Cookies 值不仅存储在客户端,还会随每个 HTTP 请求发送到服务器。这意味着我们不能在 Cookie 中存储大量数据,但仍然有趣的是,Cookie 访问性能与其他方法相比如何。特别是因为 Cookies 是 Web 的重要基础功能,已经进行了许多性能优化,甚至在这些天仍在取得进展,如 Chromium 的共享内存版本控制或异步 CookieStore API

什么是 LocalStorage

LocalStorage API 最早在 2009 年作为 WebStorage 规范的一部分提出。LocalStorage 提供了一个简单的 API 来在 Web 浏览器中存储键值对。它具有setItemgetItemremoveItemclear方法,这就是你从键值存储中需要的一切。LocalStorage 仅适合存储需要跨会话持久化的小量数据,并且受限于 5MB 的存储上限。存储复杂数据只能通过将其转换为字符串来实现,例如使用JSON.stringify()。该 API 不是异步的,这意味着在执行操作时会完全阻塞 JavaScript 进程。因此,在其上运行繁重的操作可能会阻止 UI 渲染。

还有 SessionStorage API。关键区别在于 localStorage 数据会无限期持久化,直到显式清除,而 sessionStorage 数据在浏览器标签页或窗口关闭时会被清除。

什么是 IndexedDB

IndexedDB 最早在 2015 年作为“索引数据库 API”引入。

IndexedDB 是一个低级 API,用于存储大量结构化 JSON 数据。虽然 API 有点难用,但 IndexedDB 可以利用索引和异步操作。它缺乏对复杂查询的支持,只允许迭代索引,这使得它更像是其他库的基础层,而不是一个完整的数据库。

在 2018 年,IndexedDB 2.0 版本引入了一些重大改进。最显著的是getAll()方法,在获取大量 JSON 文档时显著提高了性能。

IndexedDB 3.0 版本正在开发中,包含许多改进。最重要的是添加了基于 Promise 的调用,使现代 JS 功能如 async/await 更有用。

什么是 OPFS

Origin Private File System (OPFS)是一个相对较新的 API,允许 Web 应用程序直接在浏览器中存储大型文件。它专为数据密集型应用程序设计,旨在在模拟文件系统中写入和读取二进制数据。

OPFS 可以在两种模式下使用:

  • 在主线程上异步
  • 或在 WebWorker 中使用更快的、异步访问的createSyncAccessHandle()方法。

由于只能处理二进制数据,OPFS 被设计为库开发者的基础文件系统。当你构建一个“正常”应用程序时,你不太可能直接想要使用 OPFS,因为它太复杂了。只有在存储纯文件(如图像)时才有意义,而不是有效地存储和查询 JSON 数据。

什么是 WASM SQLite

WebAssembly (Wasm)是一种二进制格式,允许在 Web 上执行高性能代码。Wasm 在 2017 年被添加到主要浏览器中,这为在浏览器中运行的内容打开了广泛的机会。你可以将本地库编译为 WebAssembly,并在客户端上运行,只需进行少量调整。WASM 代码可以被传输到浏览器应用程序中,通常比 JavaScript 运行得更快,但仍然比本地慢约 10%。

许多人开始使用编译的 SQLite 作为浏览器内的数据库,这就是为什么将这种设置与本地 API 进行比较是有意义的。

SQLite 的编译字节码大小约为 938.9 kB,用户在首次页面加载时必须下载和解析。WASM 不能直接访问浏览器中的任何持久存储 API。相反,它需要数据从 WASM 流向主线程,然后可以放入浏览器的一个 API 中。这是通过所谓的 VFS(虚拟文件系统)适配器来处理的,这些适配器处理从 SQLite 到其他任何东西的数据访问。

什么是 WebSQL

WebSQL 是 2009 年引入的一个 Web API,允许浏览器在客户端存储中使用 SQL 数据库,基于 SQLite。其想法是为开发者提供一种在客户端使用 SQL 存储和查询数据的方法,类似于服务器端数据库。WebSQL 在当前几年中已从浏览器中移除,原因有多种:

  • WebSQL 没有标准化,基于单一特定实现(SQLite 源代码)的 API 很难成为标准。
  • WebSQL 要求浏览器使用特定版本的 SQLite(版本 3.6.19),这意味着每当 SQLite 有任何更新或错误修复时,无法将其添加到 WebSQL 中而不可能破坏 Web。
  • 主要浏览器如 Firefox 从未支持 WebSQL。

因此,在接下来的内容中,我们将忽略 WebSQL,即使可以通过设置特定的浏览器标志或使用旧版本的 Chromium 来运行测试。

功能比较

现在你了解了 API 的基本概念,让我们比较一些对使用 RxDB 和基于浏览器的存储通常重要的特定功能。

存储复杂的 JSON 文档

当你在 Web 应用程序中存储数据时,通常你想要存储复杂的 JSON 文档,而不仅仅是“普通”值,如在服务器端数据库中存储的整数和字符串。

  • 只有 IndexedDB 可以原生处理 JSON 对象。

  • 使用 SQLite WASM,你可以将 JSON 存储在文本列中,自版本 3.38.0(2022-02-22)起,甚至可以对其进行深度查询,并使用单个属性作为索引。

其他所有 API 只能存储字符串或二进制数据。当然,你可以使用JSON.stringify()将任何 JSON 对象转换为字符串,但在 API 中没有 JSON 支持可能会使运行查询变得复杂,并且多次运行JSON.stringify()可能会导致性能问题。

多标签页支持

与 Electron 或 React-Native 相比,构建 Web 应用程序时的一个大区别是,用户会在多个浏览器标签页中同时打开和关闭应用程序。因此,你不仅有一个 JavaScript 进程在运行,而且可能有多个进程存在,并可能需要在它们之间共享状态更改,以免向用户显示过时的数据。

如果用户的肌肉记忆在使用你的网站时将左手放在 F5 键上,那你就做错了!

并非所有存储 API 都支持自动在标签页之间共享写入事件的方法。

只有 localStorage 通过 API 本身的storage-event可以自动在标签页之间共享写入事件,可以用来观察更改。

// localStorage可以通过storage事件观察更改。
// IndexedDB和其他API缺少此功能
addEventListener('storage', (event) => {})

Chrome曾有实验性的experimental IndexedDB observers API,但提案库已被存档。

为了解决这个问题,有两种解决方案:

  • 第一种选择是使用BroadcastChannel API,它可以在浏览器标签页之间发送消息。因此,每当你对存储进行写入时,你也会向其他标签页发送通知,告知它们这些更改。这是最常见的解决方法,RxDB也使用了这种方法。注意,还有WebLocks API可以用于在浏览器标签页之间进行互斥。
  • 另一种解决方案是使用SharedWorker,并在该工作者中进行所有写入。所有浏览器标签页都可以订阅来自该单个SharedWorker的消息,并了解更改。

索引支持

数据库与在普通文件中存储数据的最大区别在于,数据库以一种允许通过索引运行操作的格式写入数据,以便进行快速高效的查询。在我们的技术列表中,只有IndexedDBWASM SQLite支持开箱即用的索引。理论上,你可以在任何存储上构建索引,如localStorage或OPFS,但你可能不想自己这样做。

例如,在IndexedDB中,我们可以通过给定的索引范围获取一批文档:

// 查找价格在10到50之间的所有产品
const keyRange = IDBKeyRange.bound(10, 50)
const transaction = db.transaction('products', 'readonly')
const objectStore = transaction.objectStore('products')
const index = objectStore.index('priceIndex')
const request = index.getAll(keyRange)
const result = await new Promise((res, rej) => {
  request.onsuccess = (event) => res(event.target.result)
  request.onerror = (event) => rej(event)
})

注意,IndexedDB有一个限制,即不能在布尔值上建立索引。你只能索引字符串和数字。为了解决这个问题,你必须在存储数据时将布尔值转换为数字,并在读取时转换回来。

WebWorker 支持

当运行繁重的数据操作时,你可能希望将处理移到 JavaScript 主线程之外。这确保了我们的应用程序在处理可以在后台并行运行时保持响应和快速。在浏览器中,你可以使用 WebWorkerSharedWorkerServiceWorker API 来实现。在 RxDB 中,你可以使用 WebWorker 或 SharedWorker 插件将存储移到工作者中。

对于这种用例,最常见的 API 是生成一个 WebWorker,并在该第二个 JavaScript 进程中进行大部分工作。工作者从一个单独的 JavaScript 文件(或 base64 字符串)生成,并通过postMessage()发送数据与主线程通信。

不幸的是,由于设计和安全限制,LocalStorage 和 Cookies 不能在 WebWorker 或 SharedWorker 中使用。WebWorkers 在与主浏览器线程分开的全局上下文中运行,因此不能执行可能影响主线程的操作。它们无法直接访问某些 Web API,如 DOM、localStorage 或 cookies。

其他所有内容都可以在 WebWorker 中使用。使用createSyncAccessHandle方法的 OPFS 快速版本只能在 WebWorker 中使用,而不能在主线程上使用。这是因为返回的 AccessHandle 的所有操作都不是异步的,因此会阻塞 JavaScript 进程,所以你不想在主线程上这样做并阻塞一切。

存储大小限制

  • Cookies 在 RFC-6265 中限制为大约 4 KB 的数据。由于存储的 Cookies 会随每个 HTTP 请求发送到服务器,这一限制是合理的。你可以在这里测试浏览器的 Cookie 限制。注意,你不应该填满 Cookies 的全部 4 KB,因为你的 Web 服务器不会接受过长的头,并会拒绝请求,返回 HTTP ERROR 431 - 请求头字段太大。一旦达到这一点,你甚至无法向用户提供更新的 JavaScript 来清理 Cookies,并且你将锁定该用户,直到 Cookies 被手动清理。

  • LocalStorage 的存储大小限制因浏览器而异,但通常在每个来源 4 MB 到 10 MB 之间。你可以在这里测试你的 localStorage 大小限制。

    • Chrome/Chromium/Edge:每个域 5 MB
    • Firefox:每个域 10 MB
    • Safari:每个域 4-5 MB(在不同版本之间略有变化)
  • IndexedDB 没有像 localStorage 那样的特定固定大小限制。IndexedDB 的最大存储大小取决于浏览器实现。上限通常基于用户设备上的可用磁盘空间。在 Chromium 浏览器中,它可以使用高达 80%的总磁盘空间。你可以通过调用await navigator.storage.estimate()来获得关于存储大小限制的估计。通常你可以存储数 GB 的数据。

  • OPFS 与 IndexedDB 具有相同的存储大小限制。其限制取决于可用磁盘空间。这也可以在这里测试。

性能比较

现在我们已经回顾了每种存储方法的功能,让我们深入性能比较,重点关注初始化时间、读/写延迟和批量操作。

注意,我们只进行简单测试,对于你的应用程序中的特定用例,结果可能会有所不同。此外,我们只在 Google Chrome(版本 128.0.6613.137)中比较性能。Firefox 和 Safari 的性能模式相似但不相同。你可以从这个 GitHub 存储库中在自己的机器上运行测试。对于所有测试,我们限制网络以表现为平均德国互联网速度。(下载:135,900 kbit/s,上传:28,400 kbit/s,延迟:125ms)。此外,所有测试都存储一个“平均”JSON 对象,可能需要根据存储进行字符串化。我们还只测试通过 ID 存储文档的性能,因为某些技术(Cookies、OPFS 和 localStorage)不支持索引范围操作,因此比较这些的性能没有意义。

初始化时间

在你可以存储任何数据之前,许多 API 需要一个设置过程,如创建数据库、生成 WebAssembly 进程或下载额外的东西。为了确保你的应用程序快速启动,初始化时间很重要。

localStorage 和 Cookies 的 API 没有任何设置过程,可以直接使用。IndexedDB 需要打开一个数据库和其中的一个存储。WASM SQLite 需要下载一个 WASM 文件并处理它。OPFS 需要下载并启动一个工作者文件并初始化虚拟文件系统目录。

以下是存储第一个数据位所需的时间测量:

技术时间(毫秒)
IndexedDB46
OPFS 主线程23
OPFS WebWorker26.8
WASM SQLite(内存)504
WASM SQLite(IndexedDB)535

在这里我们可以注意到一些事情:

  • 打开一个带有单个存储的 IndexedDB 数据库需要的时间出乎意料地长。
  • 将数据从主线程发送到 WebWorker OPFS 的延迟开销约为 4 毫秒。在这里,我们只发送最小的数据来初始化 OPFS 文件处理器。将会很有趣的是,当处理更多数据时,这种延迟是否会增加。
  • 下载和解析 WASM SQLite 并创建一个单表大约需要半秒钟。使用 IndexedDB VFS 来持久存储数据会增加额外的 31 毫秒。启用缓存并已准备好表的情况下重新加载页面稍快,为 420 毫秒(内存)。

小写入的延迟

接下来,让我们测试小写入的延迟。这在你进行许多独立的小数据更改时很重要。例如,当你从 WebSocket 流式传输数据或持久化伪随机发生的事件(如鼠标移动)时。

技术时间(毫秒)
Cookies0.058
LocalStorage0.017
IndexedDB0.17
OPFS 主线程1.46
OPFS WebWorker1.54
WASM SQLite(内存)0.17
WASM SQLite(IndexedDB)3.17

在这里我们可以注意到一些事情:

  • LocalStorage 的写入延迟最低,仅为 0.017 毫秒。

  • IndexedDB 的写入速度比 localStorage 慢约 10 倍。

  • 将数据发送到 WASM SQLite 进程并通过 IndexedDB 持久化的速度较慢,每次写入超过 3 毫秒。

OPFS 操作需要大约 1.5 毫秒将 JSON 数据写入每个文件的一个文档。我们可以看到,首先将数据发送到 WebWorker 稍慢,这来自于两边序列化和反序列化数据的开销。如果我们不为每个文档创建一个 OPFS 文件,而是将所有内容附加到单个文件中,性能模式会显著改变。然后,使用createSyncAccessHandle()的更快文件处理器每次写入仅需约 1 毫秒。但这需要以某种方式记住每个文档存储的位置。因此,在我们的测试中,我们将继续使用每个文档一个文件。

小读取的延迟

现在我们已经存储了一些文档,让我们测量按 ID 读取单个文档所需的时间。

技术时间(毫秒)
Cookies0.132
LocalStorage0.0052
IndexedDB0.1
OPFS 主线程1.28
OPFS WebWorker1.41
WASM SQLite(内存)0.45
WASM SQLite(IndexedDB)2.93

在这里我们可以注意到一些事情:

  • LocalStorage 的读取速度非常快,仅为 0.0052 毫秒。
  • 其他技术的读取速度与其写入延迟相似。

大批量写入

接下来,让我们进行一些大批量操作,一次处理 200 个文档。

技术时间(毫秒)
Cookies20.6
LocalStorage5.79
IndexedDB13.41
OPFS 主线程280
OPFS WebWorker104
WASM SQLite(内存)19.1
WASM SQLite(IndexedDB)37.12

在这里我们可以注意到一些事情:

  • 将数据发送到 WebWorker 并通过更快的 OPFS API 运行速度大约快一倍。
  • WASM SQLite 在批量操作中表现更好,与其单次写入延迟相比。这是因为将数据发送到 WASM 和返回的速度更快,如果是一次性完成而不是每个文档一次。

大批量读取

现在让我们在一次批量请求中读取 100 个文档。

技术时间(毫秒)
Cookies6.34
LocalStorage0.39
IndexedDB4.99
OPFS 主线程54.79
OPFS WebWorker25.61
WASM SQLite(内存)3.59
WASM SQLite(IndexedDB)5.84(35 毫秒无缓存)

在这里我们可以注意到一些事情:

  • 在 OPFS WebWorker 中读取许多文件的速度大约是较慢的主线程模式的两倍。
  • WASM SQLite 的速度出乎意料地快。进一步检查表明,WASM SQLite 进程将文档保存在内存中缓存,这在我们对相同数据进行写入后直接读取时提高了延迟。当浏览器标签页在写入和读取之间重新加载时,找到 100 个文档大约需要 35 毫秒。

性能结论

  • LocalStorage 非常快,但请记住它有一些缺点:
    • 它会阻塞主 JavaScript 进程,因此不应用于大批量操作。
    • 只能进行键值分配,当你需要对数据进行基于索引的范围查询时,无法有效使用它。
  • OPFS 在 WebWorker 中使用createSyncAccessHandle()方法时速度更快,而不是直接在主线程中使用。
  • SQLite WASM 可以很快,但你必须最初下载完整的二进制文件并启动它,这大约需要半秒钟。如果你的应用程序启动一次并长时间使用,这可能无关紧要。但对于在多个浏览器标签页中多次打开和关闭的 Web 应用程序,这可能是个问题。

可能的改进

有多种可能的改进和性能技巧可以加速操作。

  • 对于 IndexedDB,我在这里列出了一些性能技巧。例如,你可以在多个数据库和 WebWorker 之间进行分片,或使用自定义索引策略。
  • OPFS 在为每个文档写入一个文件时速度较慢。但你不必这样做,而是可以像普通数据库一样将所有内容存储在单个文件中。这显著提高了性能,就像在 RxDB OPFS RxStorage 中所做的那样。
  • 你可以混合使用技术,以优化多个场景。例如,在 RxDB 中,有一个 localStorage 元优化器,它将初始元数据存储在 localStorage 中,而将“正常”文档存储在 IndexedDB 中。这提高了初始启动时间,同时仍然以一种可以有效查询的方式存储文档。
  • 在 RxDB 中有一个内存映射存储插件,可以将数据直接映射到内存中。将其与共享工作者结合使用可以显著提高页面加载和查询时间。
  • 在存储数据之前进行压缩可能会提高某些存储的性能。
  • 通过分片在多个 WebWorker 之间分配工作可以通过利用用户设备的全部容量来提高性能。

在这里你可以看到各种 RxDB 存储实现的性能比较,这提供了更好的实际性能视图:

Maple

未来的改进

  • 目前无法直接从 WebAssembly 进程中访问持久存储。如果将来发生变化,在浏览器中运行 SQLite(或类似数据库)可能是最佳选择。
  • 在主线程和 WebWorker 之间发送数据很慢,但将来可能会有所改进。这里有一篇关于为什么postMessage()很慢的好文章。
  • IndexedDB 最近获得了存储桶支持(仅限 Chrome),这可能会提高性能。