IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Folo 中的状态管理 - 数据库篇

    Hyoban发表于 2025-06-17 10:50:53
    love 0

    最近将 Folo 桌面端和移动端中的状态管理合并到了同一的模块中,就想着记录一下相关的设计和踩坑经验。(很多是从 Innei 的实践中总结出来的,学习到了很多)

    文章大概会有两到三篇,本文中主要介绍数据库的选型和整合。

    为什么需要数据库?

    如果应用较为简单,一般可以直接使用 TanStack Query / SWR 的 Cache 来持久化请求到的数据,以改善应用首屏加载的加载体验。但是这样的话,一般对于缓存数据的操作会比较麻烦,也可能缺少类型安全。因此手动控制数据的持久化和预加载,将缓存的管理变得和 TanStack Query/SWR 无关,可能长期看来更好维护。

    数据库的选型

    因为在移动端使用了 Expo SQLite, 为了保持数据库 schema 一致,避免写两套数据库操作的代码,在桌面端就使用了 SQLite WASM 的方案。或许也可以看看 PGlite。

    在浏览器中运行 SQLite 一般可以使用以下几个库:

    • sql.js 已知的第一个在 Web 浏览器中直接使用 sqlite3 的程序
      • 只支持内存数据库,除了一次性导入导出整个数据库文件外,不支持持久化。
    • wa-sqlite 已知的第一个的 OPFS 存储实现 sqlite3 数据库,支持很多类型的 VFS(来源)。
    • SQLite Wasm sqlite3 WebAssembly 的 javascript 包装
      • SQLocal,在 SQLite Wasm 上构建,添加了更高级别的抽象,以便与 SQLite Wasm 交互(来源)。包括与 Kysely 和 Drizzle ORM 的集成。

    关于这三者的比较相关信息,可以查看 how is this different from the @rhashimoto/wa-sqlite and sql.js?。从暴露出来的 API 访问级别来看是,SQLite Wasm < wa-sqlite < sql.js,SQLite Wasm 最底层。

    最后,SQLocal 是 Folo 桌面端的数据库方案,因为它基于官方的 SQLite Wasm,由 SQLite 核心团队构建,在维护方面的表现应该会更好(来源)。

    SQLite 在浏览器中的运行模式

    SQLite 在浏览器中的运行模式主要有三种,在 sqlite3 WebAssembly & JavaScript Documentation 中有详细的介绍。

    • Key-Value VFS (kvvfs):在主 UI 线程中运行,使用如 localStorage 或 IndexedDB 来持久化数据。问题是存储空间有限,性能相对较差。
    • The Origin-Private FileSystem (OPFS):在 Worker 中运行,OPFS 对于浏览器的要求相对较高,需要 23 年 3 月之后的浏览器版本。
      • OPFS via sqlite3_vfs:需要 COOP 和 COEP HTTP 标头以使用 SharedArrayBuffer,这个要求较高,比较难以满足。对于图片的加载和外站资源的引入都需要额外的配置。
      • OPFS SyncAccessHandle Pool VFS:不需要 COOP 和 COEP HTTP 标头,性能相对更好,但不支持并发连接,文件系统不透明(即并非将数据库保存为一个 sqlite 文件)

    这些运行模式各有优劣,第一种性能较差,存储空间有限,但对浏览器的要求最低,因此仍有很多应用使用它来存储数据库到 indexedDB。第二种对于 COOP 和 COEP HTTP 标头的要求较高,难以满足,但第三种的并发支持又比较麻烦有限。因此,可以在条件允许的情况下,使用第二种,否则回退到第三种。值得一提的是,PGlite 的文件系统也很相似,在浏览器中同样是 In-memory FS、IndexedDB FS、OPFS AHP FS 三种 (来源)。

    前面提到 OPFS SAH 不支持并发,默认情况下,用户打开两个窗口时就会出错。要如何解决呢?需要从多个客户端中协商出一个可以执行查询的,然后暂停其他客户端的使用。PGlite 也有类似的 Multi-tab Worker 实现。目前 SQLocal 还没有对 OPFS SAH 的支持,相关的 issue 可以查看 Allow using sqlite's OPFS_SAH backend。我基于作者的实现分支进行了一些探索,实现了基础的支持,但目前测试还未完全通过 (PR)。

    所以 Folo 中会使用哪种运行模式呢?在本地使用网页代理来开发时,由于跨源运行 worker 的限制,会使用 Key-Value VFS;网页端和桌面端的生产环境中,因为 COOP 和 COEP HTTP 标头的条件无法满足,使用 OPFS SAH VFS;

    不过桌面端 Electron 中,也可以直接开启 SharedArrayBuffer 的支持,来使用 OPFS via sqlite3_vfs。

    app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer")
    

    值得一提的是,由于 Electron 中使用的协议不同,一般是 file:// 或是自定义的 app://,因此为了访问安全环境的中才有的 API,需要注册协议。

    // https://github.com/getsentry/sentry-electron/issues/661
    protocol.registerSchemesAsPrivileged([
      {
        scheme: "sentry-ipc",
        privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
      },
      {
        scheme: "app",
        privileges: {
          standard: true,
          bypassCSP: true,
          supportFetchAPI: true,
          secure: true,
        },
      },
    ])
    

    由于 registerSchemesAsPrivileged 这个 API 最好只被调用一次,所以如果使用了 sentry 的话,推荐将它的的 registerSchemesAsPrivileged 调用给 patch 掉,然后在自己的代码中调用。

    如何为多端复用代码?

    显然桌面端和移动端的 SQLite Client 是不同的,所以在打包的时候需要为不同的平台导入不同的文件。Folo 的代码使用后缀来区分,比如 db.desktop.ts 用于桌面端,db.rn.ts 用于移动端。Vite 可以通过插件来实现(代码),Metro 可以通过自定义 resolver.resolveRequest 来实现(代码)。

    这样就可以给每个平台提供不同的数据库实现了。db.ts 中定义类型,db.desktop.ts 和 db.rn.ts 中实现具体逻辑。这里由于使用了 Drizzle ORM,所以自然用上了 Drizzle 的数据表类型定义,来给数据库的操作提供一定的类型安全。至于实际的数据库操作,则和平常写 Drizzle 的代码没有区别。

    // db.ts
    import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"
    
    import type * as schema from "./schemas"
    
    type DB =
      | BaseSQLiteDatabase<"async", any, typeof schema>
      | BaseSQLiteDatabase<"sync", any, typeof schema>
    
    export declare const sqlite: unknown
    export declare const db: DB
    export declare function initializeDB(): void
    export declare function migrateDB(): Promise<void>
    export declare function exportDB(): Promise<Blob>
    

    数据库迁移

    • Drizzle Kit 有非常好用的 migrate 工具,可以通过 drizzle-kit generate 命令来生成迁移文件。它和 Expo SQLite 的整合使用已经有完善的文档来说明,这里不多赘述。桌面端的迁移可以基于这套方案。
    • 因为 migrate 的运行时代码并不依赖 Node,所以也可以在 Web 端来运行(代码)。
    • 由于生成的 SQL 文件引入语句是直接 import 的,所以为了照顾移动端,这里不使用 Vite 的 ?raw,而是自定义一个插件,将 SQL 文件文本转成正常的 js 模块导出(代码)。

    最后

    这一套下来就能在 Folo 中使用单独的包来维护数据库增删改查相关的逻辑,并且多端的代码实现了复用,减少维护的成本和潜在的实现不一致导致的问题。

    最后留一个小 Tip,Drizzle ORM 的更新操作处理更新值的时候有些麻烦,需要手写每一列名,且没有类型安全,可以创建一个简单的 helper 函数(来源)。

    阅读更多

    • The Current State of SQLite Persistence on the Web: February 2024 Update
    • How we sped up Notion in the browser with WASM SQLite


沪ICP备19023445号-2号
友情链接