返回

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

1,731 字

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

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

为什么需要数据库?

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

数据库的选型

因为在移动端使用了 [Expo SQLite](最早的尝试可以看这篇)(https://docs.expo.dev/versions/latest/sdk/sqlite), 为了保持数据库 schema 一致,避免写两套数据库操作的代码,在桌面端就使用了 SQLite WASM 的方案。 或许也可以看看 PGlite。

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

关于这三者的比较相关信息,可以查看 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 中有详细的介绍。

这些运行模式各有优劣,第一种性能较差,存储空间有限,但对浏览器的要求最低,因此仍有很多应用使用它来存储数据库到 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>

数据库迁移

最后

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

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

阅读更多

Esc