最近将 Folo 桌面端和移动端中的状态管理合并到了同一的模块中,就想着记录一下相关的设计和踩坑经验。(很多是从 Innei 的实践中总结出来的,学习到了很多)
文章大概会有两到三篇,本文中主要介绍数据库的选型和整合。
如果应用较为简单,一般可以直接使用 TanStack Query / SWR 的 Cache 来持久化请求到的数据,以改善应用首屏加载的加载体验。但是这样的话,一般对于缓存数据的操作会比较麻烦,也可能缺少类型安全。因此手动控制数据的持久化和预加载,将缓存的管理变得和 TanStack Query/SWR 无关,可能长期看来更好维护。
因为在移动端使用了 Expo 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 在浏览器中的运行模式主要有三种,在 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>
drizzle-kit generate
命令来生成迁移文件。它和 Expo SQLite 的整合使用已经有完善的文档来说明,这里不多赘述。桌面端的迁移可以基于这套方案。?raw
,而是自定义一个插件,将 SQL 文件文本转成正常的 js 模块导出(代码)。这一套下来就能在 Folo 中使用单独的包来维护数据库增删改查相关的逻辑,并且多端的代码实现了复用,减少维护的成本和潜在的实现不一致导致的问题。
最后留一个小 Tip,Drizzle ORM 的更新操作处理更新值的时候有些麻烦,需要手写每一列名,且没有类型安全,可以创建一个简单的 helper 函数(来源)。